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/build/template.html b/build/template.html index 1e3b93b..324255d 100644 --- a/build/template.html +++ b/build/template.html @@ -1,5 +1,5 @@ - + 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/state.js b/src/state.js index 018d3b7..0aca88b 100644 --- a/src/state.js +++ b/src/state.js @@ -43,7 +43,7 @@ export function createState(read = { loadJSON, loadStr }) { const num = (key, dflt, lo, hi) => clamp(parseFloat(read.loadStr(key, String(dflt))), lo, hi); return { nextTabId: 2, - theme: read.loadStr(KEYS.theme, 'dark'), + theme: read.loadStr(KEYS.theme, 'light'), density: 'comfortable', sidebarPx: clamp(parseInt(read.loadStr(KEYS.sidebarPx, '248'), 10), 180, 420), editorPct: num(KEYS.editorPct, 45, 15, 85), diff --git a/src/styles.css b/src/styles.css index 3f33b3e..99fb617 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; overflow: hidden; 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..39ad084 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,38 +556,50 @@ 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; - await ensureConfig(); - if (!(await getToken())) { chCtx.onSignedOut(); return; } - let lineage; + // 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); + // Everything after the synchronous open is wrapped: a token-refresh rejection, + // a lineage/cards fetch failure, or a graph-build throw must surface in the view + // (fail) instead of leaving the just-opened tab/overlay stranded on "Loading…". try { + await ensureConfig(); + if (!(await getToken())) { chCtx.onSignedOut(); view.fail('Sign in to view the schema graph.'); return; } // 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); + const lineage = await ch.loadLineageTransitive(chCtx, focus); + const g = buildSchemaGraph(lineage.rows, focus); + const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes + // Card metadata for every database the expansion reached (external nodes too). + const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; + 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 }); + // 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, + }); } 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 }); - return; + view.fail('Could not load the schema graph'); } - const g = buildSchemaGraph(lineage.rows, focus); - const ex = expandLineage(g, focus.db); // closure around focus.db, tags external nodes - // Card metadata for every database the expansion reached (external nodes too). - const dbs = [...new Set(ex.nodes.map((n) => n.db).filter(Boolean))]; - 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, { - nodes: cardGraph.nodes, edges: cardGraph.edges, focus, - tableCount: (lineage.rows.tables || []).length, - truncated: lineage.truncated || ex.truncated, - }); } // 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 @@ -777,6 +794,9 @@ export function createApp(env = {}) { doc.documentElement.setAttribute('data-theme', app.state.theme); if (app.dom.themeBtn) app.dom.themeBtn.replaceChildren(app.state.theme === 'dark' ? Icon.sun() : Icon.moon()); } + // Exposed so the schema-view overlay can drive the same toggle (keeps state + + // saved pref + header icon in sync rather than flipping data-theme behind them). + app.toggleTheme = toggleTheme; // --- actions registry -------------------------------------------------- app.actions = { 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..66f2507 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 @@ -45,6 +47,11 @@ function attachPanZoom(container, svg, dims, opts = {}) { // fitWidth: frame the graph to fill the container's WIDTH and let the height // overflow (pan/scroll down) — used by the schema full view, which can be tall. const fitWidth = !!opts.fitWidth; + // refitOnResize: re-fit when the window resizes. Set for the standalone schema + // tab + the fullscreen overlays (whose container tracks the viewport); left off + // for the small inline result pane, which re-renders often and shouldn't reset + // a user's pan/zoom on every layout change. + const refitOnResize = !!opts.refitOnResize; svg.setAttribute('width', '100%'); svg.setAttribute('height', '100%'); svg.setAttribute('preserveAspectRatio', 'xMidYMid meet'); @@ -67,8 +74,8 @@ function attachPanZoom(container, svg, dims, opts = {}) { // Pan by pixel deltas (drag grabs the content; wheel scrolls the viewport — the // caller passes the appropriate sign). const panBy = (dxPx, dyPx) => { - const r = container.getBoundingClientRect(); - vb = panBox(vb, dxPx * (vb.w / r.width), dyPx * (vb.h / r.height)); + const { dx, dy } = dragDeltaToSvg(dxPx, dyPx, vb, container.getBoundingClientRect()); + vb = panBox(vb, dx, dy); apply(); }; const centre = () => { const r = container.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }; @@ -93,6 +100,16 @@ function attachPanZoom(container, svg, dims, opts = {}) { container.addEventListener('mouseup', end); container.addEventListener('mouseleave', end); container.addEventListener('dblclick', fit); + // Refit on window resize so the viewBox aspect keeps matching the container — + // otherwise preserveAspectRatio letterboxes and drag/pan stop tracking the + // pointer (notably when the standalone schema tab is resized). The listener + // removes itself once the container leaves the DOM (overlay/tab closed); a + // detached document (defaultView null) never gets one in the first place. + const win = container.ownerDocument.defaultView; + if (win && refitOnResize) { + const onResize = () => { if (container.isConnected) fit(); else win.removeEventListener('resize', onResize); }; + win.addEventListener('resize', onResize); + } apply(); return { fit, zoomIn: () => { const c = centre(); zoomAt(ZOOM_STEP, c.x, c.y); }, zoomOut: () => { const c = centre(); zoomAt(1 / ZOOM_STEP, c.x, c.y); } }; @@ -117,15 +134,23 @@ 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)); + // A straightened (2-point) edge has no real mid-vertex, so points[len/2] + // would land on the target endpoint — use the segment midpoint instead. + // data-lbl-eidx lets the move handler reposition the label with its edge. + const pts = e.points; + const mid = pts.length === 2 + ? { x: (pts[0].x + pts[1].x) / 2, y: (pts[0].y + pts[1].y) / 2 } + : pts[Math.floor(pts.length / 2)]; + svg.appendChild(s('text', { class: 'eg-edge-label', x: mid.x, y: mid.y - 3, 'text-anchor': 'middle', 'data-lbl-eidx': i }, lbl)); } - } + }); return svg; } @@ -141,7 +166,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 +195,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 +217,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 +250,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 +289,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 +299,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, { refitOnResize: true }); + 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 +344,311 @@ 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 - ? '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 }); +// 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)); + // tabindex makes the canvas focusable so the view receives ⌘/Ctrl + key events + // (cursor mode, undo/redo) without first clicking — vital for the new tab. + const canvas = h('div', { class: 'graph-overlay-canvas', tabindex: '-1' }); + 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). +// `onToggle` is the app's real toggleTheme: passed only when the view IS the main +// document (overlay fallback) so app.state/the saved pref/the header button stay +// in sync; in a separate tab it's omitted and the flip is local + ephemeral. The +// icon is rebuilt inside withDocument(doc) so it's created in the view's own realm. +function themeToggle(doc, onToggle) { + 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', () => { + if (onToggle) onToggle(); // overlay: app's toggle flips data-theme + state + pref + header icon + else doc.documentElement.setAttribute('data-theme', doc.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light'); + withDocument(doc, () => btn.replaceChildren(icon())); + }); + return btn; +} + +// Truncation banner text (null when the lineage wasn't soft-capped). Only called +// from render() with a populated graph (the nodeCount > 0 branch), so graph.nodes +// is always present here. +function schemaNote(graph) { + return graph.truncated ? 'Lineage truncated — showing ' + graph.nodes.length + ' objects' : null; +} + +// ⌘/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 labelByIdx = new Map(); + svg.querySelectorAll('text[data-lbl-eidx]').forEach((t) => labelByIdx.set(+t.getAttribute('data-lbl-eidx'), t)); + // Each node's incident-edge indices are fixed for the view's lifetime, so map + // them once here rather than rescanning every edge on every drag-move frame. + const incidentById = new Map(); + nodes.forEach((n) => incidentById.set(n.id, incidentEdges(edges, n.id))); + 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 their labels), grow the layout bounds, 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) + ')'); + // Grow the layout bounds (same object attachPanZoom fits) so Fit/double-click + // can still frame a node dragged past dagre's original extent. + if (x + node.w > built.width) built.width = x + node.w; + if (y + node.h > built.height) built.height = y + node.h; + for (const i of incidentById.get(id)) { // every node id is mapped above + 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')); + // Keep the relationship label on the re-routed edge's midpoint, not stranded. + const lbl = labelByIdx.get(i); + if (lbl) { lbl.setAttribute('x', (pts[0].x + pts[1].x) / 2); lbl.setAttribute('y', (pts[0].y + pts[1].y) / 2 - 3); } + } + 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'); }; + // If the window loses focus mid-press the modifier keyup may never arrive, which + // would leave the grab/move cursor (.modkey) latched on — clear it on blur. + const onBlur = () => canvas.classList.remove('modkey'); + const win = targetDoc.defaultView; + const onDown = (e) => { + const g = e.target.closest('[data-node-id]'); + if (!(e.metaKey || e.ctrlKey)) { + // Plain press on a card: swallow it so the canvas doesn't pan (a clean click + // still opens the detail pane). Plain press on empty canvas falls through to pan. + if (g) e.stopPropagation(); + return; + } + if (!g) return; // ⌘/Ctrl on empty canvas → let the pan handler grab it + 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 container box is stable for the drag, so read it once; the viewBox is + // re-read each move (a ⌘/wheel zoom mid-drag changes it) so deltas stay scaled. + const rect = canvas.getBoundingClientRect(); + let last = { x: e.clientX, y: e.clientY }; + const onMove = (ev) => { + if (ev.buttons === 0) return onUp(); // button released off-window → end the drag + const { dx, dy } = dragDeltaToSvg(ev.clientX - last.x, ev.clientY - last.y, getVb(), 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); + if (win) win.addEventListener('blur', onBlur); + return { + undo: doUndo, + redo: doRedo, + canUndo: () => history.canUndo(), + canRedo: () => history.canRedo(), + // Teardown: the overlay path attaches keydown/keyup/blur to the persistent main + // document/window, 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); + if (win) win.removeEventListener('blur', onBlur); + }, + }; +} + +// 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; + let destroyed = false; + return { + render(graph) { + if (destroyed) return; // the view was closed before the lineage finished loading + withDocument(targetDoc, () => { + canvas.textContent = ''; + bar.querySelector('.graph-overlay-title').textContent = 'Schema: ' + focusLabel(graph.focus); + // Name the browser tab "Schema:" (only a real tab — never clobber the + // main app's title when this is the in-app overlay fallback). + if (targetDoc !== mainDoc) targetDoc.title = 'Schema:' + focusLabel(graph.focus); + const built = buildRichSchemaSvg(graph, app.Dagre, schemaDetailClick(app, targetDoc)); + // Right-aligned action cluster: theme switcher + (zoom controls) + (close). + // In the overlay (targetDoc === mainDoc) the toggle routes through app's own + // toggleTheme so state/pref/header stay in sync; a real tab flips locally. + const actions = h('div', { class: 'graph-overlay-actions' }, + themeToggle(targetDoc, targetDoc === mainDoc ? app.toggleTheme : null)); + 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, refitOnResize: 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); + canvas.focus({ preventScroll: true }); // focus for ⌘/Ctrl key events — but never scroll the header off + }); + }, + fail(msg) { + if (destroyed) return; + withDocument(targetDoc, () => { canvas.textContent = ''; canvas.appendChild(placeholder(msg)); }); + flashToast(msg, { document: mainDoc }); + }, + destroy() { destroyed = true; 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, win, childDoc, mainDoc) { + return withDocument(childDoc, () => { + childDoc.head.appendChild(h('style', null, app.stylesText || '')); + mirrorTheme(mainDoc, childDoc); + childDoc.title = 'Schema'; // render() refines this to "Schema:" + 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); + win.focus(); // bring the new tab to the front + give it window focus for key events + // 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, 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..8235321 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