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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
2 changes: 1 addition & 1 deletion build/template.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
Expand Down
87 changes: 87 additions & 0 deletions src/core/graph-layout.js
Original file line number Diff line number Diff line change
@@ -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; },
};
}
11 changes: 10 additions & 1 deletion src/core/schema-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || [];
Expand Down
2 changes: 1 addition & 1 deletion src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
34 changes: 32 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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); }

Expand Down
64 changes: 42 additions & 22 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
25 changes: 22 additions & 3 deletions src/ui/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down
Loading
Loading