diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b2e623..7374b2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,23 @@ 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 +- **The workbench now has independent SQL and saved-query Spec JSON editor + modes** (#212). A visible `SQL | Spec` switch keeps separate per-tab drafts, + undo/search state, dirty flags, and injected CodeMirror adapters. Spec mode + adds local JSON formatting, folding, search, parse markers with line/column, + and synchronously registered semantic validators keyed by exact path arrays; + known core field types are checked while unknown extension fields remain + valid. Linked Save atomically commits SQL plus the current valid Spec in one + Library write, with normalized Name/Description and all other fields/order + retained; invalid Spec persists nothing. Spec is a lightweight editing mode: + its toolbar contains only Format, Save, and the SQL | Spec switch, while Run, + Explain, SQL Format, Export, Share, and Share’s global shortcut are SQL-only. + Validation remains continuous through diagnostics and status. Library pencil, + favorite, and Panel writers merge their changes into every valid open draft, + preserving unrelated unsaved and extension fields; invalid JSON alone blocks + the writer, focuses the affected Spec tab, and persists nothing. Reopening an + already-open saved query activates its existing tab. No package or lockfile + change was needed because JSON language support landed in #213. - **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 diff --git a/CLAUDE.md b/CLAUDE.md index 07efda8b..4139ea32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,10 @@ all bundled — see hard rule 4). Quality is held by tests. globals). Network goes in `src/net/` with the fetch seam *injected*, never imported. DOM rendering goes in `src/ui/` as functions that take the `app` controller — except the editor, which lives in `src/editor/` behind the - injected `EditorPort` seam (#143): only `main.js` imports an adapter, and - everything else talks to `app.editor`. Side-effectful environment access + injected editor seams (#143/#212): only `main.js` imports concrete adapters, + and everything else addresses `app.sqlEditor` or `app.specEditor` explicitly. + SQL execution, schema insertion, export, and SQL formatting must never target + whichever document happens to be visible. Side-effectful environment access (location, crypto, storage, fetch) is injected through `createApp(env)` so everything is testable. 3. **No secrets in git.** `config.json` (rendered) is gitignored; only @@ -29,7 +31,8 @@ all bundled — see hard rule 4). Quality is held by tests. graph in local, CI, and release builds, and update the lock only with an intentional dependency change. There are **four** bundled runtime dependencies — **CodeMirror 6** (the SQL - editor and read-only source viewer, behind injected seams — #21/#213), + editor, saved-query Spec JSON editor, and read-only source viewer, behind + injected seams — #21/#212/#213), **Chart.js** (the Chart result view), **@dagrejs/dagre** (the EXPLAIN pipeline-graph layout), and **@preact/signals-core** (the reactivity primitive — see @@ -40,7 +43,7 @@ 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` / `env.CodeViewer`, like the fetch/crypto seams) + `app.Dagre` / `env.Editor` / `env.SpecEditor` / `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 @@ -52,8 +55,8 @@ all bundled — see hard rule 4). Quality is held by tests. high-frequency-pointer surfaces (the editor, the EXPLAIN/schema graphs, Chart.js, result-grid resize/sort) stay **imperative behind an injected seam** — signals coordinate state, they don't own every mousemove. The editor is - **CodeMirror 6** behind the `EditorPort` seam (#21, landed ahead of #84 — - the completion source swaps to from-scope data there). When a *second* consumer of a + **CodeMirror 6** behind explicit injected SQL and Spec editor seams (#21/#212; + the SQL completion source swaps to from-scope data in #84). When a *second* consumer of a complex UI pattern appears, extract a shared primitive (e.g. `EditorPort`, `GraphSurface`, a result-view registry, `Drawer`) rather than copy it — but don't build a primitive speculatively for a single caller. @@ -72,7 +75,7 @@ Touch these in one change: | `src/core/*` | pure logic, 100% covered | | `src/net/*` | OAuth + ClickHouse client, injected fetch | | `src/ui/*` | hyperscript, icons, render modules, controller | -| `src/editor/*` | `EditorPort` seam + editor adapters (#143; CM6 lands here, #21) | +| `src/editor/*` | injected SQL/Spec editor ports + CodeMirror adapters (#143/#21/#212) | | `src/state.js` | state model + pure ops | | `src/main.js` | bootstrap (OAuth callback, share-links) | | `build/build.mjs` | esbuild → `dist/sql.html` | diff --git a/README.md b/README.md index 49f81f1e..415983f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ 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 and read-only source viewer), +dependencies — **CodeMirror 6** (the SQL editor, saved-query Spec JSON 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. @@ -48,11 +49,27 @@ The browser never holds a static credential — each user authenticates with you IdP and ClickHouse sees their JWT. There is **no app-specific backend**: the only moving parts are ClickHouse's HTTP handlers and your OAuth provider. -## SQL editor +## SQL and Spec editors -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 workbench uses **CodeMirror 6** behind separately injected SQL and Spec +editor seams (#143/#21/#212) — bundled and inlined like the other runtime deps, +so the page still makes zero third-party requests. A saved-query tab exposes a +visible **SQL | Spec** switch: SQL edits the executable text, while Spec edits +only the complete `query.spec` JSON. Linked Save validates and atomically +commits both drafts; an unsaved tab remains SQL-only until its first Save. + +Spec mode provides JSON highlighting, line numbers, bracket matching, folding, +local search, undoable two-space formatting, and continuous path-addressed parse +and semantic diagnostics. Its toolbar is deliberately small: **Format**, +**Save**, and the **SQL | Spec** switch. Blocking errors disable Save and are +never persisted; unknown fields remain valid and survive Save. + +Panel controls and Library favorite/pencil edits merge their fields into valid +open Spec drafts, preserving unrelated unsaved and extension fields. Invalid +JSON is the only external-writer block: the affected Spec tab opens with a +**Fix Spec JSON first** message, and nothing is changed or persisted. Run, +Explain, SQL formatting, Export, and Share are SQL-mode actions; switch back to +SQL to use them. The same bundled CodeMirror presentation/search base also powers an injected read-only `CodeViewer` seam (#213) for source surfaces. It supports complete @@ -61,6 +78,8 @@ 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. +The SQL editor provides: + - **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 @@ -576,8 +595,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/ injected CodeMirror islands: the editable EditorPort adapter and - the smaller read-only CodeViewer, sharing presentation/search base + editor/ injected CodeMirror islands: editable SQL + Spec adapters 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/docs/ADR-0001-reactivity.md b/docs/ADR-0001-reactivity.md index 9dd89452..0bc1d4ca 100644 --- a/docs/ADR-0001-reactivity.md +++ b/docs/ADR-0001-reactivity.md @@ -194,7 +194,7 @@ refreshReference / onDocChange, injected as `env.Editor`), and #21 swapped the adapter from the hand-rolled textarea to **CodeMirror 6** — the fourth bundled runtime dependency. Signals still coordinate the state (`hasSelection`, tab/effect wiring, the app-level `onDocChange` subscriber owns the -`tab.sql`/dirty writes); CM6 owns every keystroke, selection, undo, and +`tab.sqlDraft`/`dirtySql` writes); CM6 owns every keystroke, selection, undo, and measurement inside the port. Per-tab `EditorState`s give per-tab undo — a capability the shared-textarea design structurally lacked — and the adapter is unit-tested against the real CM6 under happy-dom (the coverage gate holds @@ -222,3 +222,34 @@ 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. + +## Addendum — independent SQL and Spec JSON editor seams (#212) + +Saved-query authoring now owns two explicit imperative islands: +`app.sqlEditor` and `app.specEditor`, injected by `env.Editor` and +`env.SpecEditor`. The former `app.editor` ambiguity is intentionally gone. +Execution, schema insertion, SQL formatting, and Export always address the SQL +adapter; changing which document is visible cannot redirect a SQL operation to +JSON. Each tab holds independent `sqlDraft` and `specText` documents, parsed +Spec state, diagnostics, mode, and dirty flags, while each adapter parks its own +CodeMirror state so undo, selection, scroll, and search remain local. + +Spec parsing, normalization, and synchronous semantic validation live in pure +`core/spec-draft.js`. Validator paths are arrays of string/number segments, not +dotted strings, so array indices and object keys containing dots are exact. +The app owns the registry and feature code owns individual rules. Direct Spec +writers use one state-level patch helper. Panel controls patch the active valid +draft and leave it dirty. Immediately persisted Library pencil/favorite changes +patch every valid open draft while preserving both unrelated unsaved fields and +each draft's existing dirty state: clean stays clean; dirty stays dirty. A +syntactically invalid JSON draft is the only block; the writer reports that tab +before any mutation or persistence. Linked Save validates and persists SQL plus +Spec once, atomically; a failed Save writes nothing. + +Spec is intentionally a lightweight editing mode rather than a second +workbench. Its toolbar owns Format, Save, and the SQL | Spec switch. Run, +Explain, SQL formatting, Export, Share, and Share’s global shortcut are owned by +SQL mode. Validation is continuous through diagnostics and status; there are no +manual Validate or Revert commands. The adapter shares only the generic +CodeMirror presentation/search base and JSON language package that had already +landed with #213, so #212 adds no runtime dependency. diff --git a/src/core/spec-draft.js b/src/core/spec-draft.js new file mode 100644 index 00000000..2038ec8d --- /dev/null +++ b/src/core/spec-draft.js @@ -0,0 +1,259 @@ +// Pure saved-query Spec draft parsing, validation, normalization, and +// serialization. The workbench/editor layers own presentation and source +// markers; this module owns the deterministic data contract. + +import { cloneJson, isPlainObject } from './saved-query.js'; + +const isDigit = (ch) => ch >= '0' && ch <= '9'; +const isHex = (ch) => /[0-9a-f]/i.test(ch); +const isWs = (ch) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'; + +class JsonSyntaxError extends Error { + constructor(message, offset) { + super(message); + this.offset = offset; + } +} + +function scanJson(text) { + let pos = 0; + const fail = (message, at = pos) => { throw new JsonSyntaxError(message, at); }; + const ws = () => { while (pos < text.length && isWs(text[pos])) pos++; }; + + const string = () => { + if (text[pos] !== '"') fail('Expected a JSON string'); + pos++; + while (pos < text.length) { + const ch = text[pos++]; + if (ch === '"') return; + if (ch.charCodeAt(0) < 0x20) fail('Control character in string', pos - 1); + if (ch !== '\\') continue; + if (pos >= text.length) fail('Unterminated escape sequence', pos - 1); + const esc = text[pos++]; + if ('"\\/bfnrt'.includes(esc)) continue; + if (esc !== 'u') fail('Invalid escape sequence', pos - 2); + if (pos + 4 > text.length || ![...text.slice(pos, pos + 4)].every(isHex)) { + fail('Invalid Unicode escape', pos); + } + pos += 4; + } + fail('Unterminated string', Math.max(0, pos - 1)); + }; + + const number = () => { + const start = pos; + if (text[pos] === '-') pos++; + if (text[pos] === '0') pos++; + else { + if (!isDigit(text[pos]) || text[pos] === '0') fail('Invalid number', start); + while (isDigit(text[pos])) pos++; + } + if (text[pos] === '.') { + pos++; + if (!isDigit(text[pos])) fail('Expected digits after decimal point'); + while (isDigit(text[pos])) pos++; + } + if (text[pos] === 'e' || text[pos] === 'E') { + pos++; + if (text[pos] === '+' || text[pos] === '-') pos++; + if (!isDigit(text[pos])) fail('Expected exponent digits'); + while (isDigit(text[pos])) pos++; + } + }; + + const literal = (word) => { + if (text.slice(pos, pos + word.length) !== word) fail('Unexpected token'); + pos += word.length; + }; + + const value = () => { + ws(); + if (pos >= text.length) fail('Expected a JSON value', pos); + const ch = text[pos]; + if (ch === '"') return string(); + if (ch === '{') return object(); + if (ch === '[') return array(); + if (ch === 't') return literal('true'); + if (ch === 'f') return literal('false'); + if (ch === 'n') return literal('null'); + if (ch === '-' || isDigit(ch)) return number(); + fail('Unexpected token'); + }; + + const object = () => { + pos++; + ws(); + if (text[pos] === '}') { pos++; return; } + while (true) { + if (text[pos] !== '"') fail('Expected a property name'); + string(); + ws(); + if (text[pos] !== ':') fail("Expected ':' after property name"); + pos++; + value(); + ws(); + if (text[pos] === '}') { pos++; return; } + if (text[pos] !== ',') fail("Expected ',' or '}'"); + pos++; + ws(); + } + }; + + const array = () => { + pos++; + ws(); + if (text[pos] === ']') { pos++; return; } + while (true) { + value(); + ws(); + if (text[pos] === ']') { pos++; return; } + if (text[pos] !== ',') fail("Expected ',' or ']'"); + pos++; + ws(); + } + }; + + ws(); + value(); + ws(); + if (pos !== text.length) fail('Unexpected content after JSON value'); +} + +function location(text, offset) { + const before = text.slice(0, offset); + const lines = before.split('\n'); + return { line: lines.length, column: lines[lines.length - 1].length + 1 }; +} + +/** Parse arbitrary JSON with a deterministic syntax diagnostic. */ +export function parseSpecJson(text) { + const source = String(text ?? ''); + try { + scanJson(source); + return { value: JSON.parse(source), diagnostic: null }; + } catch (error) { + const offset = error instanceof JsonSyntaxError ? error.offset : 0; + return { + value: null, + diagnostic: { + path: [], severity: 'error', code: 'invalid-json', + message: error instanceof JsonSyntaxError ? error.message : 'Invalid JSON', + offset, ...location(source, offset), + }, + }; + } +} + +function atPath(root, path) { + let value = root; + for (const segment of path) { + if (value == null || !Object.hasOwn(Object(value), segment)) return { present: false, value: undefined }; + value = value[segment]; + } + return { present: true, value }; +} + +const typeRule = (path, type, code) => ({ + path, + validate: ({ value, present }) => { + if (!present) return []; + const valid = type === 'object' ? isPlainObject(value) : typeof value === type; + return valid ? [] : [{ path, severity: 'error', code, message: `${path.join('.')} must be ${type === 'object' ? 'an object' : `a ${type}`}` }]; + }, +}); + +/** Initial known-field validators. Unknown fields deliberately pass through. */ +export const CORE_SPEC_VALIDATORS = Object.freeze([ + typeRule(['name'], 'string', 'invalid-name-type'), + { + path: ['name'], + validate: ({ value, present }) => present && typeof value === 'string' && !value.trim() + ? [{ path: ['name'], severity: 'error', code: 'blank-name', message: 'name must not be blank' }] + : [], + }, + typeRule(['description'], 'string', 'invalid-description-type'), + typeRule(['favorite'], 'boolean', 'invalid-favorite-type'), + typeRule(['view'], 'string', 'invalid-view-type'), + typeRule(['panel'], 'object', 'invalid-panel-type'), + typeRule(['dashboard'], 'object', 'invalid-dashboard-type'), +]); + +function invokeValidators(spec, validators) { + const diagnostics = []; + for (const entry of validators) { + const path = Array.isArray(entry.path) ? entry.path : []; + const ctx = { root: spec, path: [...path], ...atPath(spec, path) }; + const produced = entry.validate(ctx) || []; + for (const diagnostic of Array.isArray(produced) ? produced : [produced]) { + diagnostics.push({ + path: [...(diagnostic.path || path)], + severity: diagnostic.severity || 'error', + code: diagnostic.code || 'invalid-spec', + message: String(diagnostic.message || 'Invalid Spec value'), + }); + } + } + return diagnostics; +} + +/** Validate a parsed Spec with a deterministic list of validator definitions. */ +export function validateSpec(spec, validators = CORE_SPEC_VALIDATORS) { + if (!isPlainObject(spec)) { + return [{ path: [], severity: 'error', code: 'invalid-spec-root', message: 'Spec must be a JSON object' }]; + } + return invokeValidators(spec, validators); +} + +/** + * Create an app-owned validator registry. Paths use string/number segments, so + * array indexes and object keys containing dots remain unambiguous. register() + * returns an unregister callback; no mutable module-global registry exists. + */ +export function createSpecValidatorRegistry(initial = CORE_SPEC_VALIDATORS) { + const entries = [...initial]; + return { + register(path, validate) { + const entry = { path: [...path], validate }; + entries.push(entry); + return () => { + const index = entries.indexOf(entry); + if (index >= 0) entries.splice(index, 1); + }; + }, + validate: (spec) => validateSpec(spec, entries), + }; +} + +/** Parse and synchronously run semantic validation. */ +export function evaluateSpecText(text, validators = CORE_SPEC_VALIDATORS) { + const parsed = parseSpecJson(text); + if (parsed.diagnostic) return { parsed: null, diagnostics: [parsed.diagnostic] }; + const diagnostics = validators && typeof validators.validate === 'function' + ? validators.validate(parsed.value) + : validateSpec(parsed.value, validators); + return { parsed: parsed.value, diagnostics }; +} + +export const hasBlockingSpecErrors = (diagnostics = []) => + diagnostics.some((diagnostic) => diagnostic.severity === 'error'); + +/** Normalize only settled known text fields; retain every extension and key order. */ +export function normalizeSpec(spec) { + const normalized = cloneJson(spec); + if (typeof normalized.name === 'string') normalized.name = normalized.name.trim(); + if (typeof normalized.description === 'string') { + normalized.description = normalized.description.trim(); + if (!normalized.description) delete normalized.description; + } + return normalized; +} + +export const serializeSpec = (spec) => JSON.stringify(spec, null, 2); + +/** Format syntactically-valid JSON without applying semantic normalization. */ +export function formatSpecText(text) { + const parsed = parseSpecJson(text); + return parsed.diagnostic + ? { text: String(text ?? ''), diagnostic: parsed.diagnostic } + : { text: serializeSpec(parsed.value), diagnostic: null }; +} diff --git a/src/editor/codemirror-adapter.js b/src/editor/codemirror-adapter.js index 5e59b25e..b2527ee6 100644 --- a/src/editor/codemirror-adapter.js +++ b/src/editor/codemirror-adapter.js @@ -29,8 +29,8 @@ 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 + +// Programmatic state syncs (tab switch, external tab.sqlDraft reconcile) must not +// reach onDocChange subscribers — the app-level subscriber writes tab.sqlDraft + // dirty, and a tab switch dirtying the incoming tab would be a bug. User edits // and port edits (insertAtCursor/replaceDocument/drop) DO emit, matching the // textarea adapter's input-event semantics. Sync transactions also stay out @@ -379,7 +379,7 @@ export function createCodeMirrorEditor(app) { ]), EditorView.updateListener.of((u) => { // Suppress only when EVERY transaction is a sync — an update that - // coalesces a user edit with a reconcile must still reach tab.sql. + // coalesces a user edit with a reconcile must still reach tab.sqlDraft. if (u.docChanged && !u.transactions.every((tr) => tr.annotation(syncTx))) { emit(u.state.doc.toString()); scheduleColumnLoad(); // user edit → prefetch the statement's FROM columns (#84) @@ -401,11 +401,11 @@ export function createCodeMirrorEditor(app) { if (!view) { const tab = activeTab(app.state); // state guarantees ≥1 tab shownTabId = tab.id; - view = new EditorView({ state: freshState(tab.sql) }); + view = new EditorView({ state: freshState(tab.sqlDraft) }); } // renderApp resets app.dom on every run — re-register the reach-in ref // (e2e/debug only; the app itself talks through the port). - app.dom.editorView = view; + app.dom.sqlEditorView = view; container.replaceChildren(view.dom); }, destroy: () => { @@ -456,23 +456,23 @@ export function createCodeMirrorEditor(app) { for (const id of tabStates.keys()) if (!ids.has(id)) tabStates.delete(id); // closed tabs if (shownTabId === tab.id) { // Same tab (the effect also fires on unrelated tab-list changes): - // reconcile only an external tab.sql change; equal doc = strict no-op + // reconcile only an external tab.sqlDraft change; equal doc = strict no-op // (selection/scroll/completion untouched). Length check first — the // effect fires on every tab op and O(doc) compares add up. - if (view.state.doc.length !== tab.sql.length || view.state.doc.toString() !== tab.sql) { - view.dispatch({ ...fullReplace(view.state, tab.sql), annotations: syncAnnotations() }); + if (view.state.doc.length !== tab.sqlDraft.length || view.state.doc.toString() !== tab.sqlDraft) { + view.dispatch({ ...fullReplace(view.state, tab.sqlDraft), annotations: syncAnnotations() }); } return; } if (ids.has(shownTabId)) tabStates.set(shownTabId, view.state); // park the outgoing tab (undo intact); a just-closed tab isn't kept let next = tabStates.get(tab.id) || null; if (next) { - // A parked state may predate a refData arrival or an external tab.sql + // A parked state may predate a refData arrival or an external tab.sqlDraft // write — re-apply the current language and reconcile the doc via // detached updates (undo history survives; no view listener fires). next = next.update({ effects: langCompartment.reconfigure(langExt) }).state; - if (next.doc.length !== tab.sql.length || next.doc.toString() !== tab.sql) { - next = next.update({ ...fullReplace(next, tab.sql), annotations: syncAnnotations() }).state; + if (next.doc.length !== tab.sqlDraft.length || next.doc.toString() !== tab.sqlDraft) { + next = next.update({ ...fullReplace(next, tab.sqlDraft), annotations: syncAnnotations() }).state; } // Collapse the restored selection to its head: an invisible parked // selection would silently retarget ⌘↵/Export (which read @@ -481,7 +481,7 @@ export function createCodeMirrorEditor(app) { const head = clamp(next.selection.main.head, 0, next.doc.length); next = next.update({ selection: { anchor: head }, annotations: syncAnnotations() }).state; } else { - next = freshState(tab.sql); + next = freshState(tab.sqlDraft); } shownTabId = tab.id; view.setState(next); // setState is not a transaction — nothing emits diff --git a/src/editor/editor-port.js b/src/editor/editor-port.js index 1249c4d5..caab0312 100644 --- a/src/editor/editor-port.js +++ b/src/editor/editor-port.js @@ -13,7 +13,7 @@ // text. // - `destroy()` is TERMINAL: it drops all subscriptions — including the // app-level one createApp registers — so a destroyed port must not be -// re-mounted (typing would repaint but never reach tab.sql again). To swap +// re-mounted (typing would repaint but never reach tab.sqlDraft again). To swap // editors, create a fresh port via `app.Editor(app)` and re-register its // consumers; today nothing calls destroy() and the port lives as long as // the app. diff --git a/src/editor/spec-editor.js b/src/editor/spec-editor.js new file mode 100644 index 00000000..90949ede --- /dev/null +++ b/src/editor/spec-editor.js @@ -0,0 +1,240 @@ +// Editable JSON CodeMirror adapter for saved-query Spec drafts. It deliberately +// owns no SQL behavior: no dialect/completion/schema loading/drag-drop. The app +// injects it separately from the SQL EditorPort as `app.specEditor`. + +import { Annotation, EditorState, StateEffect, StateField, Transaction } from '@codemirror/state'; +import { Decoration, EditorView, keymap } from '@codemirror/view'; +import { + bracketMatching, foldGutter, foldKeymap, +} from '@codemirror/language'; +import { history, historyKeymap, defaultKeymap } from '@codemirror/commands'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; +import { json } from '@codemirror/lang-json'; +import { syntaxTree } from '@codemirror/language'; +import { activeTab } from '../state.js'; +import { codePresentationExtensions, codeSearchKeymap } from './codemirror-base.js'; + +const syncTx = Annotation.define(); +const setDiagnosticMarks = StateEffect.define(); + +function namedChildren(node) { + const children = []; + const cursor = node.cursor(); + if (!cursor.firstChild()) return children; + do { + if (!cursor.type.isAnonymous) children.push(cursor.node); + } while (cursor.nextSibling()); + return children; +} + +const pathKey = (path) => JSON.stringify(path); +const jsonValueNames = new Set(['Object', 'Array', 'String', 'Number', 'True', 'False', 'Null']); +const isJsonValue = (node) => jsonValueNames.has(node.name); + +/** Build exact JSON path → value-node ranges from the current Lezer tree. */ +export function jsonPathRanges(state) { + const ranges = new Map(); + const doc = state.doc; + const visit = (node, path) => { + ranges.set(pathKey(path), { from: node.from, to: node.to }); + if (node.name === 'Object') { + for (const property of namedChildren(node).filter((child) => child.name === 'Property')) { + const children = namedChildren(property); + const nameNode = children.find((child) => child.name === 'PropertyName'); + const valueNode = children.find(isJsonValue); + if (!nameNode || !valueNode) continue; + let key; + try { key = JSON.parse(doc.sliceString(nameNode.from, nameNode.to)); } catch { continue; } + visit(valueNode, [...path, key]); // duplicate keys: last value wins, like JSON.parse + } + } else if (node.name === 'Array') { + const values = namedChildren(node).filter(isJsonValue); + values.forEach((child, index) => visit(child, [...path, index])); + } + }; + const root = namedChildren(syntaxTree(state).topNode).find(isJsonValue); + if (root) visit(root, []); + return ranges; +} + +function rangeForDiagnostic(state, diagnostic, pathRanges = jsonPathRanges(state)) { + if (diagnostic.offset != null) { + const from = Math.max(0, Math.min(diagnostic.offset, state.doc.length)); + return state.doc.length === 0 + ? { from: 0, to: 0 } + : { from: Math.min(from, state.doc.length - 1), to: Math.min(state.doc.length, from + 1) }; + } + const path = [...(diagnostic.path || [])]; + while (path.length >= 0) { + const range = pathRanges.get(pathKey(path)); + if (range) return range; + if (!path.length) break; + path.pop(); + } + return state.doc.length ? { from: 0, to: 1 } : { from: 0, to: 0 }; +} + +const diagnosticField = StateField.define({ + create: () => Decoration.none, + update(value, transaction) { + value = value.map(transaction.changes); + for (const effect of transaction.effects) { + if (!effect.is(setDiagnosticMarks)) continue; + const pathRanges = jsonPathRanges(transaction.state); + const marks = []; + for (const diagnostic of effect.value) { + const range = rangeForDiagnostic(transaction.state, diagnostic, pathRanges); + if (range.to <= range.from) continue; + marks.push(Decoration.mark({ + class: `spec-diagnostic spec-diagnostic-${diagnostic.severity || 'error'}`, + attributes: { title: diagnostic.message, 'data-code': diagnostic.code }, + }).range(range.from, range.to)); + } + marks.sort((a, b) => a.from - b.from || a.to - b.to); + value = Decoration.set(marks, true); + } + return value; + }, + provide: (field) => EditorView.decorations.from(field), +}); + +const fullReplace = (state, text) => ({ changes: { from: 0, to: state.doc.length, insert: text } }); +const syncAnnotations = () => [syncTx.of(true), Transaction.addToHistory.of(false)]; + +export function createNoopSpecEditor() { + return { + mount() {}, destroy() {}, focus() {}, requestMeasure() {}, + hasFocus: () => false, + getValue: () => '', + getSelection: () => ({ start: 0, end: 0, text: '' }), + insertAtCursor() {}, replaceDocument() {}, revealOffset() {}, syncFromState() {}, + refreshReference() {}, setDiagnostics() {}, revealDiagnostic() {}, + onDocChange: () => () => {}, + }; +} + +/** Create the injected editable Spec JSON adapter. */ +export function createSpecEditor(app) { + const subscribers = new Set(); + const tabStates = new Map(); + let view = null; + let shownTabId = null; + let diagnostics = []; + + const extensions = () => [ + ...codePresentationExtensions(), + json(), + history(), + foldGutter(), + bracketMatching(), + closeBrackets(), + diagnosticField, + codeSearchKeymap, + keymap.of([ + ...closeBracketsKeymap, + ...foldKeymap, + ...historyKeymap, + ...defaultKeymap.filter((binding) => binding.key !== 'Mod-Enter' && binding.key !== 'Escape'), + ]), + EditorView.updateListener.of((update) => { + if (update.docChanged && !update.transactions.every((tr) => tr.annotation(syncTx))) { + const text = update.state.doc.toString(); + for (const callback of subscribers) callback(text); + } + }), + ]; + const freshState = (text) => EditorState.create({ doc: text, extensions: extensions() }); + const applyDiagnostics = () => { + if (view) view.dispatch({ effects: setDiagnosticMarks.of(diagnostics) }); + }; + const focusSoon = () => { + const current = view; + queueMicrotask(() => { + if (view === current) current?.focus(); + }); + }; + + return { + mount(container) { + if (!view) { + const tab = activeTab(app.state); + shownTabId = tab.id; + view = new EditorView({ state: freshState(tab.specText) }); + applyDiagnostics(); + } + app.dom.specEditorView = view; + container.replaceChildren(view.dom); + }, + destroy() { + subscribers.clear(); + tabStates.clear(); + if (view) view.destroy(); + view = null; + }, + focus: () => { if (view) view.focus(); }, + requestMeasure: () => { if (view) view.requestMeasure(); }, + hasFocus: () => !!view && view.hasFocus, + getValue: () => (view ? view.state.doc.toString() : ''), + getSelection: () => { + if (!view) return { start: 0, end: 0, text: '' }; + const { from, to } = view.state.selection.main; + return { start: from, end: to, text: view.state.sliceDoc(from, to) }; + }, + insertAtCursor(text) { + if (!view) return; + view.dispatch(view.state.replaceSelection(text), { userEvent: 'input.paste', scrollIntoView: true }); + focusSoon(); + }, + replaceDocument(text) { + if (!view || view.state.doc.toString() === text) return; + view.dispatch({ ...fullReplace(view.state, text), userEvent: 'input.replace', scrollIntoView: true }); + focusSoon(); + }, + revealOffset(pos) { + if (!view) return; + const offset = Math.max(0, Math.min(pos | 0, view.state.doc.length)); + view.dispatch({ selection: { anchor: offset }, scrollIntoView: true }); + focusSoon(); + }, + syncFromState() { + if (!view) return; + const liveTabIds = new Set(app.state.tabs.value.map((tab) => tab.id)); + for (const id of tabStates.keys()) { + if (!liveTabIds.has(id)) tabStates.delete(id); + } + const tab = activeTab(app.state); + if (shownTabId === tab.id) { + if (view.state.doc.toString() !== tab.specText) { + view.dispatch({ ...fullReplace(view.state, tab.specText), annotations: syncAnnotations() }); + } + diagnostics = tab.specDiagnostics || []; + applyDiagnostics(); + return; + } + if (shownTabId && liveTabIds.has(shownTabId)) tabStates.set(shownTabId, view.state); + let next = tabStates.get(tab.id) || freshState(tab.specText); + if (next.doc.toString() !== tab.specText) { + next = next.update({ ...fullReplace(next, tab.specText), annotations: syncAnnotations() }).state; + } + shownTabId = tab.id; + view.setState(next); + diagnostics = tab.specDiagnostics || []; + applyDiagnostics(); + }, + refreshReference() {}, + setDiagnostics(next) { + diagnostics = [...(next || [])]; + applyDiagnostics(); + }, + revealDiagnostic(index = 0) { + if (!view || !diagnostics[index]) return; + const range = rangeForDiagnostic(view.state, diagnostics[index]); + view.dispatch({ selection: { anchor: range.from }, scrollIntoView: true }); + focusSoon(); + }, + onDocChange(callback) { + subscribers.add(callback); + return () => subscribers.delete(callback); + }, + }; +} diff --git a/src/main.js b/src/main.js index d7eb70ef..37ea37fe 100644 --- a/src/main.js +++ b/src/main.js @@ -7,12 +7,14 @@ 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 { createSpecEditor } from './editor/spec-editor.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'; import { cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery } from './core/saved-query.js'; import { isDashboardRoute } from './core/dashboard.js'; +import { setTabSpecDraft } from './state.js'; export async function bootstrap(app, env) { const loc = env.location; @@ -80,10 +82,10 @@ export async function bootstrap(app, env) { const panel = queryPanel(shared); if (shared.sql || panel) { const t0 = app.state.tabs.value[0]; - t0.sql = shared.sql; + t0.sqlDraft = shared.sql; t0.name = queryName(shared); t0.specVersion = shared.specVersion; - t0.spec = cloneJson(shared.spec); + setTabSpecDraft(t0, cloneJson(shared.spec)); if (panel && panel.cfg) { // A panel-only link (no SQL to run) must open the Panel drawer, or // the recipient lands on an empty Table view and sees nothing. @@ -121,7 +123,10 @@ 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, CodeViewer: createCodeViewer, build: '__ASB_BUILD__' }); + const app = createApp({ + Chart, Dagre, Editor: createCodeMirrorEditor, SpecEditor: createSpecEditor, + CodeViewer: createCodeViewer, build: '__ASB_BUILD__', + }); document.addEventListener('keydown', (e) => handleKeydown(e, app)); bootstrap(app, { location: window.location, diff --git a/src/state.js b/src/state.js index a078185b..e5bd54fd 100644 --- a/src/state.js +++ b/src/state.js @@ -11,6 +11,9 @@ import { import { normalizeDashLayout, normalizeDashCols } from './core/dashboard.js'; import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js'; import { emptyRecentMap } from './core/recent-values.js'; +import { + evaluateSpecText, hasBlockingSpecErrors, normalizeSpec, serializeSpec, +} from './core/spec-draft.js'; import { signal } from '@preact/signals-core'; /** @@ -18,7 +21,7 @@ import { signal } from '@preact/signals-core'; * cfg/key fields drive today's renderer; future siblings ride along unchanged. */ export function tabPanel(tab) { - const panel = queryPanel(tab); + const panel = queryPanel(tab && { spec: tab.specParsed }); return panel ? cloneJson(panel) : null; } @@ -72,13 +75,28 @@ export const MOBILE_BREAKPOINT_PX = 768; /** A blank query tab. Its complete Spec is the sole tab-side authoring source; * SQL remains the separate editor document. */ export function newTabObj(id) { + const specParsed = { name: 'Untitled', favorite: false }; return { - id, name: 'Untitled', sql: '', specVersion: SPEC_VERSION, - spec: { name: 'Untitled', favorite: false }, - dirty: false, result: null, savedId: null, + id, name: 'Untitled', sqlDraft: '', specVersion: SPEC_VERSION, + specText: serializeSpec(specParsed), specParsed, specDiagnostics: [], + editorMode: 'sql', dirtySql: false, dirtySpec: false, + result: null, savedId: null, }; } +/** Overall tab dirty state is always the OR of the independent documents. */ +export const tabDirty = (tab) => !!(tab && (tab.dirtySql || tab.dirtySpec)); + +/** Replace a tab's complete parsed Spec draft and serialized text together. */ +export function setTabSpecDraft(tab, spec, { dirty = false } = {}) { + const parsed = cloneJson(spec); + tab.specParsed = parsed; + tab.specText = serializeSpec(parsed); + tab.specDiagnostics = evaluateSpecText(tab.specText).diagnostics; + tab.dirtySpec = dirty; + return tab; +} + /** * Build the initial state, reading persisted prefs through `read` (an object * with loadJSON/loadStr, defaulting to storage.js over localStorage). @@ -248,7 +266,30 @@ export function allocTabId(state) { const rnd = () => Math.random().toString(36).slice(2, 6); const makeId = (prefix, now) => prefix + now + rnd(); -const tabsForSaved = (state, id) => state.tabs.value.filter((t) => t.savedId === id); +export const tabsForSaved = (state, id) => state.tabs.value.filter((t) => t.savedId === id); + +/** First linked tab whose textual Spec is not currently parseable JSON. */ +export const invalidSpecTabForSaved = (state, id) => + tabsForSaved(state, id).find((tab) => + tab.specDiagnostics?.some((diagnostic) => diagnostic.code === 'invalid-json')) || null; + +const patchedSpec = (spec, patch) => (typeof patch === 'function' + ? patch(cloneJson(spec)) + : patchQuerySpec({ spec }, patch).spec); + +/** + * Patch one valid open Spec draft without replacing unrelated unsaved fields. + * External writers use this helper so text and parsed state stay synchronized. + */ +export function patchSpecDraft(tab, patch, { dirty = true } = {}) { + if (!tab) return { ok: false, invalidTab: null }; + if (tab.specDiagnostics?.some((diagnostic) => diagnostic.code === 'invalid-json')) { + return { ok: false, invalidTab: tab }; + } + setTabSpecDraft(tab, patchedSpec(tab.specParsed, patch), { dirty }); + tab.name = queryName({ spec: tab.specParsed }); + return { ok: true, invalidTab: null, spec: tab.specParsed }; +} /** The saved query a tab is linked to (via tab.savedId), or null. */ export function savedForTab(state, tab) { @@ -256,50 +297,88 @@ export function savedForTab(state, tab) { } /** - * Save the tab's SQL under `name` (+ an optional free-text `description`). If - * the tab is already linked to a saved entry, update that entry in place; - * otherwise create a new one (newest first) and link the tab to it. The tab's - * name mirrors the saved name. Returns the saved entry, or null for empty - * SQL/name. + * Create a saved query from an unsaved tab. Linked tabs use commitSavedQuery() + * instead, so popover metadata can never compete with the textual Spec draft. */ -export function saveQuery(state, tab, name, description, save = saveJSON, now = Date.now()) { - const sql = String(tab.sql || '').trim(); +export function createSavedQuery(state, tab, name, description, save = saveJSON, now = Date.now()) { + if (!tab || tab.savedId) return null; + const sql = String(tab.sqlDraft || ''); const nm = String(name || '').trim(); const panel = tabPanel(tab); // The save guard relaxes per panel type (#166): a text panel is authored // entirely in its cfg, so `sql: ''` is allowed for that type ONLY. const sqlOptional = panel && panel.cfg.type === 'text'; - if ((!sql && !sqlOptional) || !nm) return null; + if ((!sql.trim() && !sqlOptional) || !nm) return null; const desc = String(description || '').trim(); // Remember the current result view (Table/JSON/Panel) so a restore reopens the // same data representation; the transient raw view isn't persisted. const view = SAVED_VIEWS.has(state.resultView.value) ? state.resultView.value : undefined; - let entry = savedForTab(state, tab); - const favorite = entry ? queryFavorite(entry) : queryFavorite(tab); - const draft = patchQuerySpec(withQuerySpec({ ...tab, sql }, tab.spec), { + const favorite = queryFavorite({ spec: tab.specParsed }); + const draft = patchQuerySpec(withQuerySpec({ sql }, tab.specParsed), { name: nm, favorite, description: desc || undefined, panel: panel || undefined, view, }); - if (entry) { - const index = state.savedQueries.findIndex((query) => query.id === entry.id); - entry = withQuerySpec({ ...draft, id: entry.id, sql }, draft.spec); - state.savedQueries[index] = entry; - } else { - entry = withQuerySpec({ ...draft, id: makeId('s', now), sql }, draft.spec); - state.savedQueries.unshift(entry); - tab.savedId = entry.id; - } + const entry = withQuerySpec({ ...draft, id: makeId('s', now), sql }, normalizeSpec(draft.spec)); + state.savedQueries.unshift(entry); + tab.savedId = entry.id; + tab.specVersion = SPEC_VERSION; + tab.sqlDraft = entry.sql; + tab.dirtySql = false; + tab.name = queryName(entry); + setTabSpecDraft(tab, entry.spec); + state.libraryDirty.value = true; + save(KEYS.saved, state.savedQueries); + return entry; +} + +/** Atomically persist both documents of a linked tab in one Library write. */ +export function commitSavedQuery(state, tab, spec, save = saveJSON) { + const index = tab && tab.savedId + ? state.savedQueries.findIndex((query) => query.id === tab.savedId) + : -1; + if (index < 0 || !spec) return null; + const normalized = normalizeSpec(spec); + const diagnostics = evaluateSpecText(serializeSpec(normalized)).diagnostics; + if (hasBlockingSpecErrors(diagnostics)) return null; + const sql = String(tab.sqlDraft || ''); + const panel = queryPanel({ spec: normalized }); + if (!sql.trim() && panel?.cfg?.type !== 'text') return null; + const current = state.savedQueries[index]; + const entry = withQuerySpec({ id: current.id, sql }, normalized); + state.savedQueries[index] = entry; tab.specVersion = SPEC_VERSION; - tab.spec = cloneJson(entry.spec); - tab.name = nm; + tab.name = queryName(entry); + tab.dirtySql = false; + setTabSpecDraft(tab, entry.spec); state.libraryDirty.value = true; save(KEYS.saved, state.savedQueries); return entry; } +/** + * Generic committed-Spec writer for pencil/star/future controls. The patch is + * applied independently to the persisted entry and every linked valid draft, + * preserving unrelated unsaved fields. Invalid JSON blocks the whole write. + */ +export function patchSavedSpec(state, id, patch, save = saveJSON) { + const invalidTab = invalidSpecTabForSaved(state, id); + if (invalidTab) return { ok: false, invalidTab, entry: null }; + const index = state.savedQueries.findIndex((query) => query.id === id); + if (index < 0) return { ok: false, invalidTab: null, entry: null }; + const current = state.savedQueries[index]; + const entry = withQuerySpec(current, patchedSpec(current.spec, patch)); + state.savedQueries[index] = entry; + for (const tab of tabsForSaved(state, id)) { + patchSpecDraft(tab, patch, { dirty: tab.dirtySpec }); + } + state.libraryDirty.value = true; + save(KEYS.saved, state.savedQueries); + return { ok: true, invalidTab: null, entry }; +} + /** * Rename a saved query, keeping any linked tab's name in sync. When * `description` is provided (not undefined) it is set/cleared too; pass @@ -315,13 +394,7 @@ export function renameSaved(state, id, name, description, save = saveJSON) { const desc = String(description || '').trim(); // match saveQuery: null/non-string → '' → cleared patch.description = desc || undefined; } - state.savedQueries[index] = patchQuerySpec(entry, patch); - for (const tab of tabsForSaved(state, id)) { - tab.name = nm; - tab.spec = patchQuerySpec(tab, patch).spec; - } - state.libraryDirty.value = true; - save(KEYS.saved, state.savedQueries); + return patchSavedSpec(state, id, patch, save); } /** Toggle a saved query's favorite flag. */ @@ -330,10 +403,7 @@ export function toggleFavorite(state, id, save = saveJSON) { const entry = index >= 0 ? state.savedQueries[index] : null; if (!entry) return; const favorite = !queryFavorite(entry); - state.savedQueries[index] = patchQuerySpec(entry, { favorite }); - for (const tab of tabsForSaved(state, id)) tab.spec = patchQuerySpec(tab, { favorite }).spec; - state.libraryDirty.value = true; - save(KEYS.saved, state.savedQueries); + return patchSavedSpec(state, id, { favorite }, save); } /** Saved queries with favorites first (stable within each group). */ @@ -379,7 +449,10 @@ export function importSaved(state, queries, save = saveJSON, genId = () => makeI /** Delete a saved query by id and clear any tab pointer to it. */ export function deleteSaved(state, id, save = saveJSON) { state.savedQueries = state.savedQueries.filter((q) => q.id !== id); - for (const t of tabsForSaved(state, id)) t.savedId = null; + for (const t of tabsForSaved(state, id)) { + t.savedId = null; + t.editorMode = 'sql'; + } state.libraryDirty.value = true; save(KEYS.saved, state.savedQueries); } @@ -393,7 +466,12 @@ export function deleteSaved(state, id, save = saveJSON) { * kept tab doesn't show "Saved" against a query that's gone. */ function pruneTabLinks(state) { const ids = new Set(state.savedQueries.map((q) => q.id)); - for (const t of state.tabs.value) if (t.savedId && !ids.has(t.savedId)) t.savedId = null; + for (const t of state.tabs.value) { + if (t.savedId && !ids.has(t.savedId)) { + t.savedId = null; + t.editorMode = 'sql'; + } + } } /** Rename the library (blank → the default name). Marks dirty; persists name. */ @@ -460,12 +538,12 @@ function pushHistory(state, sql, rows, ms, save, now) { /** * Record a successful run in history. `sqlText` overrides the recorded SQL (used - * when a selection — not the whole tab — was run); it defaults to `tab.sql`. + * when a selection — not the whole tab — was run); it defaults to `tab.sqlDraft`. */ export function recordHistory(state, tab, save = saveJSON, now = Date.now(), sqlText) { pushHistory( state, - sqlText != null ? sqlText : tab.sql, + sqlText != null ? sqlText : tab.sqlDraft, tab.result.rawText != null ? null : tab.result.rows.length, Math.round(tab.result.progress.elapsed_ns / 1e6), save, now, diff --git a/src/styles.css b/src/styles.css index 91bffbc3..6ff44079 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1408,7 +1408,59 @@ body.detached-tab .graph-overlay-panel { .tb-btn.is-disabled { opacity: .4; cursor: not-allowed; } .tb-btn.is-disabled:hover { background: transparent; color: var(--fg-mute); } -/* ------------ SQL editor ------------ */ +/* SQL | Spec is a persistent document mode, so it uses the same compact + segmented-control vocabulary as result views rather than reading as another + toolbar command. */ +.editor-mode-switch { + display: inline-flex; align-items: center; padding: 2px; + background: var(--bg-chip); border-radius: 5px; +} +.editor-mode-btn { + height: 22px; min-width: 42px; padding: 0 9px; + border: none; border-radius: 5px; background: transparent; + color: var(--fg-mute); font: 500 11px var(--ui); cursor: pointer; +} +.editor-mode-btn:hover { color: var(--fg); } +.editor-mode-btn.active { + background: var(--bg-editor); color: var(--fg); + box-shadow: 0 1px 2px rgba(0, 0, 0, .14); +} +.editor-mode-btn.is-disabled { opacity: .42; cursor: not-allowed; } +.editor-mode-btn:focus-visible, +.tb-btn:focus-visible, +.run-btn:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; +} +.ed-toolbar > [hidden], +.document-editor[hidden], +.spec-editor-pane[hidden] { display: none !important; } +.var-strip[hidden] { display: none !important; } + +.document-editor, +.spec-editor-pane { width: 100%; height: 100%; min-height: 0; } +.spec-editor-pane { display: flex; flex-direction: column; background: var(--bg-editor); } +.spec-document-editor { flex: 1; height: auto; } +.spec-status { + min-height: 24px; padding: 4px 12px; + border-top: 1px solid var(--border); + background: var(--bg-toolbar); color: var(--fg-mute); + font: 11px/15px var(--mono); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.spec-status.is-error { background: var(--error-bg); color: var(--error-fg); } +.spec-status.is-warning { background: var(--warn-bg); color: var(--warn-fg); } +.spec-status.is-valid::before { content: '✓ '; color: var(--accent); } +.spec-diagnostic { + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 1px; + text-underline-offset: 3px; +} +.spec-diagnostic-error { text-decoration-color: var(--error-fg); background: var(--error-bg); } +.spec-diagnostic-warning { text-decoration-color: var(--warn-fg); background: var(--warn-bg); } +.spec-diagnostic-info { text-decoration-color: var(--accent); background: var(--bg-highlight); } + +/* ------------ SQL + Spec editors ------------ */ /* Token colors — applied by the CM6 adapter's HighlightStyle (class: entries). */ .sql-keyword { color: #C586C0; font-weight: 500; } [data-theme='light'] .sql-keyword { color: #AF00DB; } diff --git a/src/ui/app.js b/src/ui/app.js index 8b09491a..a0cd7c03 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7,8 +7,9 @@ import { h, zoomScale, fixedAnchor } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, recordScriptHistory, saveQuery, savedForTab, tabPanel, normalizeRowLimit, - MOBILE_BREAKPOINT_PX, effectiveFilterActive, + createState, activeTab, KEYS, recordHistory, recordScriptHistory, + createSavedQuery, commitSavedQuery, savedForTab, tabPanel, + normalizeRowLimit, MOBILE_BREAKPOINT_PX, effectiveFilterActive, } from '../state.js'; import { splitStatements, isRowReturning, leadingKeyword } from '../core/sql-split.js'; import { @@ -28,7 +29,11 @@ import { toTSV, formatFileMeta, exportFilename, scriptExportName } from '../core import { newResult, applyStreamLine, parseErrorPos, findExceptionFrame } from '../core/stream.js'; import { buildResultSource } from '../core/query-source.js'; import { encodeShare } from '../core/share.js'; -import { queryDescription, queryName, withQuerySpec } from '../core/saved-query.js'; +import { queryName, queryPanel, withQuerySpec } from '../core/saved-query.js'; +import { + CORE_SPEC_VALIDATORS, createSpecValidatorRegistry, evaluateSpecText, formatSpecText, + hasBlockingSpecErrors, +} from '../core/spec-draft.js'; import { assembleReferenceData, buildCompletions } from '../core/completions.js'; import { generatePKCE, randomState } from '../core/pkce.js'; import { viewportZoom } from '../core/zoom-support.js'; @@ -39,6 +44,7 @@ import * as oauthCfg from '../net/oauth-config.js'; import * as oauth from '../net/oauth.js'; import * as ch from '../net/ch-client.js'; import { createNoopPort } from '../editor/editor-port.js'; +import { createNoopSpecEditor } from '../editor/spec-editor.js'; import { SCHEMA_GRAPH_MIME } from './dnd-mime.js'; import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import { effect, batch } from '@preact/signals-core'; @@ -229,27 +235,60 @@ export function createApp(env = {}) { app.host = () => originHost(chCtx.origin) || 'clickhouse'; app.activeTab = () => activeTab(app.state); - // --- editor seam (#143) -------------------------------------------------- - // Like Chart/Dagre, the editor adapter factory is injected: main.js passes - // the textarea adapter (the CM6 adapter swaps in with #21); headless app - // 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. + // --- independent SQL + Spec editor seams (#143/#212) --------------------- app.Editor = env.Editor || createNoopPort; + app.SpecEditor = env.SpecEditor || createNoopSpecEditor; + app.specValidators = env.specValidators && typeof env.specValidators.validate === 'function' + ? env.specValidators + : createSpecValidatorRegistry(env.specValidators || CORE_SPEC_VALIDATORS); 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 - // variables strip read tab.sql, so the tab writes come first. - app.editor.onDocChange((value) => { + app.sqlEditor = app.Editor(app); + app.specEditor = app.SpecEditor(app); + app.sqlEditor.onDocChange((value) => { const tab = app.activeTab(); - tab.sql = value; - tab.dirty = true; - app.actions.rerenderTabs(); - app.updateSaveBtn(); - app.renderVarStrip(); + tab.sqlDraft = value; + tab.dirtySql = true; + if (app.actions) app.actions.rerenderTabs(); + if (app.updateSaveBtn) app.updateSaveBtn(); + if (app.renderVarStrip) app.renderVarStrip(); + }); + const applySpecEvaluation = (tab, text, { dirty = true } = {}) => { + const evaluated = evaluateSpecText(text, app.specValidators); + tab.specText = text; + tab.specParsed = evaluated.parsed; + tab.specDiagnostics = evaluated.diagnostics; + tab.dirtySpec = dirty; + return evaluated; + }; + app.evaluateSpecDraft = (tab, text, { dirty = true } = {}) => { + const evaluated = applySpecEvaluation(tab, text, { dirty }); + if (tab === app.activeTab()) app.specEditor.setDiagnostics(tab.specDiagnostics); + if (app.actions) app.actions.rerenderTabs(); + if (app.updateSaveBtn) app.updateSaveBtn(); + if (app.updateEditorModeUi) app.updateEditorModeUi(); + return evaluated; + }; + app.revalidateSpecDrafts = ({ refreshUi = true } = {}) => { + for (const tab of app.state.tabs.value) { + applySpecEvaluation(tab, tab.specText, { dirty: tab.dirtySpec }); + } + if (!refreshUi) return; + const tab = app.activeTab(); + app.specEditor.setDiagnostics(tab.specDiagnostics); + if (app.actions) app.actions.rerenderTabs(); + if (app.updateSaveBtn) app.updateSaveBtn(); + if (app.updateEditorModeUi) app.updateEditorModeUi(); + }; + app.specEditor.onDocChange((value) => { + app.evaluateSpecDraft(app.activeTab(), value); }); + app.registerSpecValidator = (path, validate) => { + const unregister = app.specValidators.register(path, validate); + app.revalidateSpecDrafts(); + return () => { unregister(); app.revalidateSpecDrafts(); }; + }; // A `?host=` query param pre-fills the credential server address on the login // screen (and disables SSO, which only targets the serving host). app.hostHint = new URLSearchParams(loc.search || '').get('host') || ''; @@ -484,7 +523,7 @@ export function createApp(env = {}) { app.refData = assembleReferenceData(await ch.loadReferenceData(chCtx)); app.docCache.clear(); // re-fetch hover docs against the (possibly new) connection app.rebuildCompletions(); - app.editor.refreshReference(); // re-highlight with server keywords + app.sqlEditor.refreshReference(); // re-highlight with server keywords }; // A prominent, dismissible banner for schema/auth failures — the schema-panel // text alone is easy to miss on first deploy. Driven by app.state.schemaError. @@ -662,12 +701,12 @@ export function createApp(env = {}) { // Block execution while any {name:Type} variable in the active tab is unfilled // or invalid, or while its value can't serialize (e.g. an array value against // a scalar declaration) — toasting why (#134/#173). Gating on the whole - // tab.sql — the exact set the variable strip shows — keeps every execution + // tab.sqlDraft — the exact set the variable strip shows — keeps every execution // path consistent: the Run button (setRunBtn), the Run/⌘↵ path, Explain, and // Export all agree. `wallNowMs` is the caller's wave clock. function varGateBlocked(wallNowMs = wallNow()) { const tab = app.activeTab(); - const src = tab ? prepareTabSource(tab.sql, wallNowMs) : null; + const src = tab ? prepareTabSource(tab.sqlDraft, wallNowMs) : null; if (!src) return false; const blockers = src.missing.concat(src.invalid); if (blockers.length) { @@ -723,7 +762,7 @@ export function createApp(env = {}) { // `opts.sql` overrides the source SQL (a single selected statement); otherwise // the whole tab runs, byte-for-byte as before (FORMAT / EXPLAIN detection, // trailing `;`, history). - const srcSql = opts && opts.sql != null ? opts.sql : tab.sql; + const srcSql = opts && opts.sql != null ? opts.sql : tab.sqlDraft; if (!srcSql.trim()) return; const waveMs = wallNow(); // one wall clock for this run wave: gate + args see the same instant if (varGateBlocked(waveMs)) return; // block a run (incl. Explain / row-limit re-run) with unfilled variables @@ -749,7 +788,7 @@ export function createApp(env = {}) { // Every downstream decision + the request itself operate on the statement's // execution view (#165): inactive optional blocks removed, markers // stripped — byte-identical to srcSql for SQL without blocks. History still - // records the template (srcSql / tab.sql). + // records the template (srcSql / tab.sqlDraft). const execSql = execStatementSql(srcSql); // An explicit FORMAT clause runs raw and shows ClickHouse's response verbatim @@ -994,10 +1033,11 @@ export function createApp(env = {}) { // split: one statement keeps today's rich Table/Chart/EXPLAIN path (run()); // more than one runs sequentially as a script (runScript). function runEntry(opts) { + if (app.activeTab().editorMode !== 'sql') return; if (app.state.running.value) return; - const sel = app.editor.getSelection().text; + const sel = app.sqlEditor.getSelection().text; const hasSel = sel.trim() !== ''; - const input = hasSel ? sel : app.activeTab().sql; + const input = hasSel ? sel : app.activeTab().sqlDraft; const statements = splitStatements(input); if (!statements.length) return; // nothing runnable (empty / comments-only) // The unfilled-variable gate (#134) lives in run()/runScript() — the shared @@ -1061,7 +1101,7 @@ export function createApp(env = {}) { if (gate == null) { gate = running || !tab ? { missing: [], invalid: [], errors: [] } - : inputGate(tabAnalysis(tab.sql)); + : inputGate(tabAnalysis(tab.sqlDraft)); } const blockers = gate.missing.concat(gate.invalid); app.dom.runBtn.disabled = running || blockers.length > 0 || gate.errors.length > 0; @@ -1113,14 +1153,14 @@ export function createApp(env = {}) { // comparison scan, a rebuild's initial field paint, and the tail's Run- // button gate all feed off this single pass instead of re-analyzing the // same SQL a second time per editor keystroke. - const analysis = tab ? tabAnalysis(tab.sql) : null; + const analysis = tab ? tabAnalysis(tab.sqlDraft) : null; const vars = analysis ? fieldControls(analysis) : []; // #172 v2 scans the tab SQL's ANALYSIS materialization (review F2): in // the raw text a comparison inside a /*[ ]*/ optional block is one opaque // comment span and could never match. `resolveComparisonColumnType` // resolves each match's position against this same text. (Workbench-only // — the Dashboard has no schema cache and gets v1 straight from the type.) - const scanSql = tab ? analysisView(tab.sql) : ''; + const scanSql = tab ? analysisView(tab.sqlDraft) : ''; const comparisonColumns = tab ? paramComparisonColumns(scanSql) : {}; // Each field's control kind + member list (shared enum > date-like > text // priority; a type-conflicted field degrades to text — fieldControlKind). @@ -1214,13 +1254,13 @@ export function createApp(env = {}) { // 'input' mode (#170): a plausible prefix stays neutral while // the field is focused — only a value that's already certainly // wrong shows the inline error here. - const batch = prepareTabBatch(tab.sql, wallNow(), 'input'); + const batch = prepareTabBatch(tab.sqlDraft, wallNow(), 'input'); applyFieldState(input, batch.fields[v.name], baseTitle, combo && combo.previewEl); setRunBtn(app.state.running.value, batch.sources[0]); }; const onCommitHard = () => { // Hardens 'incomplete' → 'invalid' on commit (#170). - const batch = prepareTabBatch(tab.sql, wallNow(), 'execute'); + const batch = prepareTabBatch(tab.sqlDraft, wallNow(), 'execute'); hardenVar(v.name, batch.fields[v.name]); applyFieldState(input, batch.fields[v.name], baseTitle, combo && combo.previewEl); setRunBtn(app.state.running.value, batch.sources[0]); @@ -1297,7 +1337,8 @@ export function createApp(env = {}) { }; async function formatQuery() { - const raw = app.activeTab().sql || ''; + if (app.activeTab().editorMode !== 'sql') return; + const raw = app.activeTab().sqlDraft || ''; if (!raw.trim()) return; const stmts = splitStatements(raw); // #165 Format policy: a statement containing /*[ ]*/ optional blocks is @@ -1319,7 +1360,7 @@ export function createApp(env = {}) { // then reassemble with a `;` and a blank line between statements. const skipped = stmts.filter((s) => hasOptionalBlocks(s)).length; const formatted = await Promise.all(stmts.map((s) => (hasOptionalBlocks(s) ? s : formatOne(s).catch(() => s)))); - app.editor.replaceDocument(withStatementBreak(formatted.map((q, i) => q || stmts[i]).join(';\n\n'))); + app.sqlEditor.replaceDocument(withStatementBreak(formatted.map((q, i) => q || stmts[i]).join(';\n\n'))); clearFormatError(); if (skipped) { flashToast(skipped + (skipped === 1 ? ' statement contains' : ' statements contain') @@ -1333,7 +1374,7 @@ export function createApp(env = {}) { const q = await formatOne(raw); // Terminate so the caret lands past the last token — otherwise the input // event from the replace re-opens autocomplete on the trailing word. - if (q) app.editor.replaceDocument(withStatementBreak(q)); + if (q) app.sqlEditor.replaceDocument(withStatementBreak(q)); clearFormatError(); } catch (e) { const msg = String((e && e.message) || e); @@ -1343,7 +1384,7 @@ export function createApp(env = {}) { app.state.resultView.value = 'table'; renderResults(app); // explicit: the format-error tab.result is an in-place write, and resultView may already be 'table' (no effect) const pos = parseErrorPos(msg); - if (pos != null) app.editor.revealOffset(pos); + if (pos != null) app.sqlEditor.revealOffset(pos); } } finally { setFmtBtn(false); @@ -1502,15 +1543,21 @@ export function createApp(env = {}) { // `;`-separated script (ClickHouse would reject `EXPLAIN a; b; …` with a confusing // parse error). Say so with our own message instead. function explainMultiBlocked() { - if (splitStatements(app.activeTab().sql).length <= 1) return false; + if (splitStatements(app.activeTab().sqlDraft).length <= 1) return false; flashToast('Explain isn’t available for a multi-statement script — run one statement at a time.', { document: doc }); return true; } // Explain the current query without editing it: run it through the EXPLAIN // views (the editor SQL is left untouched; run() wraps it as needed). - function explainQuery() { return explainMultiBlocked() ? undefined : run({ explain: true }); } + function explainQuery() { + if (app.activeTab().editorMode !== 'sql') return undefined; + return explainMultiBlocked() ? undefined : run({ explain: true }); + } // Switch the active EXPLAIN view (re-runs the derived query, keeps the mode). - function setExplainView(id) { return explainMultiBlocked() ? undefined : run({ explainView: id }); } + function setExplainView(id) { + if (app.activeTab().editorMode !== 'sql') return undefined; + return explainMultiBlocked() ? undefined : run({ explainView: id }); + } // Change the global result-row cap: persist the (normalized) preference and // re-run the current query so a raise genuinely fetches more (server-side cap), // a lower one stops sooner. run() no-ops on an empty editor, so changing the @@ -1518,7 +1565,7 @@ export function createApp(env = {}) { function setResultRowLimit(n) { app.state.resultRowLimit = normalizeRowLimit(n); app.savePref('resultRowLimit', app.state.resultRowLimit); - return run(); + return app.activeTab().editorMode === 'sql' ? run() : undefined; } // Fetch the DDL for `target` (e.g. 'db.table' or 'DATABASE db') with @@ -1546,7 +1593,7 @@ export function createApp(env = {}) { // Replaces the active editor's content (undo restores the prior query). async function insertCreate(target) { const sql = await fetchCreateSql(target); - if (sql != null) app.editor.replaceDocument(sql); + if (sql != null) app.sqlEditor.replaceDocument(sql); } // Opens the DDL in a new tab, leaving the active tab untouched. @@ -1566,12 +1613,18 @@ export function createApp(env = {}) { // --- share + star ------------------------------------------------------ function share() { const tab = app.activeTab(); - const sql = (tab.sql || '').trim(); - const panel = tabPanel(tab); + if (tab.editorMode !== 'sql') return; + const evaluated = app.evaluateSpecDraft(tab, tab.specText, { dirty: tab.dirtySpec }); + if (!evaluated.parsed || hasBlockingSpecErrors(evaluated.diagnostics)) { + flashToast('Fix Spec errors before sharing', { document: doc }); + return; + } + const sql = String(tab.sqlDraft || ''); + const panel = queryPanel({ spec: evaluated.parsed }); // The gate matches the decode side (main.js): sql OR panel — a text panel // legitimately has no SQL, and a sql-only check would make it unshareable. - if (!sql && !isQuerylessPanel(panel)) return; - const query = withQuerySpec({ ...tab, sql }, tab.spec); + if (!sql.trim() && !isQuerylessPanel(panel)) return; + const query = withQuerySpec({ id: tab.savedId, sql }, evaluated.parsed); const url = loc.origin + loc.pathname + loc.search + '#' + encodeShare(query); win.history && win.history.replaceState && win.history.replaceState(null, '', url); const clip = (env.navigator || win.navigator || {}).clipboard; @@ -1631,11 +1684,11 @@ export function createApp(env = {}) { // (its own directory + per-statement log, since one file per script makes // no sense). Mirrors runEntry's split/branch. function exportEntry() { + if (app.activeTab().editorMode !== 'sql') return; if (app.state.exporting.value) return; const waveMs = wallNow(); // one wall clock for this export wave (gate + args) if (varGateBlocked(waveMs)) return; // don't export with unfilled variables (#134) - const sel = app.editor.getSelection().text; - const input = sel.trim() !== '' ? sel : app.activeTab().sql; + const input = app.activeTab().sqlDraft; const statements = splitStatements(input); if (!statements.length) { flashToast('Nothing to export', { document: doc }); return; } if (statements.length === 1) return exportDirect(statements[0], waveMs); @@ -1643,6 +1696,7 @@ export function createApp(env = {}) { } async function exportDirect(sqlInput, waveMs) { + if (app.activeTab().editorMode !== 'sql') return; if (app.state.exporting.value) return; if (!app.canExport()) return; // aria-disabled button; defensive guard const tab = app.activeTab(); @@ -1960,20 +2014,21 @@ export function createApp(env = {}) { url.revokeObjectURL(href); } - // The toolbar Save button reads "Saved" (accent) when the active tab is linked - // to a saved entry and its SQL AND panel config are unchanged; "Save" - // otherwise (incl. dirty) — a panel edit re-arms the button exactly like a - // SQL edit (#166 dirty pin), else the stale "Saved" label would tell the - // user their panel change needs no re-save. + const specBlocked = (tab) => !tab.specParsed || hasBlockingSpecErrors(tab.specDiagnostics); + app.specBlocked = specBlocked; + app.updateSaveBtn = () => { if (!app.dom.saveBtn) return; const tab = app.activeTab(); const entry = savedForTab(app.state, tab); - const clean = !!entry && entry.sql.trim() === String(tab.sql || '').trim() - && JSON.stringify(entry.spec) === JSON.stringify(tab.spec); + const clean = !!entry && !tab.dirtySql && !tab.dirtySpec; + const blocked = !!entry && specBlocked(tab); app.dom.saveBtn.classList.toggle('saved', clean); app.dom.saveBtn.replaceChildren(Icon.bookmark(), h('span', null, clean ? 'Saved' : 'Save')); - app.dom.saveBtn.title = clean ? 'Saved — edit to re-save (⌘S)' : 'Save query (⌘S)'; + app.dom.saveBtn.disabled = blocked; + app.dom.saveBtn.title = blocked + ? 'Fix blocking Spec errors before saving' + : clean ? 'Saved — edit to re-save (⌘S)' : 'Save query (⌘S)'; }; // Open `node` as a popover anchored under `anchorEl`: fixed-position below the // button, Esc + click-outside close (capture listeners), stored at @@ -2010,32 +2065,63 @@ export function createApp(env = {}) { return { close }; } - // Name popover anchored under the Save button. Prefill with the tab's name (or - // a name inferred from the SQL); Enter/Save → saveQuery (create or update in - // place) + relink the tab; Esc / click-outside cancels. + function commitLinkedQuery() { + const tab = app.activeTab(); + const evaluated = app.evaluateSpecDraft(tab, tab.specText, { dirty: tab.dirtySpec }); + if (!evaluated.parsed || hasBlockingSpecErrors(evaluated.diagnostics)) { + app.specEditor.revealDiagnostic(0); + flashToast('Fix Spec errors before saving', { document: doc }); + return null; + } + const panel = queryPanel({ spec: evaluated.parsed }); + if (!String(tab.sqlDraft || '').trim() && !isQuerylessPanel(panel)) { + flashToast('Nothing to save', { document: doc }); + return null; + } + const entry = commitSavedQuery(app.state, tab, evaluated.parsed, saveJSON); + if (!entry) return null; + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); + app.updateSaveBtn(); + app.actions.rerenderTabs(); + renderSavedHistory(app); + renderResults(app); + app.updateEditorModeUi(); + flashToast('Saved', { document: doc }); + return entry; + } + + function saveActiveQuery() { + return savedForTab(app.state, app.activeTab()) ? commitLinkedQuery() : openSavePopover(); + } + + // Creation-only Name/Description popover. Once linked, the textual Spec is + // authoritative and Save bypasses this UI entirely. function openSavePopover() { const tab = app.activeTab(); // A queryless panel (text, #166) is authored entirely in its cfg, so it // saves with empty SQL — the same per-type relaxation saveQuery applies. - if (!String(tab.sql || '').trim() && !isQuerylessPanel(tabPanel(tab))) { + if (!String(tab.sqlDraft || '').trim() && !isQuerylessPanel(tabPanel(tab))) { flashToast('Nothing to save', { document: doc }); return; } if (app.dom.savePopover) return; - const entry = savedForTab(app.state, tab); - const prefill = entry ? queryName(entry) : (tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sql)); + const prefill = tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sqlDraft); const input = h('input', { class: 'sp-input', value: prefill }); const descInput = h('textarea', { class: 'sp-desc', rows: '3', placeholder: 'What this query does — included in Markdown export' }); - if (entry && queryDescription(entry)) descInput.value = queryDescription(entry); let close; const commit = () => { if (!input.value.trim()) return; - saveQuery(app.state, tab, input.value, descInput.value, saveJSON); + const entry = createSavedQuery(app.state, tab, input.value, descInput.value, saveJSON); + if (!entry) return; close(); + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); app.updateSaveBtn(); + app.updateEditorModeUi(); app.actions.rerenderTabs(); renderSavedHistory(app); - flashToast('Saved', { document: doc }); // saveQuery dirtied the library → title effect adds the dot + flashToast('Saved', { document: doc }); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); // In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits. @@ -2053,6 +2139,42 @@ export function createApp(env = {}) { } app.openSavePopover = openSavePopover; + function formatSpec() { + const tab = app.activeTab(); + if (tab.editorMode !== 'spec') return; + const formatted = formatSpecText(tab.specText); + if (formatted.diagnostic) { + app.evaluateSpecDraft(tab, tab.specText, { dirty: tab.dirtySpec }); + app.specEditor.revealDiagnostic(0); + return; + } + app.specEditor.replaceDocument(formatted.text); + } + + function setEditorMode(mode) { + const tab = app.activeTab(); + if (mode === 'spec' && !savedForTab(app.state, tab)) { + flashToast('Save this query to create an editable Spec.', { document: doc }); + return false; + } + if (mode !== 'sql' && mode !== 'spec') return false; + tab.editorMode = mode; + app.updateEditorModeUi(); + const editor = mode === 'spec' ? app.specEditor : app.sqlEditor; + editor.requestMeasure?.(); + editor.focus(); + return true; + } + + app.activateInvalidSpecDraft = (tab) => { + if (!tab) return; + batch(() => { app.state.activeTabId.value = tab.id; }); + tab.editorMode = 'spec'; + app.updateEditorModeUi(); + app.specEditor.focus(); + flashToast('Fix Spec JSON first', { document: doc }); + }; + // User menu: dropdown under the header user button, holding the identity and // a Log out item. Same close model as the save popover (Esc + outside click). function openUserMenu() { @@ -2192,9 +2314,11 @@ export function createApp(env = {}) { exportDirect, cancelExport, cancelExportScript, - save: openSavePopover, + save: saveActiveQuery, openUserMenu, formatQuery, + formatSpec, + setEditorMode, explainQuery, setExplainView, setResultRowLimit, @@ -2208,8 +2332,8 @@ export function createApp(env = {}) { openDashboard, // Editor-mutating actions jump the mobile bottom-nav to the Editor panel // (#126) so a schema tap / SHOW CREATE lands where the user can see it. - insertAtCursor: (text) => { app.editor.insertAtCursor(text); toEditorOnMobile(); }, - replaceEditor: (text) => { app.editor.replaceDocument(text); toEditorOnMobile(); }, + insertAtCursor: (text) => { app.sqlEditor.insertAtCursor(text); toEditorOnMobile(); }, + replaceEditor: (text) => { app.sqlEditor.replaceDocument(text); toEditorOnMobile(); }, loadColumns, rerenderTabs: () => renderTabs(app), rerenderResults: () => renderResults(app), @@ -2302,7 +2426,11 @@ export function renderApp(app, helpers) { app.dom.runBtn = h('button', { class: 'run-btn', onclick: () => app.actions.run() }, Icon.play(), h('span', null, 'Run'), h('kbd', null, '⌘↵')); app.dom.fmtBtn = h('button', { class: 'tb-btn', title: 'Format SQL (⌘⇧↵)', onclick: () => app.actions.formatQuery() }, Icon.braces(), 'Format'); app.dom.explainBtn = h('button', { class: 'tb-btn', title: 'Explain this query (plan, indexes, pipeline, estimate)', onclick: () => app.actions.explainQuery() }, Icon.plan(), 'Explain'); + app.dom.formatSpecBtn = h('button', { class: 'tb-btn spec-action', title: 'Format Spec JSON (⌘⇧↵)', onclick: () => app.actions.formatSpec() }, Icon.braces(), 'Format'); app.dom.saveBtn = h('button', { class: 'tb-btn save-btn', onclick: () => app.actions.save() }); + app.dom.sqlModeBtn = h('button', { class: 'editor-mode-btn', onclick: () => app.actions.setEditorMode('sql'), 'aria-pressed': 'true' }, 'SQL'); + app.dom.specModeBtn = h('button', { class: 'editor-mode-btn', onclick: () => app.actions.setEditorMode('spec'), 'aria-pressed': 'false' }, 'Spec'); + app.dom.editorModeSwitch = h('div', { class: 'editor-mode-switch', role: 'group', 'aria-label': 'Editor mode' }, app.dom.sqlModeBtn, app.dom.specModeBtn); // Chromium + secure-context only (app.canExport), and disabled while one is // already running (app.state.exporting — see setExportBtn's effect below). // Aria-disabled with a tooltip rather than natively `disabled` — a natively @@ -2313,14 +2441,23 @@ export function renderApp(app, helpers) { }, Icon.download(), 'Export'); app.dom.shareBtn = h('button', { class: 'tb-btn', title: 'Share query (copies link)', onclick: () => app.actions.share() }, Icon.share(), 'Share'); - const editorToolbar = h('div', { class: 'ed-toolbar' }, app.dom.runBtn, app.dom.fmtBtn, app.dom.explainBtn, app.dom.saveBtn, h('div', { style: { flex: '1' } }), app.dom.exportBtn, app.dom.shareBtn); + const editorToolbar = h('div', { class: 'ed-toolbar' }, + app.dom.runBtn, app.dom.fmtBtn, app.dom.explainBtn, + app.dom.formatSpecBtn, + app.dom.saveBtn, app.dom.editorModeSwitch, + h('div', { style: { flex: '1' } }), app.dom.exportBtn, app.dom.shareBtn); // Query-variable strip (#134): one input per detected {name:Type} placeholder, // in a single row that scrolls horizontally (never wraps) when there are many. // Hidden (no vertical space) until the active tab has variables — see // renderVarStrip. Sits below the toolbar so it doesn't compete with the // splitter-sized editor for height. app.dom.varStrip = h('div', { class: 'var-strip', style: { display: 'none' } }); - app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } }); + app.dom.sqlEditorHost = h('div', { class: 'document-editor sql-document-editor' }); + app.dom.specEditorHost = h('div', { class: 'document-editor spec-document-editor' }); + app.dom.specStatus = h('div', { class: 'spec-status', role: 'status', 'aria-live': 'polite' }); + app.dom.specPane = h('div', { class: 'spec-editor-pane' }, app.dom.specEditorHost, app.dom.specStatus); + app.dom.editorRegion = h('div', { class: 'editor-region', style: { height: state.editorPct + '%', minHeight: '0', overflow: 'hidden', flexShrink: '0' } }, + app.dom.sqlEditorHost, app.dom.specPane); app.dom.resultsRegion = h('div', { class: 'results-region', style: { flex: '1', minHeight: '0', overflow: 'hidden' } }); // Drop a database/table from the schema tree here → render its lineage graph. // Disabled in mobile mode (#126): native drag doesn't fire from touch, and the @@ -2359,17 +2496,48 @@ export function renderApp(app, helpers) { app.root.replaceChildren(header, app.dom.banner, mainRow, app.dom.mobileNav); - app.editor.mount(app.dom.editorRegion); + app.sqlEditor.mount(app.dom.sqlEditorHost); + app.specEditor.mount(app.dom.specEditorHost); + app.updateEditorModeUi = () => { + const tab = app.activeTab(); + const linked = !!savedForTab(state, tab); + if (!linked && tab.editorMode === 'spec') tab.editorMode = 'sql'; + const specMode = tab.editorMode === 'spec'; + app.dom.sqlEditorHost.hidden = specMode; + app.dom.specPane.hidden = !specMode; + for (const button of [app.dom.runBtn, app.dom.fmtBtn, app.dom.explainBtn]) button.hidden = specMode; + app.dom.formatSpecBtn.hidden = !specMode; + for (const button of [app.dom.exportBtn, app.dom.shareBtn]) button.hidden = specMode; + app.dom.sqlModeBtn.classList.toggle('active', !specMode); + app.dom.specModeBtn.classList.toggle('active', specMode); + app.dom.sqlModeBtn.setAttribute('aria-pressed', String(!specMode)); + app.dom.specModeBtn.setAttribute('aria-pressed', String(specMode)); + app.dom.specModeBtn.classList.toggle('is-disabled', !linked); + app.dom.specModeBtn.setAttribute('aria-disabled', String(!linked)); + app.dom.specModeBtn.title = linked ? 'Edit saved-query Spec JSON' : 'Save this query to create an editable Spec.'; + const diagnostic = tab.specDiagnostics && tab.specDiagnostics[0]; + app.dom.specStatus.className = 'spec-status' + (diagnostic ? ` is-${diagnostic.severity}` : ' is-valid'); + app.dom.specStatus.textContent = diagnostic + ? `${diagnostic.line ? `Line ${diagnostic.line}, column ${diagnostic.column}: ` : ''}${diagnostic.message}` + : 'Valid Spec JSON'; + app.dom.shareBtn.disabled = app.specBlocked(tab); + app.dom.shareBtn.title = app.specBlocked(tab) ? 'Fix blocking Spec errors before sharing' : 'Share query (copies link)'; + app.dom.varStrip.hidden = specMode; + app.updateSaveBtn(); + }; // Reactive repaint of the tab-dependent surface — replaces the old tabs.js // refresh(): re-runs whenever the tab list or active tab changes, so tab ops // just mutate the signals. effect(() => { app.state.tabs.value; app.state.activeTabId.value; + app.revalidateSpecDrafts({ refreshUi: false }); renderTabs(app); - app.editor.syncFromState(); + app.sqlEditor.syncFromState(); + app.specEditor.syncFromState(); app.updateSaveBtn(); app.renderVarStrip(); // switching tabs / opening a saved query re-detects variables + app.updateEditorModeUi(); }); // Reactive repaint of the results pane: re-runs on a tab switch, a Table/JSON/ // Chart view change, or a run-state flip. (renderResults' activeTab() also @@ -2393,7 +2561,7 @@ export function renderApp(app, helpers) { // that fires for keyboard, mouse, and programmatic selection; gate on the // editor being focused so selecting elsewhere (results, address bar) is ignored. app.syncSelection = () => { - const sel = app.editor.hasFocus() ? app.editor.getSelection().text : ''; + const sel = app.sqlEditor.hasFocus() ? app.sqlEditor.getSelection().text : ''; app.state.hasSelection.value = sel.trim() !== ''; }; app.document.addEventListener('selectionchange', app.syncSelection); diff --git a/src/ui/file-menu.js b/src/ui/file-menu.js index 495bc0a5..66d9c7b3 100644 --- a/src/ui/file-menu.js +++ b/src/ui/file-menu.js @@ -233,6 +233,7 @@ function doNew(app) { * repaints itself via the libraryName/libraryDirty effect in createApp. */ function afterLibraryChange(app) { app.updateSaveBtn(); + app.updateEditorModeUi(); renderSavedHistory(app); } diff --git a/src/ui/panels.js b/src/ui/panels.js index 18002d4c..b059dfa4 100644 --- a/src/ui/panels.js +++ b/src/ui/panels.js @@ -21,7 +21,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { renderChart } from './chart-render.js'; -import { tabPanel } from '../state.js'; +import { patchSpecDraft, tabPanel } from '../state.js'; import { patchQueryPanel } from '../core/saved-query.js'; import { renderGridView, GRID_VIS_CAP } from './grid-render.js'; import { renderLogs } from './logs.js'; @@ -245,7 +245,7 @@ export function renderResolvedPanel(app, resolved, result, opts) { * a local repaint. The text arm needs no result at all; query-backed arms * show an empty-preview hint until a Run has happened. * - * Dirty pin (#166): the preview renders resolvePanel's CLONE; `tab.spec.panel` + * Dirty pin (#166): the preview renders resolvePanel's CLONE; `tab.specParsed.panel` * is written only here from picker/controls changes. Render never writes it, * so auto-derived cfg stays transient. Unknown panel siblings are retained. * @@ -273,7 +273,16 @@ function panelContext(app, r) { function writePanel(app, hooks, payload, activate = false) { const tab = app.activeTab(); - tab.spec = patchQueryPanel(tab, { cfg: payload.cfg, key: payload.key ?? undefined }).spec; + const result = patchSpecDraft(tab, (spec) => patchQueryPanel( + { id: tab.savedId, sql: tab.sqlDraft, specVersion: tab.specVersion, spec }, + { cfg: payload.cfg, key: payload.key ?? undefined }, + ).spec, { dirty: true }); + if (!result.ok) { + app.activateInvalidSpecDraft(result.invalidTab); + return; + } + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); if (activate) app.state.resultView.value = 'panel'; hooks.markDirty(); hooks.rerender(); diff --git a/src/ui/results.js b/src/ui/results.js index 8e7c175a..45ab1c91 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -114,7 +114,7 @@ export function renderResults(app) { } // The Panel drawer tab's caller seams (#166): the repaint scope, the cell // drawer, the tab-dirty wiring (a panel-cfg edit dirties exactly like a SQL -// edit — same signal writes as app.editor.onDocChange), and the display cap. +// edit — same UI writes as the independent editor callbacks), and the display cap. // Supplied from here (not imported by panels.js) so panels.js never imports // results.js back. function panelHooks(app, r) { @@ -123,9 +123,9 @@ function panelHooks(app, r) { onCell: (name, type, value) => openCellDetail(app, name, type, value), cap: r ? visCap(r) : undefined, markDirty: () => { - app.activeTab().dirty = true; app.actions.rerenderTabs(); app.updateSaveBtn(); + app.updateEditorModeUi?.(); }, }; } diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index a6bd415f..8b4f640e 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -8,7 +8,8 @@ import { Icon } from './icons.js'; import { timeAgo } from '../core/format.js'; import { SUBQUERY_MIME } from './dnd-mime.js'; import { - sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, deleteHistory, SAVED_VIEWS, + sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, + deleteHistory, invalidSpecTabForSaved, SAVED_VIEWS, } from '../state.js'; import { isAutoRunnable } from '../core/sql-split.js'; import { isQuerylessPanel } from '../core/panel-cfg.js'; @@ -114,7 +115,16 @@ function renderSaved(app, list) { const view = queryView(q); const star = h('button', { class: 'sv-star' + (favorite ? ' on' : ''), title: favorite ? 'Unfavorite' : 'Favorite', - onclick: (e) => { e.stopPropagation(); toggleFavorite(state, q.id, app.saveJSON); renderSavedHistory(app); }, + onclick: (e) => { + e.stopPropagation(); + const result = toggleFavorite(state, q.id, app.saveJSON); + if (result && result.invalidTab) app.activateInvalidSpecDraft(result.invalidTab); + else if (result && result.ok) { + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); + } + renderSavedHistory(app); + }, }, Icon.star(favorite)); // Run-less view restore (#166): an entry that can't auto-run (empty SQL — @@ -135,11 +145,23 @@ function renderSaved(app, list) { h('span', { class: 'name' }, name), h('button', { class: 'sv-act', title: 'Edit name & description', - onclick: (e) => { e.stopPropagation(); app.state.editingSavedId.value = q.id; renderSavedHistory(app); }, + onclick: (e) => { + e.stopPropagation(); + const invalidTab = invalidSpecTabForSaved(state, q.id); + if (invalidTab) app.activateInvalidSpecDraft(invalidTab); + else app.state.editingSavedId.value = q.id; + renderSavedHistory(app); + }, }, Icon.pencil()), h('button', { class: 'sv-act', title: 'Delete', - onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); }, + onclick: (e) => { + e.stopPropagation(); + deleteSaved(state, q.id, app.saveJSON); + app.updateSaveBtn(); + app.updateEditorModeUi(); + renderSavedHistory(app); + }, }, Icon.trash())), description ? h('div', { class: 'desc' }, description) : null, h('div', { class: 'preview' }, q.sql.split('\n')[0])); @@ -164,8 +186,13 @@ function savedEditForm(app, q) { if (done) return; done = true; if (commit && nameInput.value.trim()) { - renameSaved(state, q.id, nameInput.value, descInput.value, app.saveJSON); - app.actions.rerenderTabs(); + const result = renameSaved(state, q.id, nameInput.value, descInput.value, app.saveJSON); + if (result && result.invalidTab) app.activateInvalidSpecDraft(result.invalidTab); + else { + app.revalidateSpecDrafts(); + app.specEditor.syncFromState(); + app.actions.rerenderTabs(); + } } app.state.editingSavedId.value = null; renderSavedHistory(app); diff --git a/src/ui/shortcuts.js b/src/ui/shortcuts.js index d91fecc8..aac7fb0a 100644 --- a/src/ui/shortcuts.js +++ b/src/ui/shortcuts.js @@ -4,9 +4,11 @@ import { h, attachBackdropClose } from './dom.js'; const SHORTCUTS = [ ['Run query', '⌘↵'], - ['Format query', '⌘⇧↵'], + ['Format active document', '⌘⇧↵'], ['Save query', '⌘S'], ['Share query', '⌘⇧S'], + ['SQL editor mode', '⌘⌥1'], + ['Spec editor mode', '⌘⌥2'], ['Undo', '⌘Z'], ['Redo', '⌘⇧Z'], ['Show this dialog', '?'], @@ -62,6 +64,7 @@ export function handleKeydown(e, app) { if (e.defaultPrevented) return null; const mod = e.metaKey || e.ctrlKey; const signedIn = app.isSignedIn(); + const editorMode = app.activeTab().editorMode || 'sql'; // Esc cancels an in-flight query (aborts the stream + KILL QUERY). if (e.key === 'Escape' && app.state.running.value) { e.preventDefault(); @@ -69,20 +72,32 @@ export function handleKeydown(e, app) { return 'cancel'; } if (mod && e.key === 'Enter') { - // ⌘/Ctrl+Shift+Enter = format (gated by sign-in); ⌘/Ctrl+Enter = run. + // Format targets the active document. Plain Mod-Enter is SQL-only. if (e.shiftKey) { if (!signedIn) return null; e.preventDefault(); + if (editorMode === 'spec') { + app.actions.formatSpec(); + return 'formatSpec'; + } app.actions.formatQuery(); return 'formatQuery'; } + if (editorMode !== 'sql') return null; e.preventDefault(); app.actions.run(); return 'run'; } - if (mod && e.shiftKey && e.key.toLowerCase() === 's') { + if (mod && e.altKey && (e.key === '1' || e.key === '2')) { if (!signedIn) return null; e.preventDefault(); + const mode = e.key === '1' ? 'sql' : 'spec'; + app.actions.setEditorMode(mode); + return mode + 'Mode'; + } + if (mod && e.shiftKey && e.key.toLowerCase() === 's') { + if (!signedIn || editorMode !== 'sql') return null; + e.preventDefault(); app.actions.share(); return 'share'; } diff --git a/src/ui/tabs.js b/src/ui/tabs.js index 2d9618ad..e6533438 100644 --- a/src/ui/tabs.js +++ b/src/ui/tabs.js @@ -3,7 +3,7 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; -import { activeTab, allocTabId, newTabObj } from '../state.js'; +import { activeTab, allocTabId, newTabObj, setTabSpecDraft, tabDirty } from '../state.js'; import { cloneJson, queryName, upgradeSavedQuery } from '../core/saved-query.js'; import { batch } from '@preact/signals-core'; @@ -15,7 +15,7 @@ export function renderTabs(app) { const isActive = t.id === app.state.activeTabId.value; return h('div', { class: 'qtab' + (isActive ? ' active' : ''), onclick: () => selectTab(app, t.id) }, h('span', { class: 'name' }, t.name), - t.dirty ? h('span', { class: 'dirty' }) : null, + tabDirty(t) ? h('span', { class: 'dirty' }) : null, app.state.tabs.value.length > 1 ? h('button', { class: 'close', @@ -44,7 +44,7 @@ export function newTab(app) { app.state.tabs.value = [...app.state.tabs.value, newTabObj(id)]; app.state.activeTabId.value = id; }); - app.editor.focus(); + app.sqlEditor.focus(); } /** @@ -53,25 +53,34 @@ export function newTab(app) { * sharing, and Save retain extensions rather than reconstructing known fields. */ export function loadIntoNewTab(app, queryOrName, sql = '') { + if (queryOrName && typeof queryOrName === 'object' && queryOrName.id) { + const existing = app.state.tabs.value.find((tab) => tab.savedId === queryOrName.id); + if (existing) { + app.state.activeTabId.value = existing.id; + app.sqlEditor.focus(); + return existing; + } + } const id = allocTabId(app.state); const tab = newTabObj(id); if (queryOrName && typeof queryOrName === 'object') { const query = upgradeSavedQuery(queryOrName); tab.name = queryName(query); - tab.sql = query.sql; + tab.sqlDraft = query.sql; tab.savedId = query.id || null; tab.specVersion = query.specVersion; - tab.spec = cloneJson(query.spec); + setTabSpecDraft(tab, cloneJson(query.spec)); } else { tab.name = queryOrName || 'Untitled'; - tab.sql = sql; - tab.spec.name = tab.name; + tab.sqlDraft = sql; + setTabSpecDraft(tab, { ...tab.specParsed, name: tab.name }); } batch(() => { app.state.tabs.value = [...app.state.tabs.value, tab]; app.state.activeTabId.value = id; }); - app.editor.focus(); + app.sqlEditor.focus(); + return tab; } /** Close a tab (never the last one), re-selecting a neighbour if needed. */ diff --git a/tests/e2e/editor-cm6.spec.js b/tests/e2e/editor-cm6.spec.js index 65d3f55f..07120346 100644 --- a/tests/e2e/editor-cm6.spec.js +++ b/tests/e2e/editor-cm6.spec.js @@ -34,7 +34,7 @@ test.describe('CM6 editor', () => { await page.keyboard.press('Control+Enter'); // the run chord, completion still open const chords = await page.evaluate(() => window.__chords); expect(chords).toEqual([{ prevented: false, shift: false }]); // reached the global handler, unprevented - const value = await page.evaluate(() => window.__app.dom.editorView.state.doc.toString()); + const value = await page.evaluate(() => window.__app.dom.sqlEditorView.state.doc.toString()); expect(value).toBe('sel'); // no blank line inserted, no completion accepted }); @@ -46,7 +46,7 @@ test.describe('CM6 editor', () => { await expect(page.locator('.cm-tooltip-autocomplete li[aria-selected]')).toBeVisible(); await page.waitForTimeout(150); await page.keyboard.press('Enter'); - const value = await page.evaluate(() => window.__app.dom.editorView.state.doc.toString()); + const value = await page.evaluate(() => window.__app.dom.sqlEditorView.state.doc.toString()); expect(value.toUpperCase()).toBe('SELECT'); }); @@ -55,12 +55,12 @@ test.describe('CM6 editor', () => { await page.keyboard.type(')'); // typing the closer steps over, no double await page.keyboard.type(" '"); // quote pairs in code await page.keyboard.type('a('); // ( inside the string must NOT inject a stray ) - const value = await page.evaluate(() => window.__app.dom.editorView.state.doc.toString()); + const value = await page.evaluate(() => window.__app.dom.sqlEditorView.state.doc.toString()); expect(value).toBe("select () 'a('"); }); test('tab switches keep per-tab undo histories', async ({ page }) => { - const doc = () => window.__app.dom.editorView.state.doc.toString(); + const doc = () => window.__app.dom.sqlEditorView.state.doc.toString(); const switchTab = (id) => { window.__app.state.activeTabId.value = id; window.__port.syncFromState(); @@ -68,7 +68,7 @@ test.describe('CM6 editor', () => { await page.keyboard.type('one'); await page.evaluate(() => { const { state } = window.__app; - state.tabs.value = [...state.tabs.value, { id: 't2', name: 'T2', sql: '', dirty: false, result: null, savedId: null, panelCfg: null, panelKey: null }]; + state.tabs.value = [...state.tabs.value, window.__newTab('t2')]; state.activeTabId.value = 't2'; window.__port.syncFromState(); }); @@ -108,7 +108,7 @@ test.describe('CM6 editor', () => { await page.keyboard.type('select e. from events e'); // Put the caret just after the `e.` and open completion there explicitly. await page.evaluate(() => { - const v = window.__app.dom.editorView; + const v = window.__app.dom.sqlEditorView; v.dispatch({ selection: { anchor: 9 } }); // after "select e." v.focus(); }); diff --git a/tests/e2e/editor-insert.spec.js b/tests/e2e/editor-insert.spec.js index f5dc21d8..8055512e 100644 --- a/tests/e2e/editor-insert.spec.js +++ b/tests/e2e/editor-insert.spec.js @@ -10,7 +10,7 @@ import { test, expect } from '@playwright/test'; // Serialized into page.evaluate calls — reads the editor through the CM6 view. const readEditor = () => { - const view = window.__app.dom.editorView; + const view = window.__app.dom.sqlEditorView; return { value: view.state.doc.toString(), caret: view.state.selection.main.head, @@ -27,7 +27,7 @@ test.describe('editor insertion (schema double-click path)', () => { test('insertAtCursor splices at the caret and leaves the caret after the text', async ({ page }) => { await page.evaluate(() => { window.__setSql('SELECT FROM t'); - window.__app.dom.editorView.dispatch({ selection: { anchor: 7 } }); // caret at the 2nd space + window.__app.dom.sqlEditorView.dispatch({ selection: { anchor: 7 } }); // caret at the 2nd space window.__insertAtCursor('count(*)'); // what a column/db double-click does }); const r = await page.evaluate(readEditor); @@ -83,7 +83,7 @@ test.describe('editor insertion (schema double-click path)', () => { // FORMAT, which must be stripped, wrapped in parens, and spliced at the // drop point. const value = await page.evaluate(() => { - const view = window.__app.dom.editorView; + const view = window.__app.dom.sqlEditorView; window.__setSql('SELECT * FROM t'); const rect = view.contentDOM.getBoundingClientRect(); const dt = new DataTransfer(); diff --git a/tests/e2e/editor.html b/tests/e2e/editor.html index 4578bbd7..56c6fac4 100644 --- a/tests/e2e/editor.html +++ b/tests/e2e/editor.html @@ -8,12 +8,27 @@
+
+ +
+ + + + + +
+ + +