From 1b64f600796e14cbdadd6afeadf7377581b46ac6 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Sat, 27 Jun 2026 13:46:56 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(schema):=20full=20lineage=20view=20in?= =?UTF-8?q?=20a=20live=20new=20tab=20=E2=80=94=20drag-move,=20undo/redo,?= =?UTF-8?q?=20detail=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the fullscreen schema-lineage view from an in-app modal overlay into a real browser tab driven by the opener (same-origin about:blank), with an in-app overlay fallback when pop-ups are blocked / COOP severs the opener. - New tab kept live by the opener: the page CSS + theme are copied in, and the opener (which holds the OAuth token + ch-client) fetches node detail on demand and writes it into the child document. `window.open`/`stylesText` are injected seams on createApp(env); h()/s() gained a `withDocument` seam so the same builders populate the child realm (duck-typed node check fixes cross-realm `instanceof Node`). - Headline: "Schema: ", the engine colour legend (incl. Buffer/Merge), a day/night switcher, and a right-aligned actions cluster; no ✕ in the tab. - Mouse model with three distinct cursors: pointer (click → detail pane), move ✛ (⌘/Ctrl-drag → relocate node, incident edges re-route straight), grab (plain drag → pan). Esc closes the detail pane. - Node-move undo/redo (⌘Z / ⌘⇧Z / ⌘Y + headline buttons), per-result position persistence, and an Enum/long-type truncation so a card can't blow out the layout width. Pure geometry/history/card math lives in src/core (graph-layout.js, schema-cards.js) at 100% coverage; the DOM glue stays in src/ui. 985 tests pass, per-file coverage gate green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CNaybba6T2qFDDEYWqzXh1 --- README.md | 23 +- src/core/graph-layout.js | 87 ++++++++ src/core/schema-cards.js | 11 +- src/styles.css | 34 ++- src/ui/app.js | 30 ++- src/ui/dom.js | 25 ++- src/ui/explain-graph.js | 359 +++++++++++++++++++++++++----- src/ui/icons.js | 3 + src/ui/schema-detail.js | 16 +- tests/unit/app.test.js | 38 +++- tests/unit/dom.test.js | 36 ++- tests/unit/explain-graph.test.js | 362 ++++++++++++++++++++++++++++--- tests/unit/graph-layout.test.js | 107 +++++++++ tests/unit/schema-cards.test.js | 12 + tests/unit/schema-detail.test.js | 18 ++ 15 files changed, 1056 insertions(+), 105 deletions(-) create mode 100644 src/core/graph-layout.js create mode 100644 tests/unit/graph-layout.test.js diff --git a/README.md b/README.md index c45d400..8131fb9 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,24 @@ engine-specific lineage: materialized views (`feeds` from sources, `writes` to t target), regular views (`reads` their sources), dictionaries (`dict` from a source table), and `Distributed`/`Buffer`/`Merge` engines pointing at their backing tables. Nodes are coloured by kind (table / view / materialized view / dictionary / -distributed / external) with a legend; edges are coloured and labelled by -relationship. Drag a **database** → the whole-DB lineage (it shows only the tables -that participate in a relationship; a database whose tables aren't linked by any -view/MV/dictionary/Distributed engine shows a "no object relationships" message -rather than a wall of disconnected boxes); drag a **table** → its 1-hop +distributed / buffer / merge / external) with a legend; edges are coloured and +labelled by relationship. Drag a **database** → the whole-DB lineage (it shows only +the tables that participate in a relationship; a database whose tables aren't linked +by any view/MV/dictionary/Distributed engine shows a "no object relationships" +message rather than a wall of disconnected boxes); drag a **table** → its 1-hop neighbourhood. **Click any node** to run `SHOW CREATE` for it into the editor; -**⌘/Ctrl-drag** to pan; **Expand** for a fullscreen pan/zoom view. +**⌘/Ctrl-drag** to pan; **Expand** for the full view. + +The full view opens in a **real browser tab** kept live by the opener (it still +holds the OAuth token, so click-to-detail fetches on demand) — keep the tab open +beside the editor. If a pop-up is blocked it falls back to an in-app overlay. Three +cursor shapes keep the actions distinct: a **pointer** over a card (**click** opens a +detail pane — full columns / keys / partitions / DDL), the **move ✛** cursor when +**⌘/Ctrl** is held over a card (**⌘/Ctrl-drag to move it**, its edges re-route as +straight lines), and the **grab hand** over empty canvas (plain **drag pans**). Wheel +pans, ⌘/Ctrl+wheel zooms, double-click fits, **Esc** closes the detail pane. +Node moves are **undo/redo-able** (⌘/Ctrl+Z, ⌘/Ctrl+Shift+Z or ⌘/Ctrl+Y), and +manually-moved positions persist for as long as that result is open. Discovery is **structured-first, parse-fallback**, because the helpful `system.tables` columns are build-dependent: it prefers `dependencies_table` / diff --git a/src/core/graph-layout.js b/src/core/graph-layout.js new file mode 100644 index 0000000..577c2f7 --- /dev/null +++ b/src/core/graph-layout.js @@ -0,0 +1,87 @@ +// Pure geometry + state helpers for the interactive schema graph: convert a +// pixel drag into svg-user-unit deltas, re-route an edge as a straight line +// clipped to its two node boxes, find a node's incident edges, and apply/record +// manually-moved node positions. No DOM, no globals — the DOM wiring (mousedown +// tracking, attribute writes) lives in src/ui/explain-graph.js. + +/** Centre point of a node box (top-left x/y, w/h). */ +export function nodeCenter(n) { + return { x: n.x + n.w / 2, y: n.y + n.h / 2 }; +} + +// Where the ray from `node`'s centre toward `toward` crosses `node`'s rectangle +// border — so an edge endpoint lands on the box edge, not buried at the centre. +function clipToBox(node, toward) { + const cx = node.x + node.w / 2; + const cy = node.y + node.h / 2; + const dx = toward.x - cx; + const dy = toward.y - cy; + if (dx === 0 && dy === 0) return { x: cx, y: cy }; // coincident centres + let s = Infinity; + if (dx !== 0) s = Math.min(s, (node.w / 2) / Math.abs(dx)); + if (dy !== 0) s = Math.min(s, (node.h / 2) / Math.abs(dy)); + return { x: cx + dx * s, y: cy + dy * s }; +} + +/** + * Two-point polyline for an edge `from → to`, each endpoint clipped to its + * node's rectangle border. Replaces dagre's routed bend points when a node is + * moved (decision: straighten only the incident edges). + */ +export function straightEdgePoints(from, to) { + return [clipToBox(from, nodeCenter(to)), clipToBox(to, nodeCenter(from))]; +} + +/** Indices of the edges incident to `nodeId` (touching it as source or target). */ +export function incidentEdges(edges, nodeId) { + const out = []; + edges.forEach((e, i) => { if (e.from === nodeId || e.to === nodeId) out.push(i); }); + return out; +} + +/** + * Convert a pixel drag delta to svg user units for the current viewBox `vb` + * ({x,y,w,h}) shown in a container of pixel size `rect`. Mirrors the pan algebra + * in attachPanZoom (svgΔ = pxΔ · vb.w/rect.width). + */ +export function dragDeltaToSvg(dxPx, dyPx, vb, rect) { + return { dx: dxPx * (vb.w / (rect.width || 1)), dy: dyPx * (vb.h / (rect.height || 1)) }; +} + +/** + * Overlay remembered `{id: {x,y}}` positions onto laid-out nodes in place (a + * node with no saved position keeps its dagre coordinates). Returns the array. + */ +export function applyPositions(nodes, positions) { + if (!positions) return nodes; + for (const n of nodes) { + const p = positions[n.id]; + if (p) { n.x = p.x; n.y = p.y; } + } + return nodes; +} + +/** Remember a node's moved position (mutates + returns the per-result map). */ +export function recordPosition(positions, id, x, y) { + positions[id] = { x, y }; + return positions; +} + +/** + * A linear undo/redo history of node-move operations. Each op is + * `{ id, from:{x,y}, to:{x,y} }`. record() pushes an op and clears the redo + * branch (standard linear-history semantics). undo()/redo() return the op to + * apply — the caller moves the node to op.from on undo, op.to on redo — or null + * when the respective stack is empty. No DOM; the UI does the repositioning. + */ +export function createMoveHistory() { + const past = []; + const future = []; + return { + record(op) { past.push(op); future.length = 0; }, + undo() { if (!past.length) return null; const op = past.pop(); future.push(op); return op; }, + redo() { if (!future.length) return null; const op = future.pop(); past.push(op); return op; }, + canUndo() { return past.length > 0; }, + canRedo() { return future.length > 0; }, + }; +} diff --git a/src/core/schema-cards.js b/src/core/schema-cards.js index 9b6751c..06752c3 100644 --- a/src/core/schema-cards.js +++ b/src/core/schema-cards.js @@ -20,6 +20,15 @@ export const CARD = { BADGE_W: 26, // approx width of one role badge (PK/SK/PARTITION/SAMPLING) MIN_W: 130, MAX_COLS: 16, + MAX_TYPE: 28, // truncate the displayed column type — a big Enum/Tuple/Map would + // otherwise blow the card (and the whole graph) absurdly wide. +}; + +// Clamp an over-long column type for the card (the full type stays in the detail +// pane). Keeps a giant inline Enum8('a'=1, …) from stretching the layout. +const clampType = (t) => { + const s = String(t == null ? '' : t); + return s.length > CARD.MAX_TYPE ? s.slice(0, CARD.MAX_TYPE - 1) + '…' : s; }; // A ClickHouse UInt8 flag is 1/0, but JSON vs JSONStrings formats deliver it as @@ -51,7 +60,7 @@ export function buildCardModel(node, tableRow, columns, skipIndices) { const summary = engine + ' · ' + formatRows(tr.total_rows) + ' rows · ' + formatBytes(tr.total_bytes); const allCols = columns || []; const cols = allCols.slice(0, CARD.MAX_COLS).map((c) => ({ - name: c.name, type: c.type, roles: columnRoles(c), + name: c.name, type: clampType(c.type), roles: columnRoles(c), })); const overflow = Math.max(0, allCols.length - CARD.MAX_COLS); const idx = skipIndices || []; diff --git a/src/styles.css b/src/styles.css index 3f33b3e..ae0e6d7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -705,6 +705,8 @@ body { .sg-swatch--mv { background: #8b5cf6; border-color: #8b5cf6; } .sg-swatch--dictionary { background: #3b82f6; border-color: #3b82f6; } .sg-swatch--distributed { background: #f97316; border-color: #f97316; } +.sg-swatch--buffer { background: #eab308; border-color: #eab308; } +.sg-swatch--merge { background: #64748b; border-color: #64748b; } .sg-swatch--external { background: transparent; border-style: dashed; } .res-graph-title { font-size: 11.5px; color: var(--fg-mute); font-weight: 500; padding: 0 4px; } @@ -725,8 +727,14 @@ body { padding: 9px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; } -.graph-overlay-title { font-size: 12.5px; font-weight: 600; color: var(--fg); } -.graph-overlay-zoom { display: flex; gap: 6px; margin-left: auto; } +.graph-overlay-title { font-size: 12.5px; font-weight: 600; color: var(--fg); flex-shrink: 0; } +.graph-overlay-zoom { display: flex; gap: 6px; } +/* Right-aligned action cluster (theme switcher + zoom + optional close). */ +.graph-overlay-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 0; } +/* The colour key sits in the headline (not over the canvas) for the full view. */ +.graph-overlay-bar .schema-graph-legend { + position: static; max-width: none; flex-wrap: wrap; gap: 3px 12px; min-width: 0; +} .graph-overlay-close { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border: none; border-radius: 6px; @@ -741,6 +749,27 @@ body { .graph-overlay-canvas > svg.explain-graph { width: 100%; height: 100%; } .graph-overlay-note { font-size: 11px; color: #eab308; } +/* Full schema view cursor model — three distinct shapes so each action is + unambiguous: + • over a node (no modifier): pointer → click opens the detail pane + • over a node + ⌘/Ctrl: move (✛) → drag relocates the node + • empty canvas + ⌘/Ctrl: grab/grabbing → drag pans the viewport + default elsewhere is the arrow. (Nodes carry an inline cursor:pointer; the + ⌘/Ctrl rule below is a class selector, so it overrides that presentation attr.) */ +.graph-overlay-canvas.schema-canvas { cursor: default; position: relative; } +.graph-overlay-canvas.schema-canvas.modkey { cursor: grab; } +.graph-overlay-canvas.schema-canvas.grabbing { cursor: grabbing; } +/* Whole card reads as clickable (finger) at rest… */ +.graph-overlay-canvas.schema-canvas .eg-card rect, +.graph-overlay-canvas.schema-canvas .eg-card text { cursor: pointer; } +/* …and as movable (✛) while ⌘/Ctrl is held. */ +.graph-overlay-canvas.schema-canvas.modkey .eg-card rect, +.graph-overlay-canvas.schema-canvas.modkey .eg-card text { cursor: move; } + +/* The schema graph's own browser tab: the panel fills the viewport chromelessly. */ +body.schema-tab { margin: 0; height: 100vh; background: var(--bg-editor); } +body.schema-tab .graph-overlay-panel { height: 100vh; border: none; border-radius: 0; } + /* ------------ schema node detail pane (fullscreen graph) ------------ */ .schema-detail { flex: 0 0 280px; min-height: 90px; position: relative; @@ -1234,6 +1263,7 @@ body { font-size: 11px; font-family: inherit; cursor: pointer; } .res-act:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); } +.res-act:disabled { opacity: .35; cursor: default; pointer-events: none; } .stat .ic { color: var(--fg-faint); display: flex; } .stat .v { color: var(--fg); } diff --git a/src/ui/app.js b/src/ui/app.js index c015862..25da84e 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -28,7 +28,7 @@ import { mountEditor, insertAtCursor, replaceEditor, SCHEMA_GRAPH_MIME } from '. import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs.js'; import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; -import { openSchemaFullscreen } from './explain-graph.js'; +import { openSchemaView } from './explain-graph.js'; import { openDetailPane } from './schema-detail.js'; import { renderSavedHistory } from './saved-history.js'; import { libraryControls, renderLibraryTitle } from './file-menu.js'; @@ -59,6 +59,11 @@ export function createApp(env = {}) { // Pipeline-graph layout seam: dagre (injected like Chart). The DOT parser and // SVG drawer are ours; dagre only computes node positions + edge bend points. Dagre: env.Dagre || win.dagre, + // The schema graph opens in a real browser tab driven by this window. Both are + // injected seams: openWindow so tests can stub window.open, stylesText so the + // child tab can inline the page's CSS (about:blank ships none of it). + openWindow: env.openWindow || ((...a) => win.open(...a)), + stylesText: env.stylesText || (doc.querySelector('style') ? doc.querySelector('style').textContent : ''), }; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — @@ -551,16 +556,22 @@ export function createApp(env = {}) { // result) keeps the inline path's shape frozen and the card data off the hot path. async function expandSchemaGraph(focus) { if (!focus || !focus.db) return; + // Pin the result whose Expand was clicked NOW: a tab switch during the async + // fetch must not redirect the saved-positions map to a different tab's result. + const clickedTab = app.activeTab(); + const sg = (clickedTab && clickedTab.result && clickedTab.result.schemaGraph) || null; + // Open the view synchronously so a real tab survives the click gesture (a + // pop-up opened after an await is blocked); fill it once the lineage loads. + const view = openSchemaView(app); await ensureConfig(); - if (!(await getToken())) { chCtx.onSignedOut(); return; } + if (!(await getToken())) { chCtx.onSignedOut(); view.fail('Sign in to view the schema graph.'); return; } let lineage; try { // Walk lineage transitively across DB boundaries (soft-capped) — pulls in // objects an other database references, instead of dead-ending at the edge. lineage = await ch.loadLineageTransitive(chCtx, focus); } catch { - // The inline graph is still on screen; tell the user the expand didn't load. - flashToast('Could not load the schema graph', { document: doc }); + view.fail('Could not load the schema graph'); return; } const g = buildSchemaGraph(lineage.rows, focus); @@ -570,19 +581,24 @@ export function createApp(env = {}) { const cards = await ch.loadSchemaCards(chCtx, dbs); const cardGraph = buildCardGraph({ nodes: ex.nodes, edges: ex.edges }, { tables: lineage.rows.tables, columnsByKey: cards.columnsByKey, skipByKey: cards.skipByKey }); - openSchemaFullscreen(app, { + // Persist manually-moved node positions per result: the map hangs off the live + // schemaGraph result (captured above) so re-opening keeps the layout. + const positions = (sg && sg.savedPositions) || {}; + if (sg) sg.savedPositions = positions; + view.render({ nodes: cardGraph.nodes, edges: cardGraph.edges, focus, tableCount: (lineage.rows.tables || []).length, truncated: lineage.truncated || ex.truncated, + savedPositions: positions, }); } // Open the detail pane for a clicked fullscreen node: lazily load the table's full // columns / partitions / DDL (best-effort) and mount the pane in the overlay. - async function openNodeDetail(node) { + async function openNodeDetail(node, targetDoc) { if (!node || !node.db || !node.name) return; const detail = await ch.loadTableDetail(chCtx, node.db, node.name); - openDetailPane(app, node, detail); + openDetailPane(app, node, detail, targetDoc); } // Explain the current query without editing it: run it through the EXPLAIN diff --git a/src/ui/dom.js b/src/ui/dom.js index a02e0e3..c49c28d 100644 --- a/src/ui/dom.js +++ b/src/ui/dom.js @@ -5,6 +5,21 @@ const SVG_NS = 'http://www.w3.org/2000/svg'; +// Ambient target document. Normally null → the global `document` (the served +// page). `withDocument(doc, fn)` redirects element creation at `doc` for the +// duration of `fn`, so the same builders can populate a second window (the +// schema graph's new browser tab) without a document parameter on every call. +let DOC = null; +const D = () => DOC || document; +// Realm-agnostic "is this a DOM node?" — `instanceof Node` is false for a node +// from another window (e.g. the schema tab), so we duck-type on nodeType. +const isNode = (c) => c != null && typeof c === 'object' && typeof c.nodeType === 'number'; +export function withDocument(doc, fn) { + const prev = DOC; + DOC = doc; + try { return fn(); } finally { DOC = prev; } +} + // Shared prop/children application — the only difference between h and s is // which document factory creates the element. function apply(el, props, children) { @@ -22,19 +37,23 @@ function apply(el, props, children) { } for (const c of children.flat(Infinity)) { if (c == null || c === false) continue; - el.appendChild(c instanceof Node ? c : document.createTextNode(String(c))); + // Duck-type on nodeType rather than `instanceof Node`: when building into + // another document (the schema tab via withDocument), child elements belong + // to that window's realm and fail the opener's `instanceof Node`, so they'd + // be stringified to "[object HTMLDivElement]". nodeType is realm-agnostic. + el.appendChild(isNode(c) ? c : D().createTextNode(String(c))); } return el; } export function h(tag, props, ...children) { if (typeof tag === 'function') return tag(props || {}, children); - return apply(document.createElement(tag), props, children); + return apply(D().createElement(tag), props, children); } // Build an element in the SVG namespace (same prop rules as h()). export function s(tag, props, ...children) { - return apply(document.createElementNS(SVG_NS, tag), props, children); + return apply(D().createElementNS(SVG_NS, tag), props, children); } // The page's CSS `zoom` factor as seen by `el`: getBoundingClientRect() is in diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 3e44ce1..4786ecc 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -5,13 +5,15 @@ // math (parse + layout) is pure in src/core/dot.js + dot-layout.js (dagre seam) // and the viewBox algebra in src/core/panzoom.js; this module only does SVG + DOM. -import { h, s } from './dom.js'; +import { h, s, withDocument } from './dom.js'; import { Icon } from './icons.js'; import { parseDot } from '../core/dot.js'; import { dagreLayout } from '../core/dot-layout.js'; import { buildCardModel, cardSize, CARD } from '../core/schema-cards.js'; import { qualifyIdent } from '../core/format.js'; import { fitBox, fitWidthBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js'; +import { straightEdgePoints, incidentEdges, dragDeltaToSvg, applyPositions, recordPosition, createMoveHistory } from '../core/graph-layout.js'; +import { flashToast } from './toast.js'; const ZOOM_STEP = 1.2; // per zoom-button press const WHEEL_ZOOM_STEP = 1.04; // per ⌘/Ctrl+wheel notch — gentle, so trackpad/wheel zoom isn't jumpy @@ -117,15 +119,17 @@ function graphSvgWithEdges(g, edgeClass, edgeLabel) { id: 'eg-arrow', viewBox: '0 0 10 10', refX: '9', refY: '5', markerWidth: '7', markerHeight: '7', orient: 'auto-start-reverse', }, s('path', { class: 'eg-arrowhead', d: 'M0 0L10 5L0 10z' })))); - for (const e of g.edges) { + g.edges.forEach((e, i) => { const d = 'M' + e.points.map((p) => p.x + ' ' + p.y).join(' L'); - svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)' })); + // data-eidx/from/to let the schema-graph move handler find and re-route the + // edges incident to a dragged node (harmless attrs for the pipeline graph). + svg.appendChild(s('path', { class: edgeClass(e), d, 'marker-end': 'url(#eg-arrow)', 'data-eidx': i, 'data-from': e.from, 'data-to': e.to })); const lbl = edgeLabel && edgeLabel(e); if (lbl) { const mid = e.points[Math.floor(e.points.length / 2)]; svg.appendChild(s('text', { class: 'eg-edge-label', x: mid.x, y: mid.y - 3, 'text-anchor': 'middle' }, lbl)); } - } + }); return svg; } @@ -141,7 +145,7 @@ function renderGraphSvg(g, opts = {}) { }, n.label); if (opts.onNode) { rect.setAttribute('cursor', 'pointer'); text.setAttribute('cursor', 'pointer'); - const fire = (e) => { e.stopPropagation(); opts.onNode(n); }; + const fire = (e) => { e.stopPropagation(); opts.onNode(n, e); }; rect.addEventListener('click', fire); text.addEventListener('click', fire); } svg.appendChild(rect); svg.appendChild(text); @@ -170,7 +174,7 @@ export function buildSchemaSvg(graph, dagre, onNode) { // cardSize() used to size the node, so no DOM measurement is needed. `model` is // always supplied by renderRichGraphSvg (a header-only model for a card-less node). function renderCardNode(n, model, nodeClass, onNode) { - const g = s('g', { class: 'eg-card' }); + const g = s('g', { class: 'eg-card', 'data-node-id': n.id }); const rect = s('rect', { class: nodeClass(n), x: n.x, y: n.y, width: n.w, height: n.h, rx: '5' }); g.appendChild(rect); const left = n.x + CARD.PAD_X; @@ -192,7 +196,7 @@ function renderCardNode(n, model, nodeClass, onNode) { if (model.skipLine) g.appendChild(s('text', { class: 'eg-skipidx', x: left, y: rowY() }, model.skipLine)); if (onNode) { rect.setAttribute('cursor', 'pointer'); - g.addEventListener('click', (e) => { e.stopPropagation(); onNode(n); }); + g.addEventListener('click', (e) => { e.stopPropagation(); onNode(n, e); }); } return g; } @@ -225,13 +229,26 @@ export function buildRichSchemaSvg(graph, dagre, onNode) { // `external` rides through dagreLayout (like kind/db/name), so the node class can // read it off the laid node — no side-channel needed. const laid = dagreLayout(dagre, { nodes: sized, edges: g.edges || [] }); - return renderRichGraphSvg(laid, { + // Overlay any manually-moved positions remembered for this result, then + // straighten the edges touching a moved node so they still connect on first draw. + const positions = g.savedPositions; + if (positions) { + applyPositions(laid.nodes, positions); + const byId = new Map(laid.nodes.map((n) => [n.id, n])); + for (const e of laid.edges) { + if (positions[e.from] || positions[e.to]) e.points = straightEdgePoints(byId.get(e.from), byId.get(e.to)); + } + } + // Remember each card's drawn origin so a live drag can translate its by a delta. + for (const n of laid.nodes) { n.x0 = n.x; n.y0 = n.y; } + const built = renderRichGraphSvg(laid, { cardById, nodeClass: (n) => 'eg-node eg-node--' + (n.kind || 'table') + (n.external ? ' eg-node--ext' : ''), edgeClass: (e) => 'eg-edge eg-edge--' + (e.kind || 'feeds'), edgeLabel: (e) => e.kind, onNode, }); + return { ...built, nodes: laid.nodes, edges: laid.edges }; } /** @@ -251,7 +268,8 @@ export function renderExplainGraph(app, r) { // and .eg-edge-- CSS colours). const NODE_LEGEND = [ ['table', 'Table'], ['view', 'View'], ['mv', 'Materialized View'], - ['dictionary', 'Dictionary'], ['distributed', 'Distributed'], ['external', 'External'], + ['dictionary', 'Dictionary'], ['distributed', 'Distributed'], + ['buffer', 'Buffer'], ['merge', 'Merge'], ['external', 'External'], ]; function schemaLegend() { return h('div', { class: 'schema-graph-legend' }, @@ -260,39 +278,34 @@ function schemaLegend() { } /** - * Open a graph in a fullscreen overlay (drag-pan, ⌘/Ctrl+wheel zoom, fit/zoom - * buttons; Esc / ✕ / backdrop close). `build()` returns `{svg,width,height,nodeCount}` - * — shared by the pipeline and schema graphs. `extra` is an optional overlay node - * (e.g. the schema legend); `note` an optional banner shown in the bar (e.g. a - * truncation warning); `pzOpts` extra options for attachPanZoom (e.g. fitWidth). + * Open a pipeline graph in a fullscreen overlay (drag-pan, ⌘/Ctrl+wheel zoom, + * fit/zoom buttons; Esc / ✕ / backdrop close). `build()` returns + * `{svg,width,height,nodeCount}`. Reuses the same panel/zoom chrome as the schema + * view (buildGraphPanel + zoomControls + the right-aligned actions cluster). */ -function openGraphFullscreen(app, title, build, extra, emptyMsg, note, pzOpts) { +function openGraphFullscreen(app, title, build) { const doc = (app && app.document) || document; - const built = build(); - const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; - let backdrop; - function close() { backdrop.remove(); doc.removeEventListener('keydown', onKey, true); } - - const bar = h('div', { class: 'graph-overlay-bar' }, h('span', { class: 'graph-overlay-title' }, title)); - if (note) bar.appendChild(h('span', { class: 'graph-overlay-note' }, note)); - const canvas = h('div', { class: 'graph-overlay-canvas' }); - if (!built.nodeCount) { - canvas.appendChild(placeholder(emptyMsg || 'Nothing to display.')); - } else { - canvas.appendChild(built.svg); - if (extra) canvas.appendChild(extra); - const pz = attachPanZoom(canvas, built.svg, built, pzOpts || {}); - bar.appendChild(h('div', { class: 'graph-overlay-zoom' }, - h('button', { class: 'res-act', title: 'Zoom out', onclick: pz.zoomOut }, Icon.minus()), - h('button', { class: 'res-act', title: 'Zoom in', onclick: pz.zoomIn }, Icon.plus()), - h('button', { class: 'res-act', title: 'Fit to screen', onclick: pz.fit }, 'Fit'))); - } - bar.appendChild(h('button', { class: 'graph-overlay-close', title: 'Close (Esc)', onclick: close }, Icon.close())); - const panel = h('div', { class: 'graph-overlay-panel', onclick: (e) => e.stopPropagation() }, bar, canvas); - backdrop = h('div', { class: 'graph-overlay', onclick: close }, panel); - doc.body.appendChild(backdrop); - doc.addEventListener('keydown', onKey, true); - return backdrop; + return withDocument(doc, () => { + const built = build(); + const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; + let backdrop; + function close() { backdrop.remove(); doc.removeEventListener('keydown', onKey, true); } + const { panel, bar, canvas } = buildGraphPanel(title); + const actions = h('div', { class: 'graph-overlay-actions' }); + if (!built.nodeCount) { + canvas.appendChild(placeholder('Nothing to display.')); + } else { + canvas.appendChild(built.svg); + const pz = attachPanZoom(canvas, built.svg, built); + actions.appendChild(zoomControls(pz)); + } + actions.appendChild(h('button', { class: 'graph-overlay-close', title: 'Close (Esc)', onclick: close }, Icon.close())); + bar.appendChild(actions); + backdrop = h('div', { class: 'graph-overlay', onclick: close }, panel); + doc.body.appendChild(backdrop); + doc.addEventListener('keydown', onKey, true); + return backdrop; + }); } /** Fullscreen pipeline graph (DOT). */ @@ -310,20 +323,268 @@ const schemaClick = (app) => (n) => { app.actions.insertCreate(qualifyIdent(n.db, n.name)); }; -// In the fullscreen graph, clicking an object opens the detail pane (full columns / -// keys / partitions / DDL) instead of inserting SHOW CREATE — the pane carries its -// own "Insert SHOW CREATE" button. External (ext:) leaves have no detail to show. -const schemaDetailClick = (app) => (n) => { +// In the full schema view, clicking an object opens the detail pane (full columns +// / keys / partitions / DDL) instead of inserting SHOW CREATE — the pane carries +// its own "Insert SHOW CREATE" button. External (ext:) leaves have no detail; a +// ⌘/Ctrl-click is reserved for dragging the node, so it doesn't open the pane. +// `targetDoc` is this view's own document (the tab or the overlay's host), threaded +// so a node click always opens the pane in the view it came from — even when +// several full views are open at once (no shared single-slot document). +const schemaDetailClick = (app, targetDoc) => (n, e) => { if (!n.id || n.id.startsWith('ext:')) return; - app.actions.openNodeDetail(n); + if (e.metaKey || e.ctrlKey) return; + app.actions.openNodeDetail(n, targetDoc); }; -/** Fullscreen schema-lineage graph — rich cards + click-a-node detail pane. */ -export function openSchemaFullscreen(app, graph) { - const note = graph && graph.truncated +// The shared chrome for the full schema view: a title bar + an (empty) canvas, +// inside a panel. Reused by the new browser tab and the in-app overlay fallback; +// `.graph-overlay-panel` is also the mount point the detail pane looks for. +function buildGraphPanel(title) { + const bar = h('div', { class: 'graph-overlay-bar' }, h('span', { class: 'graph-overlay-title' }, title)); + const canvas = h('div', { class: 'graph-overlay-canvas' }); + const panel = h('div', { class: 'graph-overlay-panel', onclick: (e) => e.stopPropagation() }, bar, canvas); + return { panel, bar, canvas }; +} + +// Zoom-out / zoom-in / fit buttons wired to an attachPanZoom controller. +function zoomControls(pz) { + return h('div', { class: 'graph-overlay-zoom' }, + h('button', { class: 'res-act', title: 'Zoom out', onclick: pz.zoomOut }, Icon.minus()), + h('button', { class: 'res-act', title: 'Zoom in', onclick: pz.zoomIn }, Icon.plus()), + h('button', { class: 'res-act', title: 'Fit to screen', onclick: pz.fit }, 'Fit')); +} + +// Copy the theme/density data-attributes onto the child tab's so its +// CSS custom properties resolve to the same colours as the main window. +function mirrorTheme(src, dst) { + for (const attr of ['data-theme', 'data-density']) { + const v = src.documentElement.getAttribute(attr); + if (v != null) dst.documentElement.setAttribute(attr, v); + } +} + +// Headline title for a focus: "default" (whole-DB) or "default.events" (table). +function focusLabel(focus) { + const f = focus || {}; + return f.table ? f.db + '.' + f.table : (f.db || ''); +} + +// Day/night switcher for the view's own document — mirrors the main window's +// toggle (sun while dark → click for light; moon while light → click for dark). +function themeToggle(doc) { + const icon = () => (doc.documentElement.getAttribute('data-theme') === 'light' ? Icon.moon() : Icon.sun()); + const btn = h('button', { class: 'res-act', title: 'Toggle theme' }, icon()); + btn.addEventListener('click', () => { + doc.documentElement.setAttribute('data-theme', doc.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); + btn.replaceChildren(icon()); + }); + return btn; +} + +// Truncation banner text (null when the lineage wasn't soft-capped). +function schemaNote(graph) { + return graph && graph.truncated ? 'Lineage truncated — showing ' + (((graph.nodes && graph.nodes.length) || 0)) + ' objects' : null; - return openGraphFullscreen(app, 'Schema', () => buildRichSchemaSvg(graph, app && app.Dagre, schemaDetailClick(app)), schemaLegend(), schemaEmptyMessage(graph), note, { fitWidth: true }); +} + +// ⌘/Ctrl drives a hand cursor (.modkey) and gates node dragging: a ⌘/Ctrl+drag +// on a card moves it (capture phase, pre-empting the pan handler) and straightens +// only the edges incident to it; a plain drag falls through to pan. Pure geometry +// lives in core/graph-layout.js; this only mutates the DOM + records positions. +function attachSchemaInteractions(canvas, svg, built, targetDoc, positions, onChange = () => {}) { + const nodes = built.nodes; + const edges = built.edges; + const byId = new Map(nodes.map((n) => [n.id, n])); + const cardById = new Map(); + svg.querySelectorAll('g.eg-card[data-node-id]').forEach((g) => cardById.set(g.getAttribute('data-node-id'), g)); + const pathByIdx = new Map(); + svg.querySelectorAll('path[data-eidx]').forEach((p) => pathByIdx.set(+p.getAttribute('data-eidx'), p)); + const getVb = () => { const a = svg.getAttribute('viewBox').split(' ').map(Number); return { x: a[0], y: a[1], w: a[2], h: a[3] }; }; + const history = createMoveHistory(); + + // Move a node to an absolute position: translate its card, re-route only its + // incident edges, and update the persisted map. Shared by live drag + undo/redo. + const placeAt = (id, x, y) => { + const node = byId.get(id); + node.x = x; node.y = y; + cardById.get(id).setAttribute('transform', 'translate(' + (x - node.x0) + ' ' + (y - node.y0) + ')'); + for (const i of incidentEdges(edges, id)) { + const ed = edges[i]; + const pts = straightEdgePoints(byId.get(ed.from), byId.get(ed.to)); + pathByIdx.get(i).setAttribute('d', 'M' + pts.map((p) => p.x + ' ' + p.y).join(' L')); + } + if (positions) recordPosition(positions, id, x, y); + }; + + // undo()/redo() are shared by the keyboard shortcuts and the headline buttons; + // each notifies onChange so the buttons can refresh their enabled state. + const doUndo = () => { const op = history.undo(); if (op) placeAt(op.id, op.from.x, op.from.y); onChange(); }; + const doRedo = () => { const op = history.redo(); if (op) placeAt(op.id, op.to.x, op.to.y); onChange(); }; + const onKeyDown = (e) => { + if (!(e.metaKey || e.ctrlKey)) return; + canvas.classList.add('modkey'); + const k = e.key.toLowerCase(); + if (k === 'z') { e.preventDefault(); if (e.shiftKey) doRedo(); else doUndo(); } // ⌘Z undo, ⌘⇧Z redo + else if (k === 'y') { e.preventDefault(); doRedo(); } // ⌘Y redo (Windows-style) + }; + const onKeyUp = (e) => { if (!(e.metaKey || e.ctrlKey)) canvas.classList.remove('modkey'); }; + const onDown = (e) => { + if (!(e.metaKey || e.ctrlKey)) return; // plain drag → let the pan handler have it + const g = e.target.closest('[data-node-id]'); + if (!g) return; + const node = byId.get(g.getAttribute('data-node-id')); + if (!node) return; + e.preventDefault(); e.stopPropagation(); + canvas.classList.add('grabbing'); + const start = { x: node.x, y: node.y }; // for the undo record + // The viewBox and the container box are fixed for the duration of a node drag, + // so read them once here instead of reflowing/parsing on every mousemove. + const vb = getVb(); + const rect = canvas.getBoundingClientRect(); + let last = { x: e.clientX, y: e.clientY }; + const onMove = (ev) => { + const { dx, dy } = dragDeltaToSvg(ev.clientX - last.x, ev.clientY - last.y, vb, rect); + last = { x: ev.clientX, y: ev.clientY }; + placeAt(node.id, node.x + dx, node.y + dy); + }; + const onUp = () => { + targetDoc.removeEventListener('mousemove', onMove); + targetDoc.removeEventListener('mouseup', onUp); + canvas.classList.remove('grabbing'); + // Record one undoable op per drag that actually moved the node. + if (node.x !== start.x || node.y !== start.y) { history.record({ id: node.id, from: start, to: { x: node.x, y: node.y } }); onChange(); } + }; + targetDoc.addEventListener('mousemove', onMove); + targetDoc.addEventListener('mouseup', onUp); + }; + targetDoc.addEventListener('keydown', onKeyDown); + targetDoc.addEventListener('keyup', onKeyUp); + canvas.addEventListener('mousedown', onDown, true); + return { + undo: doUndo, + redo: doRedo, + canUndo: () => history.canUndo(), + canRedo: () => history.canRedo(), + // Teardown: the overlay path attaches keydown/keyup to the persistent main + // document, so closing must remove them (the tab path drops them with its doc). + teardown: () => { + targetDoc.removeEventListener('keydown', onKeyDown); + targetDoc.removeEventListener('keyup', onKeyUp); + canvas.removeEventListener('mousedown', onDown, true); + }, + }; +} + +// A controller over an already-open surface (new tab or overlay). `render(graph)` +// draws the rich-card graph into `targetDoc`'s canvas (replacing the Loading… +// placeholder) and wires pan/zoom + the drag/cursor model; `fail(msg)` shows an +// error in the canvas and toasts the main window. +function makeController(app, targetDoc, mainDoc, canvas, bar, closeBtn) { + let teardown = null; + return { + render(graph) { + withDocument(targetDoc, () => { + canvas.textContent = ''; + bar.querySelector('.graph-overlay-title').textContent = 'Schema: ' + focusLabel(graph.focus); + const built = buildRichSchemaSvg(graph, app.Dagre, schemaDetailClick(app, targetDoc)); + // Right-aligned action cluster: theme switcher + (zoom controls) + (close). + const actions = h('div', { class: 'graph-overlay-actions' }, themeToggle(targetDoc)); + if (!built.nodeCount) { + canvas.appendChild(placeholder(schemaEmptyMessage(graph))); + } else { + canvas.classList.add('schema-canvas'); + canvas.appendChild(built.svg); + const pz = attachPanZoom(canvas, built.svg, built, { fitWidth: true }); + let undoBtn, redoBtn; + const refresh = () => { undoBtn.disabled = !controls.canUndo(); redoBtn.disabled = !controls.canRedo(); }; + const controls = attachSchemaInteractions(canvas, built.svg, built, targetDoc, graph.savedPositions, refresh); + teardown = controls.teardown; + undoBtn = h('button', { class: 'res-act', title: 'Undo move (⌘Z)', onclick: controls.undo }, Icon.undo()); + redoBtn = h('button', { class: 'res-act', title: 'Redo move (⌘⇧Z)', onclick: controls.redo }, Icon.redo()); + refresh(); // start disabled (no history yet) + bar.appendChild(schemaLegend()); // colour key lives in the headline, not over the canvas + const note = schemaNote(graph); + if (note) bar.appendChild(h('span', { class: 'graph-overlay-note' }, note)); + actions.appendChild(h('div', { class: 'graph-overlay-zoom' }, undoBtn, redoBtn)); + actions.appendChild(zoomControls(pz)); + } + if (closeBtn) actions.appendChild(closeBtn); + bar.appendChild(actions); + }); + }, + fail(msg) { + withDocument(targetDoc, () => { canvas.textContent = ''; canvas.appendChild(placeholder(msg)); }); + flashToast(msg, { document: mainDoc }); + }, + destroy() { if (teardown) teardown(); }, + }; +} + +// Drive a same-origin about:blank tab from the opener: copy the page CSS + theme, +// mount the panel, and keep the detail pane targeting the child document. The +// opener keeps the token + ch-client, so click-to-detail still fetches live. +function openInTab(app, childDoc, mainDoc) { + return withDocument(childDoc, () => { + childDoc.head.appendChild(h('style', null, app.stylesText || '')); + mirrorTheme(mainDoc, childDoc); + childDoc.title = 'Schema graph'; + const { panel, bar, canvas } = buildGraphPanel('Schema'); + canvas.appendChild(placeholder('Loading…')); + // No close button — the browser tab's own close serves that. + childDoc.body.className = 'schema-tab'; + childDoc.body.appendChild(panel); + // Esc closes the open detail pane (the browser tab's own close handles the rest). + childDoc.addEventListener('keydown', (e) => { + if (e.key !== 'Escape') return; + const pane = childDoc.querySelector('.schema-detail'); + if (pane) { e.stopPropagation(); pane.remove(); } + }, true); + return makeController(app, childDoc, mainDoc, canvas, bar, null); + }); +} + +// In-app modal overlay — the fallback when a real tab can't be opened (pop-up +// blocked, window.open null, or COOP severing the opener). Esc / ✕ / backdrop close. +function openInOverlay(app, mainDoc) { + return withDocument(mainDoc, () => { + let ctrl; + // Esc closes the open detail pane first; a second Esc closes the whole overlay. + const onKey = (e) => { + if (e.key !== 'Escape') return; + e.stopPropagation(); + const pane = mainDoc.querySelector('.schema-detail'); + if (pane) pane.remove(); else close(); + }; + let backdrop; + // close() also tears down the interaction listeners attached to the main + // document (they would otherwise leak — the overlay's host doc outlives it). + function close() { backdrop.remove(); mainDoc.removeEventListener('keydown', onKey, true); ctrl.destroy(); } + const { panel, bar, canvas } = buildGraphPanel('Schema'); + canvas.appendChild(placeholder('Loading…')); + const closeBtn = h('button', { class: 'graph-overlay-close', title: 'Close (Esc)', onclick: close }, Icon.close()); + backdrop = h('div', { class: 'graph-overlay', onclick: close }, panel); + mainDoc.body.appendChild(backdrop); + mainDoc.addEventListener('keydown', onKey, true); + ctrl = makeController(app, mainDoc, mainDoc, canvas, bar, closeBtn); + return ctrl; + }); +} + +/** + * Open the full schema-lineage view and return a `{ render, fail }` controller. + * Tries a real browser tab first (kept live by the opener); on any failure — + * pop-up blocked, null window, or COOP-severed document — falls back to the + * in-app overlay. The window is opened synchronously so it survives the click + * gesture; the caller fetches lineage, then calls render()/fail(). + */ +export function openSchemaView(app) { + const mainDoc = app.document || document; + try { + const win = app.openWindow('', '_blank'); + if (win && win.document) return openInTab(app, win.document, mainDoc); + } catch (e) { /* pop-up blocked or cross-origin document — fall back to overlay */ } + return openInOverlay(app, mainDoc); } /** diff --git a/src/ui/icons.js b/src/ui/icons.js index 8fc4f87..b16bdbb 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -80,6 +80,9 @@ export const Icon = { expand: () => iconEl('', 12, 12, 1.4), // Zoom-out bar (pairs with plus for zoom-in). minus: () => svg('M2 6h8', 12, 12, { stroke: 1.6 }), + // Curved-arrow undo / redo (mirror images) for the schema node-move history. + undo: () => svg('M4.5 3.5 2 6l2.5 2.5M2 6h5a2.5 2.5 0 0 1 2.5 2.5', 12, 12), + redo: () => svg('M7.5 3.5 10 6l-2.5 2.5M10 6H5a2.5 2.5 0 0 0-2.5 2.5', 12, 12), bookmark: () => iconEl('', 12, 12, 1.3), pencil: () => iconEl('', 12, 12), trash: () => iconEl('', 12, 12, 1.2), diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js index ffae6d8..5faa1e9 100644 --- a/src/ui/schema-detail.js +++ b/src/ui/schema-detail.js @@ -4,7 +4,7 @@ // its DDL — plus an "Insert SHOW CREATE" action. Pure DOM over the app controller; // the data is fetched by app.actions.openNodeDetail (ch.loadTableDetail). -import { h } from './dom.js'; +import { h, withDocument } from './dom.js'; import { Icon } from './icons.js'; import { clamp, formatRows, formatBytes, qualifyIdent } from '../core/format.js'; import { columnRoles } from '../core/schema-cards.js'; @@ -18,13 +18,21 @@ const TOP_MARGIN = 100; * or null when no overlay is open. The ✕ button closes just the pane; Esc closes * the whole overlay (which removes the pane with it). */ -export function openDetailPane(app, node, detail) { - const doc = (app && app.document) || document; +export function openDetailPane(app, node, detail, targetDoc) { + // `targetDoc` is the view's own document (a schema tab, or the overlay's host); + // fall back to the main document. Both host a .graph-overlay-panel. + const doc = targetDoc || (app && app.document) || document; const panel = doc.querySelector('.graph-overlay-panel'); - if (!panel) return null; // overlay already closed + if (!panel) return null; // view already closed const prior = panel.querySelector('.schema-detail'); if (prior) prior.remove(); // re-opening for another node replaces the pane + return withDocument(doc, () => buildDetailPane(app, node, detail, panel)); +} + +// Build + mount the pane (created in the active document via withDocument). +function buildDetailPane(app, node, detail, panel) { + const doc = panel.ownerDocument; const cols = detail.columns || []; const parts = detail.partitions || []; const ident = qualifyIdent(node.db, node.name); diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index dd306f8..088450a 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -80,6 +80,23 @@ describe('createApp basics', () => { const app = createApp(env({ location: { host: 'h', origin: 'https://h', pathname: '/sql', search: '?host=antalya.demo:9000' } })); expect(app.hostHint).toBe('antalya.demo:9000'); }); + it('openWindow + stylesText seams resolve from env, from window.open, and from the page