Files
datastore/programs/server/play.html
T
Alexey MilovidovandClaude Opus 4.8 78ba540f36 Web UI: ignore the connection-menu latch for non-pointer clicks
Follow-up to the AI review on #109451. Resetting `connection_pointerdown_inside`
at the end of the `click` handler consumes the latch only when a `click` fires,
but a mouse interaction can end without one: a right-click emits `contextmenu`,
a middle-click emits `auxclick`, and a primary drag released outside the window
emits neither. In those cases the latch stays `true`, and the next keyboard- or
script-generated outside `click` (which has no owning `mousedown`) reads that
stale `true` and wrongly leaves the connection drop-down open.

Determine "started inside" from a real pointer `click` only: a keyboard or
synthetic activation reports `detail === 0`, so gate the latch on `e.detail !== 0`.
This uniformly ignores every non-`click` mouse termination without enumerating
`contextmenu`/`auxclick`/out-of-window handlers, and keeps the drag-selection fix
(a real `click` with `detail >= 1` whose press started inside still suppresses
the dismissal).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-05 21:33:13 +00:00

8566 lines
373 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<title>ClickHouse Query</title>
<!-- Code Style:
Do not use any JavaScript or CSS frameworks or preprocessors.
This HTML page should not require any build systems (node.js, npm, gulp, etc.)
This HTML page should not be minified, instead it should be reasonably minimalistic by itself.
This HTML page should not load any external resources on load.
(CSS and JavaScript must be embedded directly to the page. No external fonts or images should be loaded).
This UI should look as lightweight, clean and fast as possible.
All UI elements must be aligned in pixel-perfect way.
There should not be any animations except the progress bar.
No unexpected changes in positions of elements while the page is loading.
Navigation by keyboard should work.
64-bit numbers must display correctly.
-->
<!-- Development Roadmap:
1. Support readonly servers.
Check if readonly = 1 (with SELECT FROM system.settings) to avoid sending settings. It can be done once on address/credentials change.
It can be done in background, e.g. wait 100 ms after address/credentials change and do the check.
Also, it can provide visual indication that credentials are correct.
Nice to have:
- show/hide the editor
- check for readonly
- query caching
- history navigation
- short links by saving in a special table
- sharing by creating a short-living user
- notebook mode
-->
<style>
:root {
--background-color-rotate: 215;
--background-color: oklch(96% 0.0296 var(--background-color-rotate));
/* Top color of the active tab's gradient: same hue/lightness as the page but
more saturated (higher chroma), fading down to the page color. */
--active-tab-top-color: oklch(96% 0.09 var(--background-color-rotate));
--element-background-color: #FFF;
--element-color-ok: #080;
--element-background-color-fail: #FEE;
--bar-color: #FF8; /* Light bar in background of table cells. */
--bar-color-negative: #FEE;
/* Lightness and chroma for the heatmap/categorical cell coloring; only the hue varies.
Light theme: a high lightness so the dark cell text stays readable on top. */
--viz-cell-lightness: 88%;
--viz-cell-chroma: 0.09;
--border-color: #CCC;
--shadow-color: rgba(0, 0, 0, 0.1);
--button-color: #FF0;
--text-color: #000;
--text-color-active: #000;
--button-active-color: #FE0;
--button-active-text-color: #000;
--misc-text-color: #888;
--error-color: #FEE; /* Light-pink on light-cyan is so neat, I even want to trigger errors to see this cool combination of colors. */
--table-header-color: #EEE;
--table-hover-filter: brightness(0.9);
--link-color: #06D;
--logo-color: #FFF;
--border-color-selected: #F80;
--menu-toggle-color: #000;
--menu-icon-color: #24292f; /* Match the GitHub logo's color. */
--progress-color: #0C4;
--table-size-bar-color: #FFF;
--selection-color: #FFF;
--selection-background-color: #456;
--current-query-background-color: #FFF4E0;
/* Syntax highlighting palette, modelled after `clickhouse-client`. The
textarea text is made transparent when highlighting is ready, so the
user sees the backdrop text in these colors directly.
Light-theme values are darker variants of the terminal palette so they
read well on a light background. */
--syntax-default: var(--text-color-active);
--syntax-keyword: var(--text-color-active); /* bold default fg */
--syntax-identifier: #00838F; /* CYAN */
--syntax-function: #875F00; /* BROWN */
--syntax-number: #008700; /* bright green */
--syntax-string: #006400; /* GREEN */
--syntax-qident: #008B8B; /* alias / teal */
--syntax-comment: #757575;
--syntax-operator: var(--text-color-active);
--syntax-error: #B71C1C;
/* Rainbow parentheses: one color per nesting depth, cycling.
Dark variants so they read on a light background. */
--syntax-bracket-0: #A31515;
--syntax-bracket-1: #8A4B00;
--syntax-bracket-2: #6B5D00;
--syntax-bracket-3: #1B5E3A;
--syntax-bracket-4: #0D3C8C;
--syntax-bracket-5: #4A2A85;
--syntax-bracket-6: #8A0F42;
}
[data-theme="dark"] {
--background-color-rotate: 250;
--background-color: oklch(15% 0.0296 var(--background-color-rotate));
/* Top color of the active tab's gradient: same hue as the page but brighter
(higher lightness), fading down to the page color. */
--active-tab-top-color: oklch(28% 0.0296 var(--background-color-rotate));
--element-background-color: #000;
--element-color-ok: #8F8;
--element-background-color-fail: #300;
--bar-color: #320;
--bar-color-negative: #311;
/* Dark theme: a low lightness so the light cell text stays readable on top. */
--viz-cell-lightness: 38%;
--viz-cell-chroma: 0.08;
--border-color: #444;
--shadow-color: rgba(0, 0, 0, 0.1);
--text-color: #CCC;
--text-color-active: #FFF;
--button-color: rgb(250 255 105);
--button-text-color: #000;
--button-active-color: #FE0;
--button-active-text-color: #000;
--misc-text-color: #888;
--error-color: #400;
--table-header-color: #102020;
--table-hover-filter: contrast(0.9) brightness(2);
--link-color: #4BDAF7;
--logo-color: #444;
--border-color-selected: #FF0;
--menu-toggle-color: #FF0;
--menu-icon-color: #FFF; /* Match the GitHub logo's color. */
--progress-color: #FF0;
--table-size-bar-color: #104050;
--selection-color: #000;
--selection-background-color: #FF8;
--current-query-background-color: #442;
/* Dark-theme palette uses the actual xterm 16-color values that
`clickhouse-client` renders to in a dark terminal. */
--syntax-keyword: var(--text-color-active); /* bold default fg */
--syntax-identifier: #00CDCD; /* CYAN */
--syntax-function: #CDCD00; /* BROWN */
--syntax-number: #00D700; /* rgb666(0,4,0) */
--syntax-string: #00CD00; /* GREEN */
--syntax-qident: #00D7D7; /* rgb666(0,4,4) */
--syntax-comment: #9E9E9E;
--syntax-operator: var(--text-color-active);
--syntax-error: #FF6E40;
/* Rainbow parentheses: one color per nesting depth, cycling.
Light variants so they read on a dark background. */
--syntax-bracket-0: #FF9999;
--syntax-bracket-1: #FFD3A6;
--syntax-bracket-2: #F7FCC0;
--syntax-bracket-3: #9BFAB8;
--syntax-bracket-4: #BFF3FF;
--syntax-bracket-5: #D7C4FC;
--syntax-bracket-6: #FFB0DE;
}
::selection {
color: var(--selection-color);
background-color: var(--selection-background-color);
}
*
{
box-sizing: border-box;
/* For iPad */
margin: 0;
border-radius: 0;
tab-size: 4;
}
html, body
{
height: 100%;
margin: 0;
/* This enables position: sticky on controls */
overflow: auto;
scrollbar-color: var(--border-color) var(--element-background-color);
}
html
{
/* The fonts that have full support for hinting. */
font-family: Roboto Mono, Liberation Sans, DejaVu Sans, sans-serif, Noto Color Emoji, Apple Color Emoji, Segoe UI Emoji;
background: var(--background-color);
color: var(--text-color);
}
body
{
position: relative;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas: "terminal terminal" "menu main";
}
#menu
{
padding: 0.25rem;
grid-area: menu;
position: sticky;
top: 0;
z-index: 1;
height: calc(100vh - var(--terminal-height, 0px));
align-self: start;
background: var(--table-header-color);
border-right: 1px solid var(--border-color);
display: grid;
grid-template-rows: auto 1fr auto;
grid-template-areas: "databases-icon" "contents" "terminal-icon" "docs-icon" "github-icon";
cursor: e-resize;
min-height: 0;
overflow-x: visible;
overflow-y: clip;
}
/* When the databases list is expanded, lay out the bottom icons (Web
Terminal, Documentation, GitHub) side by side instead of stacked, so the list of
databases/tables gets more vertical space. */
#menu.databases-opened
{
grid-template-columns: auto auto 1fr;
grid-template-areas: "databases-icon databases-hide databases-hide" "contents contents contents" "terminal-icon docs-icon github-icon";
column-gap: 0.25rem;
}
#menu.databases-opened #github-icon
{
justify-self: start;
}
#main
{
overflow: auto;
grid-area: main;
padding: 0.5rem;
position: relative;
z-index: 0; /* stacking context so children don't overlap #menu */
}
/* Tab bar: the first child of #main, scrolling vertically together with the page
content. Sticky on the horizontal axis only (left: 0, no top) like #controls and
#logo-container, so it stays at the left edge while a wide result is scrolled
horizontally but still scrolls away vertically.
The full-width gray/black strip and the top-of-main border are painted by
::before, which extends 0.5rem into #main's side padding. A negative left/right
margin is NOT used for that bleed: it is clamped away under position: sticky in
some browsers, leaving #main's padding showing to the left of the tabs. */
#tab-bar
{
display: flex;
align-items: flex-end;
gap: 0.25rem;
position: sticky;
left: 0;
/* Raise the whole bar above #controls (a later sibling that is also `position:
sticky`, hence its own stacking context). Without this the connection drop-down
— nested in this bar's stacking context — would render behind the query editor,
which paints later. The bar and the editor never overlap otherwise, so this only
affects the open drop-down. */
z-index: 2;
margin: -0.5rem 0 0.5rem 0; /* negative top bleeds into #main's top padding */
/* No left padding: the bar's content edge already sits at #main's content
edge, so the first tab lines up with the query editor and result tables.
Top padding equals the inter-tab gap (0.25rem), so the space above the tabs
matches the space between them. It resolves to a whole pixel at the default
root size, avoiding the sub-pixel border rasterization a fractional value
(e.g. 0.15rem = 2.4px) would cause between the native button and the divs. */
padding: 0.25rem 0.5rem 0 0;
}
#tab-bar::before
{
content: "";
position: absolute;
/* Cover the tab bar and bleed 0.5rem into #main's side padding for a full-width
strip; +1px at the bottom so the border sits exactly at each tab's bottom
(the active tab covers its own 1px segment via its box-shadow). */
inset: 0 -0.5rem -1px -0.5rem;
z-index: -1;
background: var(--table-header-color); /* light gray strip */
border-bottom: 1px solid var(--border-color);
}
/* The `display: flex` above (id specificity) overrides the user-agent
`[hidden] { display: none }` rule, so restore hiding explicitly with a
selector that wins on specificity. */
#tab-bar[hidden]
{
display: none;
}
[data-theme="dark"] #tab-bar::before
{
background: #000; /* pure black strip */
}
#tabs
{
display: flex;
align-items: flex-end;
gap: 0.25rem;
/* Take only the width of the tabs (do not grow), so the [+] button sits
directly after the last tab. No overflow clipping here: it would force
overflow-y to clip and cut off the 1px the active tab uses to merge with
the main area. When there are many tabs they compress (see .tab). */
flex: 0 1 auto;
min-width: 0;
}
.tab
{
display: flex;
align-items: center;
gap: 0.35rem;
/* Stay at natural width when there is room; compress (title ellipsizes) when
there are many tabs, instead of overflowing. */
flex: 0 1 auto;
min-width: 0;
max-width: 12rem; /* keep tabs compact; longer titles ellipsize (see .tab-title) */
padding: 0.25rem 0.5rem;
cursor: pointer;
user-select: none;
white-space: nowrap;
color: var(--text-color);
/* No bottom border: the single top-of-main line is the strip's border-bottom,
and every tab sits directly on it. A per-tab bottom border would render a
second line 1px below it. */
border: 1px solid var(--border-color);
border-bottom: none;
/* Inactive tabs are tinted by the hash of their own query text, mirroring
the page background formula, so different queries get distinct colors. */
background: oklch(96% 0.0296 var(--tab-hue, var(--background-color-rotate)));
}
[data-theme="dark"] .tab
{
background: oklch(15% 0.0296 var(--tab-hue, var(--background-color-rotate)));
}
/* The active tab uses the page background and paints over its segment of the strip's
border line with a page-background box-shadow, so its title reads as part of the
main area below. The shadow covers 2px (not 1px): on the sticky compositing layer
a 1px shadow and the 1px border can round to different device rows, leaving a
faint sliver of the border visible; 2px guarantees full coverage. The extra pixel
is the page background (the merge), not a line, and it adds no layout offset. */
.tab.active
{
/* Gradient: more saturated (light) / brighter (dark) at the top, fading to the
page color at the bottom so it still merges with the main area below. */
background: linear-gradient(var(--active-tab-top-color), var(--background-color));
color: var(--text-color-active);
font-weight: bold;
position: relative; /* for the ::after that opens the bottom border */
}
/* Erase the top-of-main border only in the active tab's interior (between its side
borders), so the bottom looks open and merges with the main area while the side
borders still connect to the line at the bottom corners. left/right: 0 are inset
by the 1px side borders (relative to the padding box); the 2px height robustly
covers the 1px line even with sticky-layer rounding. */
.tab.active::after
{
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 2px;
background: var(--background-color);
}
.tab-title
{
overflow: hidden;
text-overflow: ellipsis;
min-width: 0; /* allow the title to ellipsize when the tab is compressed */
}
/* Over the active tab's title the cursor indicates inline text editing. */
.tab.active .tab-title
{
cursor: text;
}
/* While editing, let the title grow with its content instead of being clipped
to an ellipsis, and remove the focus outline. */
.tab-title.editing
{
overflow: visible;
text-overflow: clip;
min-width: 1ch;
outline: none;
color: var(--text-color-active);
}
/* The tab being edited drops its width cap and flexes to fit the full title. */
.tab.editing
{
max-width: none;
flex-shrink: 0;
}
.tab-close
{
line-height: 1;
font-size: 1em; /* same size as the [+] glyph, so the two align vertically */
padding: 0 0.15rem;
color: var(--misc-text-color);
flex-shrink: 0; /* never shrink the close icon */
}
.tab-close:hover
{
color: var(--text-color-active);
background: var(--border-color);
}
/* Styled like a tab (same border, height and bottom-line alignment) and placed
directly after the last tab. */
#tab-add
{
flex-shrink: 0;
align-self: flex-end;
/* Center the [+] glyph so it lines up vertically with each tab's close [x]. */
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.6rem;
cursor: pointer;
font-weight: bold;
font-size: 100%;
background: var(--element-background-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-bottom: none; /* sits on the strip line like the tabs (no second line) */
}
#tab-add:hover, #tab-add:focus
{
background: var(--button-color);
color: var(--button-text-color);
}
/* Holds the 🔑 button and its drop-down; pushed to the far right of the tab bar
so the connection settings sit in the top-right corner of the main area.
`position: relative` is the containing block for the absolutely positioned
drop-down below. */
#connection-menu
{
margin-left: auto;
/* Cancel the tab bar's 0.5rem right padding so the key's right edge lines up with
the query editor and result tables (the right edge of #main's content). */
margin-right: -0.5rem;
align-self: flex-end;
position: relative;
flex-shrink: 0;
}
/* A borderless, background-less button: just the 🔑 glyph aligned to the right edge. */
#connection-key
{
display: flex;
align-items: center;
justify-content: flex-end;
/* No right padding: the glyph sits flush at the right edge of the page. */
padding: 0.25rem 0 0.25rem 0.6rem;
cursor: pointer;
font-size: 100%;
line-height: 1;
background: none;
border: none;
/* On active, brighten the emoji itself rather than painting a button background. */
filter: brightness(0.85);
transition: filter 0.1s;
}
#connection-key[hidden]
{
display: none;
}
#connection-key:hover, #connection-key:focus
{
filter: brightness(1.15);
}
/* The drop-down panel with the connection parameters. Anchored to the right edge
of the 🔑 button and floating above the content below it. */
#connection-dropdown
{
position: absolute;
top: 100%;
right: 0;
z-index: 3;
padding: 0.75rem;
width: min(90vw, 34rem);
/* The page background, so the inputs look exactly as they do in the inline top bar
(a field on the page color, delineated by its own border) rather than blending
into a same-colored panel. The border and shadow below set the panel apart from
the content it floats over. */
background: var(--background-color);
border: 1px solid var(--border-color);
box-shadow: 0 0.25rem 1rem var(--shadow-color);
}
#connection-dropdown[hidden]
{
display: none;
}
/* Inside the drop-down the same relocated `#inputs` element is laid out in two stories:
the url (host) fills the first row, user and password share the second. `flex-wrap`
plus a 100% basis on the url forces the wrap; the negative top margin on the second
row overlaps the url's bottom border so there is a single seam and no vertical gap
(mirroring the horizontal -1px overlap the inputs already use side by side).
`position: relative` makes `#inputs` the containing block for the status dot. */
#connection-dropdown #inputs
{
display: flex;
flex-wrap: wrap;
width: 100%;
position: relative;
}
#connection-dropdown #url
{
flex: 1 1 100%;
width: auto;
}
#connection-dropdown #user, #connection-dropdown #password
{
flex: 1 1 0;
width: auto;
margin-top: -1px; /* overlap the url's bottom border: one seam, no gap */
}
#connection-dropdown #user
{
margin-left: 0;
}
/* The connection-status dot sits at the right end of the url (first) row. The inline
layout relies on its static position after the url input; in the wrapped two-story
layout that position moves to the next row, so pin it explicitly instead. */
#connection-dropdown #url_status
{
top: 0;
right: 0;
transform: none;
}
.menu-icon
{
width: 2.25em;
cursor: pointer;
user-select: none;
fill: var(--menu-toggle-color);
/* Stroke-based icons (Web Terminal, Documentation) draw with `currentColor`;
pin it to the GitHub logo's color so all three icons match. */
color: var(--menu-icon-color);
}
.menu-icon:hover
{
transform: translate(1px, 1px);
}
#databases-icon
{
grid-area: databases-icon;
text-decoration: none;
display: block;
}
/* Drop the inline-SVG baseline gap so the icon's box hugs the glyph and the
"hide panel" button can be centered against it. */
#databases-icon svg
{
display: block;
}
/* Small "hide panel" button shown at the top-right corner of the menu, vertically
aligned with the database icon. Only visible while the database panel is open. */
#databases-hide
{
grid-area: databases-hide;
display: none;
justify-self: end;
/* Center against the database icon: same row, aligned by vertical midpoint.
The icon's bars span y=0..8 of a 9-unit viewBox, so their visual midpoint sits
(0.5/9) * 2.25em = 0.125em above the row center; nudge the arrow up to match.
font-size: inherit makes the button's em equal the icon's (buttons default to a
smaller font), so the offset is exact and scales with zoom. */
align-self: center;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0 0.25rem;
cursor: pointer;
font-size: inherit;
transform: translateY(-0.125em);
/* Faint color, with no change on hover. */
color: var(--menu-toggle-color);
opacity: 0.25;
}
#databases-hide svg
{
width: 1.25em;
display: block;
}
/* Shown only while the database panel is open. */
#menu.databases-opened #databases-hide
{
display: flex;
}
#databases
{
grid-area: contents;
max-width: 25em;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
/* The menu column is auto-sized (max-content), so the appearance of the vertical
scrollbar (e.g. when expanding tables) would otherwise widen the whole panel by
the scrollbar width. Reserve the gutter permanently to avoid that layout shift. */
scrollbar-gutter: stable;
}
.tables:not(:empty)
{
margin-bottom: 1em;
}
#terminal-icon
{
grid-area: terminal-icon;
margin-bottom: 0.25rem;
/* Rendered as an <a> so middle-click and Ctrl/Shift+click open the terminal in a
new browser tab natively. color: inherit keeps the currentColor strokes from
picking up the default link color, and display: block matches #databases-icon. */
text-decoration: none;
display: block;
color: inherit;
}
#terminal-icon.active svg
{
fill: var(--button-color);
}
#terminal-panel
{
grid-area: terminal;
display: none;
position: relative;
background: #000;
border-bottom: 2px solid var(--border-color);
}
#terminal-panel.active
{
display: block;
}
#terminal-iframe
{
width: 100%;
height: 100%;
border: none;
display: block;
}
#terminal-resizer
{
position: absolute;
bottom: -0.3rem;
left: 0;
width: 100%;
height: 0.5rem;
cursor: row-resize;
user-select: none;
z-index: 10;
}
#terminal-resizer:hover, .terminal-resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color);
}
#docs-icon
{
grid-area: docs-icon;
text-decoration: none;
display: block;
margin-bottom: 0.25rem;
}
/* This is an `<a>` whose SVG draws with `currentColor`. Pin the color across all link
states (including `:visited`, which the generic `a:visited` rule would otherwise tint
with `--link-color`) so the icon always matches the other menu icons. */
#docs-icon:link, #docs-icon:visited, #docs-icon:hover, #docs-icon:active
{
color: var(--menu-icon-color);
}
#github-icon
{
grid-area: github-icon;
}
#github-icon-light, #github-icon-dark
{
display: none;
}
[data-theme="light"] #github-icon-light, [data-theme="dark"] #github-icon-dark
{
display: block;
}
.database
{
/* Anchor the loading hourglass, which is absolutely positioned (see below). */
position: relative;
}
.database button
{
cursor: pointer;
background: transparent;
font-size: 100%;
/* Span the full panel width so the whole row is a click target. */
display: block;
width: 100%;
text-align: left;
border: none;
padding: 0;
user-select: none;
color: var(--text-color);
&:hover {
text-decoration: 1px underline dotted var(--text-color);
}
}
.database button:hover
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.database button:focus
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.loading-hourglass
{
/* Pinned to the top-right of the database row so the full-width name
button does not push it onto a new line while tables load. */
position: absolute;
top: 0;
right: 0.25rem;
color: var(--misc-text-color);
animation: hourglass-animation 2s linear infinite;
/* The hourglass overlays the right edge of the full-width database button.
Let clicks pass through to the button underneath, so the row stays fully
clickable while tables load (otherwise a click here is treated as a menu
background click and would close the whole panel). */
pointer-events: none;
}
/* `.database button.database-refresh` (not just `.database-refresh`) so it
outranks the `.database button` rule that forces `display: block; width: 100%`,
which would otherwise stretch the button full width and push the icon to the left. */
.database button.database-refresh
{
/* Pinned to the top-right of the database row, in the same spot as the
loading hourglass (only one of them is shown at a time). */
position: absolute;
top: 0;
right: 0.25rem;
display: inline-block;
width: auto;
color: var(--misc-text-color);
line-height: 1;
&:hover {
text-decoration: none;
color: var(--text-color);
}
}
.no-tables
{
color: var(--misc-text-color);
}
.current
{
font-weight: bold;
}
/* Number of tables shown next to an opened database name. Kept non-bold even when the
database is the current one (which renders the name in bold). */
.database-table-count
{
font-weight: normal;
color: var(--misc-text-color);
}
.table
{
background-repeat: no-repeat;
}
.table button
{
/* Span the full panel width so the whole row is a click target. */
display: block;
width: 100%;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
background: transparent;
border: none;
padding: 0;
font-size: 100%;
user-select: none;
color: var(--text-color);
}
.table button:hover, .table button:focus
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.table-icon
{
width: 1em;
height: 1em;
vertical-align: -0.1em;
margin-right: 0.25em;
flex-shrink: 0;
opacity: 0.5;
}
.table-addendum
{
position: fixed;
z-index: 999;
white-space: pre;
transform: translateY(-50%);
color: var(--element-background-color);
background: color-mix(in srgb, var(--text-color) 50%, var(--background-color));
backdrop-filter: blur(4px);
border-radius: 0.5rem;
padding: 0.25rem;
margin-left: 0.5rem;
opacity: 0;
pointer-events: none;
transition: opacity 0s 0.1s;
}
.table-addendum::before {
content: " ";
position: absolute;
top: 50%;
transform: translate(calc(-100% - 0.2rem), -50%);
line-height: 0;
border-width: 0.5rem;
border-style: solid;
border-color: transparent color-mix(in srgb, var(--text-color) 50%, var(--background-color)) transparent transparent;
}
.table-name:hover ~ .table-addendum,
.table .table-addendum:hover
{
opacity: 1;
pointer-events: auto;
transition: opacity 0s 0.1s;
}
/* Show the balloon when the row (or one of its links) is focused via
keyboard navigation, mirroring the hover behaviour above. */
.table:focus-within .table-addendum
{
opacity: 1;
pointer-events: auto;
transition: opacity 0s 0.1s;
}
/* While suppress-balloons is set on the body (set on scroll, cleared on the
next mousemove), no balloon may appear from a :hover state. This prevents
a different table button from scrolling under a stationary cursor and
revealing its balloon without `mouseenter` having fired to position it. */
body.suppress-balloons .table-name:hover ~ .table-addendum,
body.suppress-balloons .table .table-addendum:hover
{
opacity: 0;
pointer-events: none;
}
/* The keyboard-focused row's balloon is repositioned explicitly on scroll,
so it must stay visible even while `suppress-balloons` hides hover-opened
balloons (e.g. when the stationary cursor also happens to be over the
focused row). Placed after the suppress rule so it wins the specificity
tie. */
body.suppress-balloons .table:focus-within .table-addendum
{
opacity: 1;
pointer-events: auto;
}
.table-addendum-elem
{
display: block;
cursor: pointer;
&:hover {
text-decoration: 1px underline dotted var(--element-background-color);
}
&:focus {
outline: none;
text-decoration: 1px underline solid var(--element-background-color);
}
}
#controls
{
/* When a page will be scrolled horizontally due to large table size, keep controls in place. */
position: sticky;
left: 0;
}
/* Otherwise Webkit based browsers will display ugly border on focus. */
textarea, input, button
{
outline: none;
border: none;
color: var(--text-color);
}
/* The rule above removes the default outline; re-add a clear one for
keyboard focus only (Tab), so the controls show where focus is while a
mouse click does not draw a ring. Covers the real <button>s (Run, Run
all, Download) and the icon "buttons" (copy, download, theme switcher). */
button:focus-visible,
[role="button"]:focus-visible
{
outline: 2px solid var(--link-color);
outline-offset: 2px;
}
.monospace
{
/* Prefer fonts that have full hinting info. This is important for non-retina displays.
Also I personally dislike "Ubuntu" font due to the similarity of 'r' and 'г' (it looks very ignorant). */
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
}
.shadow
{
box-shadow: 0 0 1rem var(--shadow-color);
}
input, textarea
{
border: 1px solid var(--border-color);
/* The font must be not too small (to be inclusive) and not too large (it's less practical and make general feel of insecurity) */
font-size: 11pt;
padding: 0.25rem;
background-color: var(--element-background-color);
}
input:focus, textarea:focus
{
color: var(--text-color-active);
}
#query
{
display: block;
position: relative;
z-index: 1;
/* Make enough space for even big queries. */
height: 20vh;
/* Keeps query text-area's width full screen even when user adjusting the width of the query box. */
width: 100%;
min-width: 100%;
max-width: 100%;
margin-top: -1px; /* To compensate the border. */
resize: none; /* Disable default browser resizer */
}
/* When the backdrop is populated with the highlighted version of the text,
we hide the textarea's own text by setting its color to transparent. What
the user sees is the backdrop directly. The caret keeps a normal color via
`caret-color` (the bare `color: transparent` would also make the caret
transparent because the caret defaults to `currentColor`). */
#query.has-backdrop
{
background: transparent;
color: transparent;
caret-color: var(--text-color-active);
}
#query-backdrop
{
display: none;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
/* Typography MUST match the textarea exactly. */
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
font-size: 11pt;
line-height: normal;
padding: 0.25rem;
border: 1px solid transparent; /* same box model as textarea */
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
background-color: var(--element-background-color);
/* Default backdrop text is the "neutral" color of the chosen palette.
It coincides with the canonical text color after the difference blend. */
color: var(--syntax-default);
}
#query-backdrop .q-active { background-color: var(--current-query-background-color); }
/* Per-token syntax highlighting classes. The color value here is the raw color
painted in the backdrop; the visible color the user sees is the difference
between this and --text-color-active (see the comment on .has-backdrop). */
#query-backdrop .q-kw { color: var(--syntax-keyword); font-weight: bold; }
#query-backdrop .q-id { color: var(--syntax-identifier); }
#query-backdrop .q-fn { color: var(--syntax-function); }
#query-backdrop .q-num { color: var(--syntax-number); }
#query-backdrop .q-str { color: var(--syntax-string); }
#query-backdrop .q-qid { color: var(--syntax-qident); }
#query-backdrop .q-com { color: var(--syntax-comment); font-style: italic; }
#query-backdrop .q-op { color: var(--syntax-operator); }
#query-backdrop .q-err { color: var(--syntax-error); text-decoration: underline wavy; }
/* Rainbow parentheses: brackets are colored by their nesting depth (cycling
through the seven --syntax-bracket-* colors). */
#query-backdrop .q-br0 { color: var(--syntax-bracket-0); }
#query-backdrop .q-br1 { color: var(--syntax-bracket-1); }
#query-backdrop .q-br2 { color: var(--syntax-bracket-2); }
#query-backdrop .q-br3 { color: var(--syntax-bracket-3); }
#query-backdrop .q-br4 { color: var(--syntax-bracket-4); }
#query-backdrop .q-br5 { color: var(--syntax-bracket-5); }
#query-backdrop .q-br6 { color: var(--syntax-bracket-6); }
/* The pair of brackets around the cursor is emphasized in bold with a faint
background box. Bold monospace has the same advance width, so the surrounding
text does not shift. */
#query-backdrop .q-br-match
{
font-weight: bold;
background-color: color-mix(in srgb, var(--syntax-default) 20%, transparent);
border-radius: 0.15rem;
}
/* Underline used both for every occurrence of the identifier under the cursor and
for the digit-group separators inside long numbers (matching `clickhouse-client`). */
#query-backdrop .q-underline { text-decoration: underline; }
/* Off-screen mirror used to measure the pixel coordinates of the caret inside the
textarea. Its typography and box model MUST match the textarea exactly (same as
the backdrop) so that wrapping is identical. */
#completion-mirror
{
position: absolute;
inset: 0;
visibility: hidden;
overflow: hidden;
pointer-events: none;
z-index: -1;
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
font-size: 11pt;
line-height: normal;
padding: 0.25rem;
border: 1px solid transparent;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
box-sizing: border-box;
}
/* The completion overlay sits above the textarea and shows the "ghost" completion
candidates. The container itself does not capture clicks (so the user can keep
interacting with the textarea), only its entries do. Its inner-border (padding box)
origin coincides with the textarea's, so entry offsets line up with the caret. */
#completion-overlay
{
display: none;
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 2;
border: 1px solid transparent;
box-sizing: border-box;
}
/* A hidden, zero-visibility span injected into the backdrop at the cursor. It occupies
exactly the width (and wrapping) of the selected completion's suffix, so the text to
the right shifts as if the completion had already been typed. */
#query-backdrop .completion-spacer
{
visibility: hidden;
}
.completion-entry
{
position: absolute;
white-space: pre;
pointer-events: auto;
cursor: pointer;
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
font-size: 11pt;
line-height: normal;
/* Faint text (a muted version of the backdrop's default token color). The
faintness lives in the color, not in `opacity`, so the background can stay
fully opaque and cover any existing text underneath. Inactive candidates are
a 30% mix; the selected one is brighter (see below). */
color: color-mix(in srgb, var(--syntax-default) 30%, var(--element-background-color));
/* Opaque background so the entry fully covers any existing text underneath. */
background-color: var(--element-background-color);
border-radius: 0.2rem;
}
/* The selected entry is distinguished only by being a little brighter: a 50% mix of
the default color (vs 30% for the inactive ones). */
.completion-entry.completion-selected
{
color: color-mix(in srgb, var(--syntax-default) 50%, var(--element-background-color));
}
/* The "…" marker shown when more candidates exist below the visible window. */
.completion-entry.completion-more
{
cursor: default;
}
#inputs
{
white-space: nowrap;
}
#url
{
width: 70%;
}
#user, #password
{
width: calc(15% + 1px);
margin-left: -1px;
}
input.ok
{
color: var(--element-color-ok);
}
input.fail
{
background-color: var(--element-background-color-fail);
}
#run_div
{
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 1rem;
}
query-progress
{
flex: 1;
}
#run, #run-all
{
grid-area: run;
user-select: none;
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 1rem;
cursor: pointer;
font-weight: bold;
font-size: 100%; /* Otherwise button element will have a lower font size. */
}
#run-all
{
display: none;
}
#run:hover, #run:focus, #run-all:hover, #run-all:focus, #copy-div:hover, #copy-div:focus, #download-div:hover, #download-div:focus
{
color: var(--button-active-text-color);
background-color: var(--button-active-color);
}
#hint
{
grid-area: hint;
color: var(--misc-text-color);
}
#copy-div
{
display: none;
user-select: none;
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 0.65rem;
cursor: pointer;
position: relative;
}
.copy-balloon
{
position: absolute;
bottom: calc(100% + 0.25rem);
left: 50%;
transform: translateX(-50%);
background-color: var(--button-color);
color: var(--button-text-color);
padding: 0.25rem 0.5rem;
white-space: nowrap;
font-size: 0.85em;
opacity: 0;
animation: balloon-fade 1s ease-out;
pointer-events: none;
}
@keyframes balloon-fade
{
0% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; }
}
#copy-icon
{
width: 1em;
height: 1em;
vertical-align: middle;
}
#download-div
{
grid-area: download;
display: none;
user-select: none;
color: var(--button-text-color);
background-color: var(--button-color);
padding: 0.25rem 0.65rem;
cursor: pointer;
position: relative;
}
#download
{
width: 1em;
height: 1em;
vertical-align: middle;
}
#download-dropdown
{
display: none;
position: absolute;
background-color: var(--button-color);
border: 1px solid var(--button-text-color);
padding: 0.5rem;
z-index: 999;
box-shadow: 0 4px 6px var(--shadow-color);
}
#download-dropdown.show
{
display: block;
}
.download-option
{
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.25rem;
}
.download-option label
{
color: var(--button-text-color);
white-space: nowrap;
margin-right: 0.5rem;
}
.download-option select,
#download-format-other
{
flex: 1;
padding: 0.25rem;
background-color: var(--element-background-color);
color: var(--text-color);
border: 1px solid var(--shadow-color);
&:focus {
outline: none;
}
}
#download-format-other
{
display: none;
margin-top: 0.25rem;
margin-bottom: 0.5rem;
}
#download-format-other.show
{
display: block;
}
#download-button
{
width: 100%;
margin-top: 0rem;
padding: 0.5rem 0.5rem;
background-color: var(--element-background-color);
color: var(--text-color);
border: none;
cursor: pointer;
font-weight: bold;
&:hover {
background-color: var(--button-active-color);
color: var(--button-active-text-color);
}
}
#theme
{
grid-area: theme;
}
#toggle-light, #toggle-dark
{
cursor: pointer;
user-select: none;
}
[data-theme="light"] #toggle-light, [data-theme="dark"] #toggle-dark
{
display: none;
}
.textarea-resizer {
position: absolute;
bottom: -0.3rem;
left: 0;
width: 100%;
height: 0.5rem;
cursor: row-resize;
user-select: none;
}
.textarea-resizer:hover, .textarea-resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color);
}
#query_div {
position: relative;
}
#query-params
{
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
margin-top: 0.25rem;
align-items: center;
}
.query-param
{
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.query-param label
{
color: var(--misc-text-color);
white-space: nowrap;
}
.query-param input
{
width: 10em;
max-width: 20em;
}
a, a:visited
{
color: var(--link-color);
text-decoration: none;
}
#logo
{
fill: var(--logo-color);
}
#cloud-logo
{
color: transparent;
text-shadow: 0 0 0 var(--logo-color);
font-size: 10vw;
display: block;
}
#logo-container
{
text-align: center;
margin-top: 5em;
line-height: 0.75;
position: sticky;
left: 0;
z-index: -1; /* Below the expanded selected cell */
}
#url_status
{
position: absolute;
display: inline-block;
font-size: 11pt;
padding: 0.25rem;
transform: translateX(-100%);
color: transparent;
user-select: none;
}
#url_status.ok
{
color: #0F0;
}
#url_status.fail
{
color: #F00;
}
#server_info
{
color: var(--misc-text-color);
}
/* Prevents a different color when Chromium auto-fills inputs from the list. */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active
{
transition: background-color 600000s 0s, color 600000s 0s;
}
</style>
</head>
<body>
<div id="menu">
<a id="databases-icon" class="menu-icon" href="" title="Databases and tables">
<svg id="databases-toggle" viewBox="0 0 9 9"><path d="M0,0 h1 v8 h-1 z"/><path d="M2,0 h1 v8 h-1 z"/><path d="M4,0 h1 v8 h-1 z"/><path d="M6,0 h1 v8 h-1 z"/><path d="M8,3.25 h1 v1.5 h-1 z"/></svg>
</a>
<button id="databases-hide" type="button" title="Hide the database panel">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><line x1="3" y1="12" x2="15" y2="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><polyline points="9,6 3,12 9,18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="20" y1="4" x2="20" y2="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
<div id="databases"></div>
<a id="terminal-icon" class="menu-icon" href="" title="Web Terminal (~) — middle-click or Ctrl/Shift+click to open in a new tab">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="22" height="22" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M6 8l4 4-4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="13" y1="16" x2="18" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</a>
<a id="docs-icon" class="menu-icon" href="/docs" target="_blank" rel="noopener noreferrer" title="Documentation">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 4a2 2 0 0 1 2-2h13v18H6a2 2 0 0 0-2 2V4z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><line x1="8" y1="7" x2="15" y2="7" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="8" y1="11" x2="15" y2="11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</a>
<!-- https://github.com/logos -->
<div id="github-icon" class="menu-icon">
<a href="https://github.com/ClickHouse/ClickHouse/">
<svg id="github-icon-light" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
<svg id="github-icon-dark" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
</a>
</div>
</div>
<div id="terminal-panel">
<div id="terminal-resizer"></div>
</div>
<div id="main">
<div id="tab-bar" hidden>
<div id="tabs"></div>
<button id="tab-add" type="button" title="New query (open a new tab)">+</button>
<div id="connection-menu">
<button id="connection-key" type="button" title="Connection settings" aria-expanded="false" hidden>🔑</button>
<div id="connection-dropdown" hidden></div>
</div>
</div>
<form id="controls">
<div id="inputs">
<input name="url" form="controls" spellcheck="false" class="monospace shadow" id="url" type="url" value="" placeholder="url" /><span id="url_status"><span id="server_info"></span>●</span><input name="user" form="controls" spellcheck="false" class="monospace shadow" id="user" type="text" value="" placeholder="user" /><input name="password" form="controls" class="monospace shadow" id="password" type="password" placeholder="password" />
</div>
<div id="query_div"><div id="query-backdrop"></div><div id="completion-mirror"></div><textarea autofocus spellcheck="false" autocorrect="false" autocomplete="false" data-gramm="false" class="monospace shadow" id="query"></textarea><div id="completion-overlay"></div></div>
<div id="query-params"></div>
<div id="run_div">
<button type="submit" class="shadow" id="run">Run</button>
<button type="button" class="shadow" id="run-all">Run all</button>
<div id="hint">&nbsp;(<span id="hint-modifier">Ctrl/Cmd</span>+Enter<span id="hint-run-all" style="display: none">, +Shift to run all</span>)</div>
<query-progress id="query-progress"></query-progress>
<div id="copy-div" class="shadow" title="Copy raw result to clipboard">
<svg id="copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</div>
<div id="download-div" class="shadow">
<svg id="download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div id="theme"><span id="toggle-dark">🌘</span><span id="toggle-light">☀️</span></div>
</div>
</form>
<query-result id="query-result"></query-result>
<p id="logo-container">
<a href="https://clickhouse.com/">
<svg id="logo" width="50%" viewBox="0 0 180 28" version="1.1" xmlns="http://www.w3.org/2000/svg"><title>Powered by ClickHouse</title><g transform="translate(21, 0)"><path d="M9.76,20.95 C8.43,20.97 7.11,20.73 5.87,20.26 C4.72,19.80 3.67,19.11 2.81,18.22 C1.90,17.32 1.20,16.25 0.74,15.07 C-0.24,12.29 -0.24,9.27 0.74,6.49 C1.67,4.07 3.61,2.16 6.08,1.26 C7.35,0.79 8.71,0.55 10.07,0.57 C11.16,0.54 12.24,0.67 13.30,0.93 C14.15,1.16 14.98,1.47 15.78,1.87 L15.03,4.17 C14.33,3.70 13.57,3.32 12.78,3.03 C11.83,2.71 10.83,2.56 9.82,2.58 C7.96,2.56 6.18,3.35 4.96,4.74 C4.30,5.50 3.79,6.38 3.47,7.33 C3.08,8.46 2.89,9.65 2.91,10.85 C2.90,12.02 3.07,13.18 3.43,14.29 C3.73,15.23 4.21,16.09 4.86,16.84 C5.46,17.52 6.21,18.06 7.05,18.41 C7.95,18.78 8.92,18.96 9.90,18.96 C10.80,18.96 11.68,18.82 12.53,18.55 C13.34,18.27 14.12,17.91 14.85,17.47 L15.57,19.20 C14.87,19.72 14.09,20.13 13.25,20.40 C12.13,20.78 10.95,20.97 9.76,20.95 L9.76,20.95 Z"></path><polygon points="21.20 0 21.20 20.67 18.73 20.67 18.73 0 21.20 0"></polygon><path d="M26.51,4.15 C26.09,4.18 25.68,4.03 25.38,3.74 C25.08,3.46 24.92,3.05 24.94,2.64 C24.92,2.23 25.07,1.83 25.35,1.52 C25.99,0.92 26.99,0.92 27.63,1.52 C27.92,1.82 28.07,2.23 28.06,2.64 C28.07,3.05 27.92,3.44 27.63,3.72 C27.33,4.01 26.93,4.16 26.51,4.15 L26.51,4.15 Z M27.75,6.31 L27.75,20.56 L25.31,20.56 L25.31,6.31 L27.77,6.31 L27.75,6.31 Z"></path><path d="M38.20,20.95 C37.23,20.96 36.27,20.78 35.36,20.44 C34.50,20.12 33.72,19.62 33.07,18.98 C32.40,18.30 31.88,17.51 31.54,16.63 C30.78,14.61 30.78,12.39 31.54,10.36 C31.89,9.48 32.43,8.68 33.11,8.02 C33.80,7.36 34.62,6.85 35.53,6.53 C36.46,6.19 37.45,6.02 38.44,6.02 C39.23,6.02 40.02,6.11 40.78,6.29 C41.40,6.43 41.99,6.67 42.52,7.00 L41.73,9.14 C41.23,8.76 40.68,8.44 40.10,8.18 C39.49,7.95 38.85,7.84 38.20,7.86 C37.57,7.85 36.95,7.97 36.38,8.20 C35.80,8.44 35.28,8.80 34.87,9.26 C34.42,9.77 34.07,10.36 33.85,10.99 C33.58,11.78 33.45,12.61 33.46,13.44 C33.36,14.92 33.85,16.38 34.83,17.51 C35.81,18.45 37.15,18.94 38.53,18.87 C39.72,18.85 40.89,18.48 41.88,17.81 L42.44,19.61 C41.88,20.02 41.26,20.33 40.60,20.52 C39.82,20.79 39.01,20.93 38.20,20.95 L38.20,20.95 Z"></path><path d="M47.94,0 L47.94,20.67 L45.44,20.67 L45.44,0 L47.94,0 Z M54.35,20.67 L48.27,13.01 L54.27,6.33 L56.83,6.33 L50.90,12.72 L57.24,20.67 L54.35,20.67 L54.35,20.67 Z"></path><polygon points="73.11 11.87 62.77 11.87 62.77 20.65 60.10 20.65 60.10 0.85 62.77 0.85 62.77 9.89 73.11 9.89 73.11 0.85 75.78 0.85 75.78 20.67 73.11 20.67"></polygon><path d="M86.30,20.95 C85.38,20.95 84.47,20.78 83.61,20.44 C82.78,20.12 82.01,19.63 81.38,19.00 C80.72,18.32 80.21,17.53 79.87,16.65 C79.12,14.60 79.12,12.35 79.87,10.30 C80.21,9.43 80.72,8.64 81.38,7.98 C82.02,7.35 82.78,6.86 83.61,6.53 C84.47,6.20 85.38,6.02 86.30,6.02 C87.23,6.02 88.15,6.20 89.01,6.53 C89.86,6.86 90.62,7.35 91.27,7.98 C91.94,8.64 92.47,9.43 92.82,10.30 C93.59,12.35 93.59,14.60 92.82,16.65 C92.50,17.52 92.00,18.32 91.37,19.00 C90.73,19.63 89.96,20.12 89.12,20.44 C88.22,20.79 87.27,20.96 86.30,20.95 Z M86.30,19.10 C86.88,19.10 87.46,18.99 88.00,18.79 C88.56,18.59 89.07,18.25 89.47,17.81 C89.93,17.31 90.28,16.71 90.50,16.06 C90.78,15.23 90.91,14.35 90.89,13.48 C90.92,12.60 90.78,11.72 90.50,10.89 C90.28,10.25 89.93,9.66 89.47,9.16 C89.07,8.72 88.56,8.39 88.00,8.18 C87.46,7.98 86.88,7.88 86.30,7.88 C85.74,7.88 85.18,7.98 84.65,8.18 C84.10,8.39 83.61,8.73 83.22,9.16 C82.77,9.66 82.42,10.25 82.21,10.89 C81.94,11.73 81.81,12.60 81.84,13.48 C81.81,14.35 81.94,15.23 82.21,16.06 C82.42,16.71 82.76,17.31 83.22,17.81 C83.61,18.25 84.10,18.58 84.65,18.79 C85.18,18.99 85.74,19.10 86.30,19.10 L86.30,19.10 Z"></path><path d="M104.21,20.12 C103.26,20.71 102.14,21.00 101.01,20.95 C99.79,21.00 98.61,20.56 97.74,19.73 C96.83,18.70 96.38,17.34 96.50,15.98 L96.50,6.31 L98.98,6.31 L98.98,15.98 C98.97,16.47 99.04,16.95 99.19,17.41 C99.30,17.76 99.50,18.07 99.77,18.32 C100.01,18.55 100.31,18.72 100.64,18.81 C100.99,18.91 101.35,18.96 101.71,18.96 C102.65,19.00 103.58,18.73 104.34,18.18 C105.05,17.63 105.63,16.94 106.03,16.14 L106.03,6.31 L108.49,6.31 L108.49,20.56 L106.32,20.56 L106.10,18.00 L106.10,18.00 C105.68,18.86 105.03,19.60 104.21,20.12 L104.21,20.12 Z"></path><path d="M116.42,20.95 C115.80,20.99 115.17,20.99 114.55,20.95 C114.09,20.89 113.62,20.80 113.17,20.69 C112.81,20.60 112.46,20.48 112.11,20.34 C111.83,20.22 111.55,20.07 111.29,19.91 L112.09,17.88 C112.27,18.02 112.47,18.15 112.67,18.26 C112.97,18.43 113.28,18.57 113.60,18.69 C114.01,18.85 114.43,18.97 114.86,19.06 C115.38,19.16 115.90,19.20 116.42,19.20 C117.26,19.26 118.09,19.04 118.79,18.59 C119.33,18.19 119.63,17.56 119.60,16.90 C119.62,16.34 119.38,15.80 118.94,15.45 C118.24,14.98 117.47,14.63 116.64,14.43 L114.86,13.84 C114.33,13.66 113.82,13.39 113.36,13.07 C112.91,12.76 112.55,12.35 112.28,11.89 C111.99,11.34 111.84,10.72 111.87,10.10 C111.79,8.92 112.33,7.79 113.29,7.10 C114.46,6.39 115.83,6.05 117.20,6.13 C118.07,6.11 118.94,6.19 119.79,6.37 C120.38,6.49 120.97,6.68 121.52,6.92 L120.95,8.96 C120.47,8.73 119.99,8.52 119.50,8.34 C118.79,8.11 118.05,8.00 117.31,8.02 C116.53,7.98 115.76,8.13 115.05,8.45 C114.47,8.75 114.14,9.37 114.20,10.01 C114.20,10.33 114.29,10.63 114.47,10.89 C114.66,11.15 114.90,11.36 115.18,11.52 C115.50,11.72 115.85,11.87 116.21,11.99 L117.45,12.36 L119.13,12.91 C119.66,13.10 120.16,13.36 120.61,13.70 C121.60,14.41 122.15,15.56 122.08,16.76 C122.12,17.46 121.95,18.17 121.61,18.79 C121.30,19.33 120.86,19.79 120.35,20.14 C119.80,20.50 119.19,20.76 118.55,20.91 C117.84,21.01 117.13,21.02 116.42,20.95 Z"></path><path d="M136.37,19.42 C135.76,19.91 135.06,20.28 134.31,20.50 C133.39,20.80 132.43,20.95 131.47,20.93 C129.48,21.04 127.53,20.30 126.14,18.89 C124.81,17.36 124.15,15.38 124.28,13.38 C124.26,12.31 124.43,11.26 124.79,10.26 C125.11,9.39 125.59,8.60 126.22,7.92 C126.79,7.29 127.50,6.79 128.29,6.45 C129.13,6.11 130.03,5.94 130.93,5.94 C131.84,5.93 132.74,6.10 133.58,6.45 C134.35,6.77 135.02,7.28 135.53,7.94 C136.06,8.66 136.42,9.50 136.56,10.38 C136.75,11.50 136.75,12.64 136.56,13.76 L126.72,13.76 C126.83,17.17 128.46,18.87 131.62,18.87 C132.38,18.89 133.13,18.77 133.85,18.53 C134.49,18.30 135.10,18.00 135.67,17.63 L136.37,19.42 Z M131.02,7.73 C130.01,7.72 129.05,8.12 128.35,8.83 C127.52,9.75 127.03,10.91 126.94,12.13 L134.33,12.13 C134.53,10.97 134.25,9.78 133.56,8.81 C132.93,8.09 131.99,7.69 131.02,7.73 Z"></path></g></svg>
</a>
<a id="cloud-logo" href="https://clickhouse.cloud/">&#9729;</a>
</p>
<div id="download-dropdown">
<div>
<div class="download-option">
<label for="download-format">Format:</label>
<select id="download-format">
<option value="CSV">CSV</option>
<option value="CSVWithNames">CSVWithNames</option>
<option value="TSV">TSV</option>
<option value="JSON">JSON</option>
<option value="NDJSON" selected>JSONLines</option>
<option value="Parquet">Parquet</option>
<option value="Markdown">Markdown</option>
<option value="other">Other...</option>
</select>
</div>
<input type="text" id="download-format-other" placeholder="Enter format name (e.g., Arrow, Avro)" />
</div>
<button id="download-button">Download</button>
</div>
</div>
</body>
<script type="text/javascript">
class QueryProgressElement extends HTMLElement
{
constructor()
{
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host
{
display: grid;
/* Progress and stats share the available width equally, and both can
shrink below their content size (the zero minimum), so they resize
uniformly when the row is narrow instead of one hogging the space. */
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
align-items: center;
}
#hourglass, #check-mark
{
grid-column: 1;
grid-row: 1;
}
#hourglass
{
display: none;
font-size: 110%;
color: #888;
animation: hourglass-animation 2s linear infinite;
}
#check-mark
{
display: none;
font-size: 110%;
color: #080;
}
#progress
{
display: none;
width: 100%;
color: var(--misc-text-color);
background: linear-gradient(to right,
transparent 0%,
var(--progress-color) var(--progress, 0%),
transparent var(--progress, 0%));
}
#stats
{
color: var(--misc-text-color);
}
#stats b
{
color: var(--progress-color);
}
@keyframes hourglass-animation
{
0% { transform: none; }
25% { transform: rotate(180deg); }
50% { transform: rotate(180deg); }
75% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
</style>
<span id="hourglass">&#10711;</span>
<span id="check-mark">&#10004;</span>
<span id="progress"></span>
<span id="stats"></span>
`;
this._hourglass = this.shadowRoot.getElementById('hourglass');
this._checkMark = this.shadowRoot.getElementById('check-mark');
this._progress = this.shadowRoot.getElementById('progress');
this._stats = this.shadowRoot.getElementById('stats');
}
clear()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'none';
this._stats.innerText = '';
this._progress.style.display = 'none';
this._progress.innerText = '';
this.style.setProperty('--progress', '0%');
}
start()
{
this._checkMark.style.display = 'none';
this._hourglass.style.display = 'inline-block';
}
finish()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'inline-block';
}
updateProgress(progress)
{
const rows = +progress.read_rows;
const bytes = +progress.read_bytes;
const total_rows = +progress.total_rows_to_read;
const elapsed_ns = +progress.elapsed_ns;
let formatted_rows = formatReadableRows(rows);
let formatted_bytes = formatReadableBytes(bytes);
const rps = rows * 1e9 / elapsed_ns;
const bps = bytes * 1e9 / elapsed_ns;
let formatted_rps = formatReadableRows(rps) + '/sec';
let formatted_bps = formatReadableBytes(bps) + '/sec';
if (rows >= 1e11) { formatted_rows = `<b>${formatted_rows}<\/b>`; }
if (bytes >= 1e12) { formatted_bytes = `<b>${formatted_bytes}<\/b>`; }
if (rps >= 1e10) { formatted_rps = `<b>${formatted_rps}<\/b>`; }
if (bps >= 1e10) { formatted_bps = `<b>${formatted_bps}<\/b>`; }
let text = '';
if (total_rows) { text += (100 * Math.min(1.0, rows / total_rows)).toFixed(1) + '%, '; }
text += `Read ${formatted_rows} rows, ${formatted_bytes}`;
if (rps > 1e6) { text += ` (${formatted_rps}, ${formatted_bps})`; }
this._stats.innerHTML = text;
this.style.setProperty('--progress',
rows && total_rows ? (100 * rows / total_rows) + '%' : '0');
}
updateText(rowCount, elapsedNs, incomplete)
{
let progress_text = '';
if (rowCount)
{
progress_text += `${rowCount}${incomplete ? '+' : ''} rows in result`;
if (elapsedNs) progress_text += ', ';
}
if (elapsedNs) { progress_text += `${(elapsedNs / 1e9).toFixed(2)} sec.`; }
this._progress.innerText = progress_text;
this._progress.style.display = 'block';
}
}
customElements.define('query-progress', QueryProgressElement);
/// Per-column color coding modes chosen by the user, keyed by column name.
/// Only columns with an explicit (non-default) choice are stored here. It is the single source
/// of truth shared across result tables and is persisted in the URL and browser history.
let column_color_modes = {};
class QueryResultElement extends HTMLElement
{
static _loadVizPromise = null;
static _vizInstance = null;
static _loadUplotPromise = null;
/// The glyph used to render SQL NULL values (superscript "NULL").
static _NULL_TEXT = 'ᴺᵁᴸᴸ';
constructor()
{
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
*
{
box-sizing: border-box;
}
:host
{
display: block;
outline: none;
}
/* Show a focus ring only for keyboard focus (Tab), not when a cell
click programmatically focuses the table. */
:host(:focus-visible)
{
outline: 2px solid var(--link-color);
outline-offset: -2px;
}
.monospace
{
font-family: DejaVu Sans Mono, Liberation Mono, MonoLisa, Consolas, monospace;
}
.shadow
{
box-shadow: 0 0 1rem var(--shadow-color);
}
::selection
{
color: var(--selection-color);
background-color: var(--selection-background-color);
}
a, a:visited
{
color: var(--link-color);
text-decoration: none;
}
#data_div
{
margin-top: 1rem;
background-color: var(--element-background-color);
}
#data_div.fixed
{
width: fit-content;
}
#data-table
{
width: 100%;
border-spacing: 0;
background-color: var(--element-background-color);
border-collapse: collapse;
}
#data-table.fixed
{
table-layout: fixed;
width: var(--table-width);
}
#data-unparsed
{
width: fit-content;
background-color: var(--element-background-color);
margin-top: 0rem;
padding: 0.25rem 0.5rem;
display: none;
}
#image
{
display: none;
margin-top: 1rem;
max-width: 100%;
background-color: var(--element-background-color);
}
td
{
background-color: var(--element-background-color);
max-width: 40vw;
padding: 0; /* Padding is on .cell-content instead */
border: 1px solid var(--border-color);
white-space: pre;
vertical-align: top;
}
/* The selected cell receives keyboard focus; selection is shown by
the .td-selected class, so suppress the default focus outline. */
td:focus
{
outline: none;
}
/* Inner div limits cell height to 3 lines; expands on selection */
.cell-content
{
padding: 0.25rem 0.5rem;
max-height: 3lh;
overflow: hidden;
}
td u
{
text-decoration-color: var(--misc-text-color);
}
.right
{
text-align: right;
}
th
{
position: sticky;
top: -0.5rem;
padding: 0.25rem 0.5rem;
text-align: center;
background-color: var(--table-header-color);
border: 1px solid var(--border-color);
}
th .name
{
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.type-hint
{
display: none;
position: absolute;
user-select: none;
left: 0;
top: -1px;
font-size: 75%;
font-weight: normal;
color: var(--misc-text-color);
background: var(--element-background-color);
max-height: 5em;
white-space: pre;
}
thead .type-hint
{
transform: translateY(-100%);
}
th:hover .type-hint
{
display: inline-block;
}
.resizer
{
position: absolute;
top: 0;
right: -0.3rem;
width: 0.5rem;
cursor: col-resize;
user-select: none;
height: 100%;
}
.resizer:hover, .resizer-dragging
{
border-radius: 0.25rem;
background-color: var(--border-color-selected);
}
/* Icon at the right side of a column header to switch the color coding mode.
On devices with a hovering pointer it appears only while the header is hovered,
so it stays out of the way otherwise. Sits just left of the resizer. */
.colorize-toggle
{
display: none;
position: absolute;
top: 50%;
right: 0.35rem;
transform: translateY(-50%);
cursor: pointer;
font-size: 0.9em;
line-height: 1;
user-select: none;
}
th:hover .colorize-toggle
{
display: inline-block;
}
/* Touch / coarse-pointer devices have no hover, and a hidden (display: none) icon is
not hit-testable: a tap would land on the th, never reaching the icon's click
handler, so the color switch could not be activated at all. Keep the icon always
visible there, which makes it directly tappable. */
@media (hover: none), (pointer: coarse)
{
.colorize-toggle
{
display: inline-block;
}
}
/* A faint dimming on hover, since the colorful emoji cannot take a text color. */
.colorize-toggle:hover
{
opacity: 0.7;
}
/* Apply hover/selection filter per-cell, excluding the active cell,
because filter on tr would create a stacking context trapping it. */
tr:hover td:not(.td-selected)
{
filter: var(--table-hover-filter);
}
.tr-selected
{
position: relative;
z-index: 1; /* Above hovered rows */
}
.tr-selected td:not(.td-selected)
{
filter: var(--table-hover-filter);
}
#error
{
background: var(--error-color);
white-space: pre-wrap;
padding: 0.5rem 1rem;
display: none;
}
.cell-copy
{
display: none;
position: absolute;
bottom: 100%;
right: -1px;
padding: 0.25rem;
width: fit-content;
cursor: pointer;
filter: invert(1);
background-color: var(--element-background-color);
z-index: 1;
}
/* First row after a sticky header: place to the side to avoid overlap */
thead + tbody tr:first-child .cell-copy
{
bottom: auto;
left: 100%;
top: -1px;
}
thead + tbody tr:first-child td:last-child .cell-copy
{
left: auto;
right: 100%;
}
.cell-copy svg
{
width: 1em;
height: 1em;
vertical-align: middle;
}
.td-selected .cell-copy
{
display: block;
}
/* Selected cell stacks above all other cells */
.td-selected
{
position: relative;
z-index: 10;
}
/* Expand the inner div on top of the table without reflowing it */
.td-selected .cell-content
{
position: absolute;
top: -1px;
left: -1px;
width: calc(100% + 2px); /* Compensate for collapsed border (1px each side) */
min-height: calc(100% + 2px);
max-height: 50vh;
white-space: pre-wrap;
word-break: break-all;
overflow-y: auto;
scrollbar-width: thin;
filter: invert(1);
background-color: var(--element-background-color);
}
td.empty-result
{
text-align: center;
vertical-align: middle;
}
.row-number
{
width: 1%;
text-align: right;
padding: 0.25rem 0.5rem;
background-color: var(--table-header-color);
color: var(--misc-text-color);
}
tfoot td
{
font-weight: bold;
background-color: var(--table-header-color);
position: sticky;
bottom: -0.5rem;
border: 1px solid var(--border-color);
}
div.empty-result
{
opacity: 10%;
font-size: 7vw;
font-family: Liberation Sans, DejaVu Sans, sans-serif;
}
#graph
{
display: none;
margin: auto;
overflow: auto;
background: white;
color: black;
padding: 0.5rem;
border-radius: 3px;
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
}
#graph svg
{
display: block;
margin: auto;
}
#chart
{
border: 1px solid var(--border-color);
background-color: var(--element-background-color);
filter: drop-shadow(.2rem .2rem .2rem var(--shadow-color));
display: none;
height: 70vh;
}
.u-wrap {position: relative;user-select: none;}
.u-over, .u-under, .u-axis {position: absolute;}
.u-under {overflow: hidden;}
.uplot canvas {display: block;position: relative;width: 100%;height: 100%;}
.u-legend {font-family: Liberation Mono, DejaVu Sans Mono, MonoLisa, Consolas, monospace; border-spacing: 0; border-collapse: collapse;}
.u-inline {display: block;}
.u-inline * {display: inline-block;}
.u-legend th {font-weight: 600;}
.u-legend th > * {vertical-align: middle;display: inline-block;}
.u-legend td { min-width: 13em; }
.u-legend .u-marker {width: 1em;height: 1em;margin-right: 4px;background-clip: padding-box !important;}
.u-inline.u-live th::after {content: ":";vertical-align: middle;}
.u-inline:not(.u-live) .u-value {display: none;}
.u-series > * {padding: 4px;}
.u-series th {cursor: pointer;}
.u-legend .u-off > * {opacity: 0.3;}
.u-select {background: rgba(0,0,0,0.07);position: absolute;pointer-events: none;}
.u-cursor-x, .u-cursor-y {position: absolute;left: 0;top: 0;pointer-events: none;will-change: transform;z-index: 100;}
.u-hz .u-cursor-x, .u-vt .u-cursor-y {height: 100%;border-right: 1px dashed #607D8B;}
.u-hz .u-cursor-y, .u-vt .u-cursor-x {width: 100%;border-bottom: 1px dashed #607D8B;}
.u-cursor-pt {position: absolute;top: 0;left: 0;border-radius: 50%;border: 0 solid;pointer-events: none;will-change: transform;z-index: 100;background-clip: padding-box !important;}
.u-axis.u-off, .u-select.u-off, .u-cursor-x.u-off, .u-cursor-y.u-off, .u-cursor-pt.u-off {display: none;}
</style>
<div id="data_div">
<table class="monospace shadow" id="data-table"></table>
<pre class="monospace shadow" id="data-unparsed"></pre>
</div>
<img id="image" class="shadow" alt="Query result image">
<div id="chart"></div>
<div id="graph"></div>
<p id="error" class="monospace shadow"></p>
`;
this._dataDiv = this.shadowRoot.getElementById('data_div');
this._dataTable = this.shadowRoot.getElementById('data-table');
this._dataUnparsed = this.shadowRoot.getElementById('data-unparsed');
this._image = this.shadowRoot.getElementById('image');
this._chart = this.shadowRoot.getElementById('chart');
this._graph = this.shadowRoot.getElementById('graph');
this._error = this.shadowRoot.getElementById('error');
this._row_idx = 0;
this._elapsed_ns = 0;
this._incomplete_result = false;
this._is_explain_graph = false;
this._explain_graph = '';
this._column_is_number = {};
/// Date/DateTime columns are colorable by their numeric (time) value, but are kept
/// separate from `_column_is_number` so they stay left-aligned and unformatted.
this._column_is_date = {};
this._extremes = {};
this._totals = null;
this._header = {};
this._current_selected_cell = null;
/// State of the categorical same-value emphasis, cached to avoid recomputing it when the
/// active cell moves but its value (and column) is unchanged.
this._emphasized_col_idx = null;
this._emphasized_value = null;
this._emphasized_active_cell = null;
/// Debounce timer for the categorical emphasis, so rapid selection changes recompute once.
this._emphasis_timer = null;
this._uplot = null;
this._queryText = '';
/// Object URL backing the displayed image, kept so it can be revoked when the result is cleared.
this._imageObjectURL = null;
this._max_rows = 1_000;
this._max_cells = 100_000;
this.addEventListener('keydown', (e) => this._handleKeyDown(e));
/* Clicking on non-data areas (e.g. table header) deactivates the selected cell */
this.shadowRoot.addEventListener('click', (e) => {
if (!e.target.closest('td') || e.target.closest('.row-number')) this.clearSelection();
});
new ResizeObserver(() => this._resizeChart()).observe(this._chart);
}
connectedCallback()
{
/// Make the result table part of the Tab navigation sequence: the user can
/// Tab to it, navigate cells with the arrow keys, then Tab on to the next
/// element. The focus ring is handled via `:host(:focus-visible)` in the CSS.
if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '0');
}
disconnectedCallback()
{
/// Multi-query results are transient elements that get removed from the DOM
/// (e.g. via `multiQueryContainer.innerHTML = ''`) without going through `clear`,
/// so revoke the image object URL here to avoid leaking the response blob.
this._clearImage();
}
get rowCount() { return this._row_idx; }
get elapsedNs() { return this._elapsed_ns; }
set elapsedNs(v) { this._elapsed_ns = v; }
get incompleteResult() { return this._incomplete_result; }
get isExplainGraph() { return this._is_explain_graph; }
get explainGraph() { return this._explain_graph; }
set queryText(v) { this._queryText = v; }
get rawText() { return this._dataUnparsed.textContent; }
_clearElement(elem)
{
while (elem.firstChild)
{
elem.removeChild(elem.lastChild);
}
elem.style.display = 'none';
}
clear()
{
this._clearElement(this._dataTable);
this._clearElement(this._graph);
this._clearElement(this._chart);
this._clearElement(this._dataUnparsed);
this._clearElement(this._error);
this._clearImage();
this._dataTable.classList.remove('fixed');
this._dataDiv.classList.remove('fixed');
this._extremes = {};
this._totals = null;
this._header = {};
this._row_idx = 0;
this._elapsed_ns = 0;
this._incomplete_result = false;
this._is_explain_graph = false;
this._explain_graph = '';
this._current_selected_cell = null;
this._emphasized_col_idx = null;
this._emphasized_value = null;
this._emphasized_active_cell = null;
clearTimeout(this._emphasis_timer);
}
/// Select a cell and move keyboard focus to it. We focus the cell itself rather than
/// the whole result element: the browser scrolls the focused element into view both on
/// `focus` and when the tab is re-focused (e.g. after opening a link in a new tab). The
/// result element spans the full width starting at the left edge, so focusing it would
/// scroll the horizontal position back to the start; focusing the cell keeps the current
/// scroll position because the cell is already in view. `preventScroll` avoids an extra
/// jump here since we scroll the cell into view explicitly with `block: 'nearest'`.
_selectCell(td)
{
if (this._current_selected_cell)
{
this._current_selected_cell.classList.remove('td-selected');
this._current_selected_cell.parentElement.classList.remove('tr-selected');
}
this._current_selected_cell = td;
td.classList.add('td-selected');
td.parentElement.classList.add('tr-selected');
(td.querySelector('.cell-content') || td).scrollIntoView({ block: 'nearest' });
td.tabIndex = -1;
td.focus({ preventScroll: true });
this._scheduleCategoricalEmphasis();
}
clearSelection()
{
if (this._current_selected_cell)
{
this._current_selected_cell.classList.remove('td-selected');
this._current_selected_cell.parentElement.classList.remove('tr-selected');
this._current_selected_cell.removeAttribute('tabindex');
}
this._current_selected_cell = null;
this._scheduleCategoricalEmphasis();
}
clearError()
{
this._clearElement(this._error);
}
_renderCell(name, value)
{
let td = document.createElement('td');
let div = document.createElement('div');
div.className = 'cell-content'; /* Height-limited container; expands absolutely when selected */
let is_null = (value === null);
let is_link = false;
/// Mark the cell from the original value, so categorical coloring can skip real NULLs
/// without relying on the rendered glyph (a string value equal to `_NULL_TEXT` is data).
if (is_null) { td.dataset.isNull = ''; }
let text;
if (is_null)
{
text = QueryResultElement._NULL_TEXT;
}
else if (typeof(value) === 'object')
{
text = JSON.stringify(value);
}
else
{
text = value;
if (typeof(value) == 'string' && value.match(/^https?:\/\/\S+$/))
{
is_link = true;
}
}
let node = document.createTextNode(text);
if (is_link)
{
let link = document.createElement('a');
link.appendChild(node);
link.href = text;
link.setAttribute('target', '_blank');
node = link;
}
td.className = this._column_is_number[name] ? 'right' : 'left';
if (this._column_is_number[name])
{
if (text.length >= 5 && text.match(/^[\d]{5,}$/))
{
let i = 0;
div.innerHTML = text.split('').reverse().map(c => {
if (i > 0 && i % 3 == 0) { c = `<u>${c}<\/u>`; }; ++i; return c;
}).reverse().join('');
td.appendChild(div);
this._appendCopyButton(td, text, value);
return td;
}
}
div.appendChild(node);
td.appendChild(div);
this._appendCopyButton(td, text, value);
return td;
}
_appendCopyButton(td, text, value)
{
if (value === null || value === '' || value === 0 || value === '0') return;
let btn = document.createElement('span');
btn.className = 'cell-copy';
btn.title = 'Copy to clipboard';
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
btn.onclick = (e) => {
e.stopPropagation();
if (navigator.clipboard && navigator.clipboard.writeText)
{
navigator.clipboard.writeText(text);
}
};
td.appendChild(btn);
}
appendHeader(meta)
{
if (meta.length == 0) { return; }
this._header = meta;
let thead = document.createElement('thead');
let th = document.createElement('th');
th.className = 'row-number';
th.appendChild(document.createTextNode('\u2116'));
thead.appendChild(th);
let zIndex = meta.length;
meta.forEach(elem => {
let th = document.createElement('th');
const name = document.createTextNode(elem.name);
const nameSpan = document.createElement('span');
nameSpan.className = 'name';
nameSpan.appendChild(name);
th.appendChild(nameSpan);
let type_hint = document.createElement('span');
type_hint.className = 'type-hint';
type_hint.appendChild(document.createTextNode(elem.type));
th.appendChild(type_hint);
this._column_is_number[elem.name] = !!elem.type.match(/^(Nullable\()?(U?Int|Decimal|Float)/);
this._column_is_date[elem.name] = !!elem.type.match(/^(Nullable\()?Date/);
this._appendColorizeToggle(th, elem.name);
this._appendHeaderResizer(th);
th.style.zIndex = zIndex;
--zIndex;
thead.appendChild(th);
});
let table = this._dataTable;
while (table.firstChild)
{
table.removeChild(table.lastChild);
}
let tbody = document.createElement('tbody');
tbody.id = 'tbody';
table.appendChild(thead);
table.appendChild(tbody);
table.style.display = 'table';
this._row_idx = 0;
}
appendRow(row, fragment)
{
if (this._row_idx >= this._max_rows || (1 + this._row_idx) * Object.keys(this._header).length > this._max_cells)
{
this._incomplete_result = true;
return false;
}
++this._row_idx;
let tr = document.createElement('tr');
let td = document.createElement('td');
td.className = 'row-number';
td.appendChild(document.createTextNode(this._row_idx));
tr.appendChild(td);
for (const column of this._header)
{
const td = this._renderCell(column.name, row[column.name]);
td.onclick = () => {
this._selectCell(td);
};
tr.appendChild(td);
}
fragment.appendChild(tr);
this._incomplete_result = false;
return true;
}
flushFragment(fragment)
{
let tbody = this.shadowRoot.getElementById('tbody');
if (tbody && fragment.hasChildNodes())
{
tbody.appendChild(fragment);
}
}
/// Add the icon at the right of a column header that cycles the column's color coding mode.
_appendColorizeToggle(th, name)
{
const toggle = document.createElement('span');
toggle.className = 'colorize-toggle';
/// A rainbow emoji, conveying "change the coloring".
toggle.textContent = '\u{1F308}';
this._updateColorizeToggleTitle(toggle, name);
toggle.addEventListener('click', (e) => {
e.stopPropagation();
this._cycleColorMode(name);
});
/// Do not let pressing the icon start a column-resize drag or a text selection.
toggle.addEventListener('mousedown', (e) => e.stopPropagation());
th.appendChild(toggle);
}
_updateColorizeToggleTitle(toggle, name)
{
toggle.title = 'Color coding: ' + this._effectiveColorMode(name) + ' (click to change)';
}
/// The sequence of color coding modes available for a column, in cycling order. Value columns
/// (numbers, Date/DateTime) support all four modes; other columns only none <-> categorical.
_colorModeCycle(name)
{
return this._columnSupportsValueColoring(name)
? ['bar', 'heatmap', 'categorical', 'none']
: ['none', 'categorical'];
}
/// Switch a column to the next color coding mode. Numeric columns cycle through
/// bar -> heatmap -> categorical -> none; non-numeric columns toggle none <-> categorical.
_cycleColorMode(name)
{
const cycle = this._colorModeCycle(name);
const idx = cycle.indexOf(this._effectiveColorMode(name));
const next = cycle[(idx + 1) % cycle.length];
/// Store only non-default choices, to keep the URL and history state compact.
if (next === this._defaultColorMode(name))
{
delete column_color_modes[name];
}
else
{
column_color_modes[name] = next;
}
/// The modes are shared by column name, so every visible result table that has this
/// column must be repainted (not just the clicked one) to stay in sync with the URL.
for (const el of document.querySelectorAll('query-result'))
{
el.refreshColumnColor(name);
}
persistColorModes();
}
/// Repaint one column for the current (shared) mode and refresh its header toggle title.
/// A no-op for tables that do not have this column, or that are not in normal tabular layout
/// (raw/chart/image results, or the vertical transposed layout, which has no header row).
refreshColumnColor(name)
{
if (!Array.isArray(this._header) || !this._dataTable.tHead) { return; }
const col_idx = this._columnIndex(name);
if (col_idx < 0) { return; }
this._applyColumnColor(name, col_idx);
/// Keep the active-cell categorical emphasis consistent with the new mode.
this._scheduleCategoricalEmphasis();
/// The header cells are direct `<th>` children of `<thead>` (not wrapped in a `<tr>`),
/// with the row-number header at index 0, so the column's header is `children[col_idx]`.
const toggle = this._dataTable.tHead.children[col_idx]?.querySelector('.colorize-toggle');
if (toggle) { this._updateColorizeToggleTitle(toggle, name); }
}
/// Per-type default color mode: `bar` for plain numbers, `none` for everything else
/// (including Date/DateTime, which are colorable but show no visualization by default).
_defaultColorMode(name)
{
return this._column_is_number[name] ? 'bar' : 'none';
}
/// Effective color coding mode for a column: an explicit user choice if present and supported
/// by this column's type, otherwise the per-type default. The support check matters in
/// multi-query mode, where the mode is shared by column name across tables: e.g. a numeric
/// column's `heatmap` must not leak into a string column of the same name (which would also
/// break its none <-> categorical cycle), so it falls back to the default there.
_effectiveColorMode(name)
{
if (Object.prototype.hasOwnProperty.call(column_color_modes, name))
{
const stored = column_color_modes[name];
if (this._colorModeCycle(name).includes(stored)) { return stored; }
}
return this._defaultColorMode(name);
}
/// Columns whose value can drive the bar/heatmap modes: plain numbers and Date/DateTime.
_columnSupportsValueColoring(name)
{
return this._column_is_number[name] || this._column_is_date[name];
}
/// Numeric value of a cell's displayed text. Date/DateTime text is parsed into a timestamp
/// so it can be ordered and scaled like a number; everything else is parsed as a plain number.
_parseNumeric(name, text)
{
if (this._column_is_date[name])
{
/// Parse the calendar components and combine them with `Date.UTC`, so the value is
/// independent of the viewer's browser timezone and DST (`Date.parse` would apply the
/// local zone, which can make the color scale non-monotonic). Accepts `YYYY-MM-DD` and
/// `YYYY-MM-DD HH:MM:SS[.fff]` (the time part and fractional seconds are optional).
const m = String(text).match(/^(\d+)-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?/);
if (!m) { return NaN; }
const base = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] || 0), +(m[5] || 0), +(m[6] || 0));
/// Add the fractional seconds OUTSIDE `Date.UTC`, whose millisecond argument is truncated
/// to an integer, as a fractional number of milliseconds. The result is a JS double, so
/// it is exact to roughly microsecond precision near modern timestamps (the ULP of a
/// ~1.7e12 ms value is ~2e-4 ms). That covers `Date`, `DateTime` and `DateTime64` up to
/// microseconds; `DateTime64(9)` differences below a microsecond are not distinguished
/// in the color scale, which is acceptable for a visualization (such a span is not
/// visually meaningful) and would otherwise require BigInt arithmetic throughout.
const frac_ms = m[7] ? Number('0.' + m[7]) * 1000 : 0;
return base + frac_ms;
}
return +text;
}
_columnIndex(name)
{
for (let i = 0; i < this._header.length; ++i)
{
if (this._header[i].name === name) { return i + 1; }
}
return -1;
}
/// Rows whose data cells participate in coloring: the body rows and the totals row,
/// but not the header (so a categorical column does not tint its own header cell).
_colorTargetRows()
{
let rows = [];
for (const tbody of this._dataTable.tBodies)
{
for (const row of tbody.rows) { rows.push(row); }
}
if (this._dataTable.tFoot)
{
for (const row of this._dataTable.tFoot.rows) { rows.push(row); }
}
return rows;
}
/// Min/max for a numeric column. Prefers the server-provided extremes (computed over the
/// whole result), falling back to the displayed values when the extremes are unavailable.
_numericColumnRange(name, col_idx)
{
if (this._extremes.min && this._extremes.max
&& this._extremes.min[name] !== undefined && this._extremes.max[name] !== undefined)
{
const min = this._parseNumeric(name, this._extremes.min[name]);
const max = this._parseNumeric(name, this._extremes.max[name]);
if (Number.isFinite(min) && Number.isFinite(max) && max > min) { return { min, max }; }
}
let min = Infinity;
let max = -Infinity;
for (const row of this._colorTargetRows())
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
const value = this._parseNumeric(name, (cell.querySelector('.cell-content') || cell).innerText);
if (!Number.isFinite(value)) { continue; }
if (value < min) { min = value; }
if (value > max) { max = value; }
}
return max > min ? { min, max } : null;
}
/// Deterministic hue in [0, 360) from a string, using an FNV-1a hash. Only the hue varies;
/// the lightness and chroma are fixed (see the `--viz-cell-*` variables). The full 32-bit
/// hash is mapped across the hue circle (not reduced modulo 360) to minimize collisions.
_hashHue(str)
{
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; ++i)
{
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0) / 4294967296 * 360;
}
/// Reset coloring on both layers of every cell in a column: the inner `.cell-content` div
/// (used by bar mode) and the `<td>` itself (used by heatmap/categorical to fill the whole cell).
/// Also drops any categorical same-value font-weight emphasis.
_clearColumnColor(col_idx)
{
for (const row of this._colorTargetRows())
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
cell.style.background = '';
const div = cell.querySelector('.cell-content');
if (div) { div.style.background = ''; div.style.fontWeight = ''; div.style.color = ''; }
}
}
/// Apply the current color coding mode to every cell of one column.
_applyColumnColor(name, col_idx)
{
if (col_idx < 0) { return; }
const mode = this._effectiveColorMode(name);
const rows = this._colorTargetRows();
/// Reset both layers first, so switching modes never leaves stale coloring behind.
this._clearColumnColor(col_idx);
if (mode === 'none')
{
return;
}
/// Heatmap and categorical color the whole cell: the background goes on the `<td>` (which
/// spans the full row height) rather than the inner div, so a cell shorter than a taller
/// neighbour in the same row is still fully filled.
if (mode === 'categorical')
{
for (const row of rows)
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
/// Empty strings and NULLs carry no category, so leave them uncolored. NULL is
/// detected from the original value (marked in `_renderCell`), not the rendered
/// glyph, so a real string value equal to the NULL glyph still gets a color.
if (cell.dataset.isNull !== undefined) { continue; }
const text = (cell.querySelector('.cell-content') || cell).innerText;
if (text === '') { continue; }
const hue = this._hashHue(text);
cell.style.background = `oklch(var(--viz-cell-lightness) var(--viz-cell-chroma) ${hue})`;
}
return;
}
/// The remaining modes (bar, heatmap) need a numeric range; for non-numeric columns
/// (other than Date/DateTime) there is nothing to scale, so leave the cleared coloring.
const range = this._columnSupportsValueColoring(name) ? this._numericColumnRange(name, col_idx) : null;
if (!range)
{
return;
}
if (mode === 'heatmap')
{
const span = range.max - range.min;
for (const row of rows)
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
const value = this._parseNumeric(name, (cell.querySelector('.cell-content') || cell).innerText);
if (!Number.isFinite(value)) { continue; }
const t = Math.min(1, Math.max(0, (value - range.min) / span));
/// Map low -> high values to a blue -> red hue sweep.
const hue = 260 - 235 * t;
cell.style.background = `oklch(var(--viz-cell-lightness) var(--viz-cell-chroma) ${hue})`;
}
return;
}
/// mode === 'bar' for Date/DateTime: a zero baseline is meaningless for timestamps,
/// so the bar spans the column's min..max range, with min rendered as an empty bar.
if (this._column_is_date[name])
{
const span = range.max - range.min;
for (const row of rows)
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
const div = cell.querySelector('.cell-content') || cell;
const value = this._parseNumeric(name, div.innerText);
if (!Number.isFinite(value)) { div.style.background = ''; continue; }
const ratio = Math.min(1, Math.max(0, (value - range.min) / span));
div.style.background = `linear-gradient(to right,
var(--bar-color) 0%, var(--bar-color) ${100 * ratio}%,
var(--element-background-color) ${100 * ratio}%, var(--element-background-color) 100%)`;
}
return;
}
/// mode === 'bar': a horizontal bar whose length is proportional to the value,
/// two-sided when the column has negative values.
const double_sided = range.min < 0;
const ratio_positive = range.max / (range.max - range.min);
const ratio_negative = 1 - ratio_positive;
for (const row of rows)
{
const cell = row.cells[col_idx];
if (!cell) { continue; }
const div = cell.querySelector('.cell-content') || cell;
const value = this._parseNumeric(name, div.innerText);
if (!double_sided)
{
if (value > 0)
{
const ratio = value / range.max;
div.style.background = `linear-gradient(to right,
var(--bar-color) 0%, var(--bar-color) ${100 * ratio}%,
var(--element-background-color) ${100 * ratio}%, var(--element-background-color) 100%)`;
}
else
{
div.style.background = '';
}
}
else if (value > 0)
{
const ratio = value / range.max;
div.style.background = `linear-gradient(to right,
var(--element-background-color) 0%, var(--element-background-color) ${100 * ratio_negative}%,
var(--bar-color) ${100 * ratio_negative}%, var(--bar-color) ${100 * (ratio_negative + ratio * ratio_positive)}%,
var(--element-background-color) ${100 * (ratio_negative + ratio * ratio_positive)}%, var(--element-background-color) 100%)`;
}
else if (value < 0)
{
const ratio = value / range.min;
div.style.background = `linear-gradient(to right,
var(--element-background-color) 0%, var(--element-background-color) ${100 * (1 - ratio) * ratio_negative}%,
var(--bar-color-negative) ${100 * (1 - ratio) * ratio_negative}%, var(--bar-color-negative) ${100 * ratio_negative}%,
var(--element-background-color) ${100 * ratio_negative}%, var(--element-background-color) 100%)`;
}
else
{
div.style.background = '';
}
}
}
/// Re-apply color coding for every column (after rendering or restoring a result).
applyColumnColors()
{
/// Only tabular results have a column header array; raw/chart/image results do not.
if (!Array.isArray(this._header)) { return; }
/// Vertical (transposed) single-row results do not show per-column color coding.
if (this._willTranspose()) { return; }
let col_idx = 0;
for (const column of this._header)
{
++col_idx;
this._applyColumnColor(column.name, col_idx);
}
this._scheduleCategoricalEmphasis();
}
/// Recompute the categorical emphasis after a 100 ms quiet period. Coalesces bursts of
/// selection changes (e.g. holding an arrow key) into a single recomputation.
_scheduleCategoricalEmphasis()
{
clearTimeout(this._emphasis_timer);
this._emphasis_timer = setTimeout(() => this._refreshCategoricalEmphasis(), 100);
}
/// Apply (or remove) the same-value emphasis on a single cell: a heavier font and a
/// full-contrast text color (`--text-color-active` is pure black in the light theme and pure
/// white in the dark theme). The background color is left unchanged.
_setCellEmphasis(cell, on)
{
const target = cell.querySelector('.cell-content') || cell;
target.style.fontWeight = on ? '600' : '';
target.style.color = on ? 'var(--text-color-active)' : '';
}
/// Remove the emphasis from the previously emphasized column, if any, and reset the
/// emphasis-tracking state.
_clearCategoricalEmphasis()
{
if (this._emphasized_col_idx === null) { return; }
for (const row of this._colorTargetRows())
{
const c = row.cells[this._emphasized_col_idx];
if (c) { this._setCellEmphasis(c, false); }
}
this._emphasized_col_idx = null;
this._emphasized_value = null;
this._emphasized_active_cell = null;
}
/// In categorical mode, emphasize the other cells that share the active cell's value (the
/// background colors stay unchanged). The active cell itself is left unemphasized. A no-op for
/// any other mode. Called whenever the selection changes.
_refreshCategoricalEmphasis()
{
/// The vertical (transposed) single-row layout shows no color coding (`applyColumnColors`
/// skips it), so it must not participate in categorical emphasis either. A transposed table
/// has no `tHead` (dropped by `transposeIfNeeded`), and every value cell there has
/// `cellIndex == 1`, which would otherwise be matched against the first column's stored
/// mode. Clear any leftover emphasis and bail out.
if (!this._dataTable.tHead)
{
this._clearCategoricalEmphasis();
return;
}
/// Identify the new emphasis target: the active cell's column and value, but only when it
/// is a categorical column. Otherwise there is nothing to emphasize.
const cell = this._current_selected_cell;
let col_idx = null;
let active_text = null;
if (cell && Array.isArray(this._header))
{
const idx = cell.cellIndex;
const name = this._header[idx - 1]?.name;
if (name !== undefined && this._effectiveColorMode(name) === 'categorical')
{
/// Empty strings and NULLs carry no category (mirroring `_applyColumnColor`), so
/// selecting one emphasizes nothing. NULL is detected from the original value marked
/// in `_renderCell`, not the rendered glyph, so a real string value equal to the NULL
/// glyph is still a category.
const text = (cell.querySelector('.cell-content') || cell).innerText;
if (cell.dataset.isNull === undefined && text !== '')
{
col_idx = idx;
active_text = text;
}
}
}
/// Fast path: same column and same value as before, so the set of same-value cells is
/// unchanged and only the active cell moved within it. The old active cell becomes an
/// emphasized peer, the new active cell drops back to normal — no full re-scan.
if (col_idx !== null && col_idx === this._emphasized_col_idx && active_text === this._emphasized_value)
{
if (this._emphasized_active_cell && this._emphasized_active_cell !== cell)
{
this._setCellEmphasis(this._emphasized_active_cell, true);
}
this._setCellEmphasis(cell, false);
this._emphasized_active_cell = cell;
return;
}
/// Clear the emphasis on the previously emphasized column.
this._clearCategoricalEmphasis();
if (col_idx === null) { return; }
for (const row of this._colorTargetRows())
{
const c = row.cells[col_idx];
if (!c) { continue; }
/// A real NULL peer must not match a real string equal to the NULL glyph, so exclude
/// NULL cells from grouping just like `_applyColumnColor` does. Empty cells never match
/// here because `active_text` is guaranteed non-empty above.
const target = c.querySelector('.cell-content') || c;
this._setCellEmphasis(c, c !== cell && c.dataset.isNull === undefined && target.innerText === active_text);
}
this._emphasized_col_idx = col_idx;
this._emphasized_value = active_text;
this._emphasized_active_cell = cell;
}
renderTotals()
{
if (!this._totals) return;
let table = this._dataTable;
let tfoot = document.createElement('tfoot');
let tr = document.createElement('tr');
let td = document.createElement('td');
td.className = 'row-number';
td.appendChild(document.createTextNode('\u03A3'));
tr.appendChild(td);
for (const column of this._header)
{
const td = this._renderCell(column.name, this._totals[column.name]);
tr.appendChild(td);
}
tfoot.appendChild(tr);
table.appendChild(tfoot);
}
/// A single wide row is displayed vertically (each column becomes its own row). The condition
/// is evaluated before `renderTotals`/`transposeIfNeeded`, where the DOM is identical, so
/// `applyColumnColors` can rely on it to skip coloring in this mode.
_willTranspose()
{
const table = this._dataTable;
if (this._totals) { return false; }
if (!Array.isArray(this._header) || this._header.length == 0) { return false; }
if (table.rows.length > 1) { return false; }
/// An empty result (no data rows) is shown vertically too, so `transposeIfNeeded` can
/// render its `empty result` marker; this does not depend on the column count.
if (table.rows.length == 0) { return true; }
/// A single row is shown vertically only when it is wide: more than five cells, counting
/// the leading row-number cell.
return table.rows[0].cells.length > 5;
}
transposeIfNeeded()
{
if (!this._willTranspose()) { return; }
let table = this._dataTable;
let heads = table.tHead.childNodes;
let values = table.rows[0]?.cells;
let num_cols = heads.length - 1;
let new_tbody = document.createElement('tbody');
for (let i = 0; i < num_cols; ++i)
{
let tr = document.createElement('tr');
let th = heads[1];
th.className = 'right';
th.style.width = '0';
th.removeChild(th.querySelector('.resizer'));
/// No per-column color coding in vertical mode, so drop the colorize toggle too.
const toggle = th.querySelector('.colorize-toggle');
if (toggle) { th.removeChild(toggle); }
tr.appendChild(th);
if (values)
{
let td = values[1];
td.classList.remove('right');
tr.appendChild(td);
}
else if (i == 0)
{
let td = document.createElement('td');
td.rowSpan = num_cols;
td.className = 'empty-result';
let div = document.createElement('div');
div.appendChild(document.createTextNode("empty result"));
div.className = 'empty-result';
td.appendChild(div);
tr.appendChild(td);
}
new_tbody.appendChild(tr);
}
table.removeChild(table.tHead);
table.removeChild(table.tBodies[0]);
table.appendChild(new_tbody);
}
_changeTableLayout()
{
const table = this._dataTable;
const headers = table.querySelectorAll('th');
table.style.setProperty('--table-width', `${table.offsetWidth}px`);
let columnWidths = [];
headers.forEach(header => {
columnWidths.push(header.offsetWidth);
});
/* Freeze row heights so that absolutely-positioned cell content
does not cause reflow. Read all heights first, then write,
to avoid layout thrashing. */
let rowHeights = [];
for (let row of table.rows)
{
rowHeights.push(row.offsetHeight);
}
table.classList.add('fixed');
this._dataDiv.classList.add('fixed');
headers.forEach((header, index) => {
header.style.width = `${columnWidths[index]}px`;
});
let i = 0;
for (let row of table.rows)
{
row.style.height = `${rowHeights[i++]}px`;
}
}
_appendHeaderResizer(header)
{
let drag_state = {
elem: null,
is_dragging: true,
offset_x: null,
offset_width: null
};
const start = (e) => {
if (e.button !== 0) { return; }
this._changeTableLayout();
drag_state.offset_x = e.clientX;
drag_state.offset_width = header.offsetWidth;
drag_state.is_dragging = true;
drag_state.elem = e.target;
drag_state.elem.classList.add('resizer-dragging');
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', stop);
document.addEventListener('pointercancel', stop);
};
const move = (e) => {
if (!drag_state.is_dragging) { return; }
const dx = e.clientX - drag_state.offset_x;
header.style.width = `${drag_state.offset_width + dx}px`;
if (dx < 0)
{
const table = this._dataTable;
table.style.setProperty('--table-width', `${table.offsetWidth + dx}px`);
}
};
const stop = (e) => {
drag_state.is_dragging = false;
drag_state.elem.classList.remove('resizer-dragging');
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', stop);
document.removeEventListener('pointercancel', stop);
};
const resizer = document.createElement('div');
resizer.className = 'resizer';
resizer.addEventListener('pointerdown', start);
resizer.addEventListener('touchstart', (e) => e.preventDefault());
header.appendChild(resizer);
}
renderError(message)
{
this._error.innerText = message ? message : "No response.";
this._error.style.display = 'block';
}
updateRaw(data)
{
const fragment = document.createTextNode(data);
this._dataUnparsed.appendChild(fragment);
this._dataUnparsed.style.display = 'block';
}
/// Display a binary image result (e.g. `FORMAT PNG`) instead of a table.
/// `blob` is the raw response body; we wrap it in an object URL for the `<img>` element.
renderImage(blob)
{
this._clearImage();
this._imageObjectURL = URL.createObjectURL(blob);
this._image.src = this._imageObjectURL;
this._image.style.display = 'block';
}
_clearImage()
{
if (this._imageObjectURL)
{
URL.revokeObjectURL(this._imageObjectURL);
this._imageObjectURL = null;
}
this._image.removeAttribute('src');
this._image.style.display = 'none';
}
update(json, fragment)
{
if (json.progress)
{
this._elapsed_ns = +json.progress.elapsed_ns;
this.dispatchEvent(new CustomEvent('query-progress', { detail: json.progress }));
return true;
}
else if (json.meta)
{
this.appendHeader(json.meta);
return true;
}
else if (json.row)
{
if (this._is_explain_graph
|| (this._row_idx == 0 && Object.keys(json.row).length == 1 && json.row.explain == 'digraph'
&& this._queryText.match(/^\s*EXPLAIN/i)))
{
this._is_explain_graph = true;
this._explain_graph += json.row.explain + '\n';
return true;
}
else
{
return this.appendRow(json.row, fragment);
}
}
else if (json.totals)
{
this._totals = json.totals;
return true;
}
else if (json.min)
{
this._extremes['min'] = json.min;
return true;
}
else if (json.max)
{
this._extremes['max'] = json.max;
return true;
}
else if (json.exception)
{
this.renderError(json.exception);
return true;
}
}
_handleKeyDown(e)
{
const arrows = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];
let cell = this._current_selected_cell;
if (!cell)
{
/// The table is focused (e.g. via Tab) but nothing is selected yet:
/// the first arrow key selects the first data cell so keyboard
/// navigation can begin. Other keys (including Tab) are left alone so
/// focus can move on to the next element.
if (arrows.includes(e.key))
{
const first_cell = this._dataTable.querySelector('td:not(.row-number)');
if (first_cell)
{
e.preventDefault();
this._selectCell(first_cell);
}
}
return;
}
if (e.key === 'Escape')
{
e.preventDefault();
this.clearSelection();
return;
}
const row = cell.parentElement;
const table = row.parentElement;
let cell_index = cell.cellIndex;
/// `table` is the `<tbody>`, so `table.rows` is indexed within the section;
/// use `sectionRowIndex` (not table-wide `rowIndex`, which counts the header)
/// so both sides share the same coordinate system.
let row_index = row.sectionRowIndex;
switch(e.key)
{
case 'ArrowUp': --row_index; break;
case 'ArrowDown': ++row_index; break;
case 'ArrowLeft': --cell_index; break;
case 'ArrowRight': ++cell_index; break;
default:
return;
}
if (row_index < 0 || cell_index <= 0) { return; }
let new_cell = table.rows[row_index]?.cells[cell_index];
if (!new_cell) { return; }
e.preventDefault();
cell.removeAttribute('tabindex');
this._selectCell(new_cell);
}
_loadJS(src, integrity)
{
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
if (integrity)
{
script.crossOrigin = 'anonymous';
script.integrity = integrity;
}
else if (!src.startsWith('/'))
{
console.warn('no integrity for', src);
}
script.addEventListener('load', function() { resolve(true); });
/// Reject on a failed load (blocked/unavailable CDN, network error, or SRI mismatch)
/// so awaiting callers (the chart/graph renderers in the query lifecycle) fall into
/// their `catch` and report the error instead of hanging forever - the script `error`
/// event otherwise leaves this promise pending and the run stuck in flight.
script.addEventListener('error', function() { reject(new Error('Failed to load script: ' + src)); });
document.head.appendChild(script);
});
}
async _getViz()
{
if (QueryResultElement._vizInstance) { return QueryResultElement._vizInstance; }
if (!QueryResultElement._loadVizPromise)
{
/// Drop the cached promise on failure so a later attempt can retry the load
/// instead of re-awaiting a permanently rejected promise.
QueryResultElement._loadVizPromise = this._loadJS('/js/viz-standalone.js')
.catch((e) => { QueryResultElement._loadVizPromise = null; throw e; });
}
await QueryResultElement._loadVizPromise;
if (typeof Viz === 'undefined')
{
throw new Error('Viz.js failed to load from /js/viz-standalone.js.');
}
QueryResultElement._vizInstance = await Viz.instance();
return QueryResultElement._vizInstance;
}
_loadUplot()
{
if (QueryResultElement._loadUplotPromise) { return QueryResultElement._loadUplotPromise; }
QueryResultElement._loadUplotPromise = this._loadJS('https://cdn.jsdelivr.net/npm/[email protected]/dist/uPlot.iife.min.js',
'sha384-TwdJPnTsKP6pnvFZZKda0WJCXpjcHCa7MYHmjrYDu6rsEsb/UnFdoL0phS5ODqTA')
/// Drop the cached promise on failure so a later attempt can retry the load
/// instead of re-awaiting a permanently rejected promise.
.catch((e) => { QueryResultElement._loadUplotPromise = null; throw e; });
return QueryResultElement._loadUplotPromise;
}
/// `isValid` (optional) is checked right after the async Viz load and before any DOM
/// mutation, so a tab switch during the first (slow) load cannot let this stale render
/// paint into the now-active tab's shared result element.
async renderGraph(isValid)
{
this._clearElement(this._dataTable);
const viz = await this._getViz();
if (isValid && !isValid()) return;
const svg = viz.renderSVGElement(this._explain_graph);
this._graph.replaceChildren(svg);
this._graph.style.display = 'block';
}
_legendAsTooltipPlugin({ className, style = {} } = {})
{
let legendEl;
let multiline;
function init(u, opts)
{
legendEl = u.root.querySelector(".u-legend");
legendEl.classList.remove("u-inline");
className && legendEl.classList.add(className);
uPlot.assign(legendEl.style, {
textAlign: "right",
pointerEvents: "none",
display: "none",
position: "absolute",
left: 0,
top: 0,
zIndex: 100,
boxShadow: "2px 2px 10px rgba(0, 0, 0, 0.1)",
...style
});
const nodes = legendEl.querySelectorAll("th");
for (let i = 0; i < nodes.length; i++)
nodes[i]._order = i;
if (opts.series.length == 2)
{
multiline = false;
for (let i = 0; i < nodes.length; i++)
nodes[i].style.display = "none";
}
else
{
multiline = true;
legendEl.querySelector("th").remove();
legendEl.querySelector("td").setAttribute('colspan', '2');
legendEl.querySelector("td").style.textAlign = 'center';
let footer = legendEl.insertRow().insertCell();
footer.setAttribute('colspan', '2');
footer.style.textAlign = 'center';
footer.classList.add('u-value');
footer.parentNode.classList.add('u-series','footer');
footer.textContent = ". . .";
}
const overEl = u.over;
overEl.style.overflow = "visible";
overEl.appendChild(legendEl);
overEl.addEventListener("mouseenter", () => {legendEl.style.display = null;});
overEl.addEventListener("mouseleave", () => {legendEl.style.display = "none";});
}
function nodeListToArray(nodeList)
{
return Array.prototype.slice.call(nodeList);
}
function update(u)
{
let { left, top } = u.cursor;
left -= legendEl.clientWidth * (left / u.width);
if (top >= legendEl.clientHeight)
{
top -= legendEl.clientHeight;
}
legendEl.style.transform = "translate(" + left + "px, " + top + "px)";
if (multiline)
{
let nodes = nodeListToArray(legendEl.querySelectorAll("tr"));
let header = nodes.shift();
let footer = nodes.pop();
let showLimit = Math.floor(u.height / 30);
nodes.forEach(function (node) { node._sort_key = nodes.length > showLimit ? +node.querySelector("td").textContent.replace(/,/g,'') : node._order; });
nodes.sort((a, b) => b._sort_key - a._sort_key);
nodes.forEach(function (node) { node.parentNode.appendChild(node); });
for (let i = 0; i < nodes.length; i++)
{
nodes[i].style.display = i < showLimit ? null : "none";
}
footer.parentNode.appendChild(footer);
footer.style.display = nodes.length > showLimit ? null : "none";
}
}
return {
hooks: {
init: init,
setCursor: update,
}
};
}
/// `isValid` (optional) is checked right after the async uPlot load and before any DOM
/// mutation, so a tab switch during the first (slow) load cannot let this stale render
/// paint into the now-active tab's shared result element.
async renderChart(json, isValid)
{
await this._loadUplot();
if (isValid && !isValid()) return;
if (this._uplot)
{
this._uplot.destroy();
this._uplot = null;
}
let chart = this._chart;
chart.style.display = 'block';
let paths = json[0].length < chart.clientWidth / 5 ? uPlot.paths.bars() : uPlot.paths.linear();
const [line_color, fill_color, grid_color, axes_color] = theme == 'light'
? ["rgba(255, 0, 200, 100%)", "rgba(255, 128, 200, 50%)", "#CCC", "#444"]
: ["rgba(255, 255, 0, 100%)", "rgba(255, 255, 100, 50%)", "#444", "#CCC"];
const opts = {
width: chart.clientWidth,
height: chart.clientHeight,
scales: { x: { time: json[0][0] > 1000000000 && json[0][0] < 2000000000 } },
axes: [ { stroke: axes_color,
grid: { width: 1 / devicePixelRatio, stroke: grid_color },
ticks: { width: 1 / devicePixelRatio, stroke: grid_color } },
{ stroke: axes_color,
grid: { width: 1 / devicePixelRatio, stroke: grid_color },
ticks: { width: 1 / devicePixelRatio, stroke: grid_color } } ],
series: [ { label: "x" },
{ label: "y", stroke: line_color, fill: fill_color, paths, points: { show: false } } ],
padding: [ null, null, null, (Math.ceil(Math.log10(Math.max(...json[1]))) + Math.floor(Math.log10(Math.max(...json[1])) / 3)) * 6 ],
plugins: [ this._legendAsTooltipPlugin() ],
};
this._uplot = new uPlot(opts, json, chart);
}
_resizeChart()
{
if (this._uplot)
{
let chart = this._chart;
this._uplot.setSize({ width: chart.clientWidth, height: chart.clientHeight });
}
}
redrawChart()
{
if (this._uplot && this._chart.style.display == 'block')
{
this.renderChart(this._uplot.data);
}
}
}
customElements.define('query-result', QueryResultElement);
/// Incremental request number. When response is received,
/// if its request number does not equal to the current request number, response will be ignored.
/// This is to avoid race conditions.
let request_num = 0;
/// While a tab activation is rebuilding the active tab's parameter inputs, this holds the
/// activation's request token. `restoreFromHistory` sets it right before the `updateQueryParams`
/// await (the `param_*` DOM inputs still hold the PREVIOUS tab's values during that await) and
/// clears it once the restored parameters are committed. `captureActiveTab` uses it to avoid
/// snapshotting the stale inputs into the newly-active tab while the restore is in flight.
let params_restore_pending_token = null;
/// Tabs let the user keep several independent queries side by side. Each tab owns its
/// query text, title, query parameters and the last result snapshot. The connection
/// settings (url/user/password) are global and shared across all tabs.
/// We keep a SINGLE editor and a SINGLE result element and swap their content when the
/// active tab changes, rather than maintaining a DOM tree per tab.
let tabs = []; /// array of tab objects; display order == array order
let activeTabId = null;
let tabSeq = 0; /// monotonic counter for synthetic, stable tab ids
let tabTitleSeq = 0; /// counter for default titles "Query A", "Query B", ...
let dbReady = null; /// Promise<IDBDatabase|null>; null result means persistence is unavailable
/// The page is interactive from first paint (the bootstrap tab holds the URL query, if any),
/// but the saved workspace is only read from IndexedDB asynchronously in `reconcileStartup`.
/// If the user types/pastes a query, creates a tab, or starts a run during that window, the
/// live in-memory state is NEWER than the saved one, and the restore must NOT clobber it.
/// `bootstrap_dirty` records such interaction; `bootstrap_settled` is set once reconciliation
/// takes over, after which these signals are irrelevant (and reconciliation's own synthetic
/// edits/runs must not set the flag).
let bootstrap_dirty = false;
let bootstrap_settled = false;
function markBootstrapDirty(e) {
/// Only genuine user actions count. The page programmatically dispatches input events and
/// calls run helpers during bootstrap/reconciliation; those carry `isTrusted === false`
/// (or arrive after `bootstrap_settled`) and must be ignored.
if (bootstrap_settled) return;
if (e && e.isTrusted === false) return;
bootstrap_dirty = true;
}
/// Start of the last query
let last_query_start = 0;
const current_url = new URL(window.location);
const opened_locally = location.protocol == 'file:';
/// Run query instantly after page is loaded if the run parameter is present.
/// Mutable: a stale reload (the URL hash is older than a persisted, never-run local edit)
/// drops this directive so the edit is not auto-run just because the old URL carried `run=1`.
let run_immediately = current_url.searchParams.has("run");
/// Restore per-column color coding modes from the URL, so a shared/bookmarked link keeps them.
const color_modes_from_url = current_url.searchParams.get('color_modes');
if (color_modes_from_url) {
try { column_color_modes = JSON.parse(color_modes_from_url) || {}; } catch (e) { /* ignore malformed */ }
}
let url_elem = document.getElementById('url');
let user_elem = document.getElementById('user');
let password_elem = document.getElementById('password');
let progressEl = document.getElementById('query-progress');
let resultEl = document.getElementById('query-result');
let logoEl = document.getElementById('logo-container');
resultEl.addEventListener('query-progress', (e) => {
elapsed_ns = +e.detail.elapsed_ns;
progressEl.updateProgress(e.detail);
progressEl.updateText(resultEl.rowCount, elapsed_ns, resultEl.incompleteResult);
});
if (url_elem.value == '') {
const server_address = current_url.searchParams.get('url');
if (server_address) {
url_elem.value = server_address;
} else if (!opened_locally) {
/// Substitute the address of the server where the page is served.
url_elem.value = location.origin;
} else {
url_elem.value = 'http://localhost:8123/';
}
}
/// Substitute username if it's specified in the query string
const user_from_url = current_url.searchParams.get('user');
if (user_from_url) {
user_elem.value = user_from_url;
}
const pass_from_url = current_url.searchParams.get('password');
if (pass_from_url) {
password_elem.value = pass_from_url;
/// Browsers don't allow manipulating history for the 'file:' protocol.
if (!opened_locally) {
let replaced_pass = current_url.searchParams;
replaced_pass.delete('password');
window.history.replaceState(null, '',
window.location.origin + window.location.pathname + '?'
+ replaced_pass.toString() + window.location.hash);
}
}
async function ping(url) {
try {
let response = await fetch(new URL(url).origin + "?query", { method: 'OPTIONS', headers: { 'Authorization': 'never' } });
return response.ok;
} catch (e) {
return false;
}
}
let ping_request_num = 0;
function checkURL() {
++ping_request_num;
const current_ping_request_num = ping_request_num;
let elem = document.getElementById('url_status');
elem.className = '';
document.getElementById('server_info').innerText = '';
ping(url_elem.value).then(status => {
if (current_ping_request_num == ping_request_num) {
elem.className = status ? 'ok' : 'fail';
if (status) {
checkCredentials();
}
}
});
};
url_elem.addEventListener('input', checkURL);
checkURL();
/// The `/docs` page lives on the ClickHouse server addressed by `url_elem.value`, which may differ from
/// the page's own origin when `/play` is opened locally (`file://`) or pointed at a remote server.
/// Resolve the Documentation link against that configured server (like `getTerminalURL` does for the Web
/// Terminal) instead of leaving it hardcoded to `/docs`, and keep it in sync as the URL input changes.
{
const docs_icon = document.getElementById('docs-icon');
function updateDocsURL() {
try {
docs_icon.href = new URL('/docs', url_elem.value).href;
} catch (e) {
/// Leave the previous href in place while the configured URL is not parsable.
}
}
url_elem.addEventListener('input', updateDocsURL);
updateDocsURL();
}
/// `quiet` runs a background panel-maintenance query without touching the main result pane:
/// a failure must not wipe the current result and replace it with this query's error, and a
/// success must not clear an error the user's own query left on screen. It is used for the
/// automatic database-restore loads that fire merely because the panel was reopened; explicit
/// panel actions keep reporting their errors in the result pane as before.
///
/// `generation` is the `connectionGeneration` captured by the caller before the request was
/// started. Even a non-quiet request may finish after the connection settings changed (which
/// bumps `connectionGeneration`); reporting its result would then clear or overwrite the main
/// result pane with a response from the previous connection, so the result-pane side effects are
/// skipped once the generation no longer matches. Pass `null` for requests that are not
/// connection-scoped and should always report.
async function auxiliaryQuery(server_address, user, password, query, url_params = '', quiet = false, generation = null) {
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1&default_format=JSON' + url_params;
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
/// Whether this request still belongs to the active connection. Re-evaluated after the
/// `await` (not captured up front) because the connection can change while the fetch is in
/// flight; a stale request must not touch the shared result pane.
const belongs_to_active_connection = () => generation === null || generation === connectionGeneration;
let response;
try {
response = await fetch(url, { method: "POST", body: query, headers: { 'Authorization': 'never' } });
} catch (e) {
if (!quiet && belongs_to_active_connection()) {
clear();
resultEl.renderError("Network error");
logoEl.style.display = 'none';
}
return false;
}
if (!response.ok) {
if (!quiet && belongs_to_active_connection()) {
clear();
resultEl.renderError(await response.text());
logoEl.style.display = 'none';
}
return false;
}
if (!quiet && belongs_to_active_connection()) {
resultEl.clearError();
logoEl.style.display = 'block';
}
return await response.json();
}
async function getServerStatus(server_address, user, password) {
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1&default_format=JSONEachRow';
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
try {
let response = await fetch(url, { method: "POST", body: "SELECT version() AS v, uptime() AS t", headers: { 'Authorization': 'never' } });
if (!response.ok) return false;
json = await response.json();
return json;
} catch (e) {
return false;
}
}
/// Names of databases that the user has expanded. Persisted across panel
/// open/close so that reopening the panel restores the previously expanded
/// databases (those that still exist).
let opened_databases = new Set();
let load_databases_request_num = 0;
async function loadDatabases(server_address, user, password) {
++load_databases_request_num;
const current_load_databases_request_num = load_databases_request_num;
/// The connection settings (url/user/password) can change while this `system.databases`
/// query is in flight; `resetConnectionScopedState` then bumps `connectionGeneration`.
/// Capture it now so a response for the previous connection is discarded instead of
/// filling the panel with the old server's databases and opening it: every row handler
/// created below closes over this connection, so a stale result would keep querying the
/// old server until the panel is closed and reopened.
const generation = connectionGeneration;
let json = await auxiliaryQuery(server_address, user, password,
`SELECT database, database = currentDatabase() AS current
FROM system.databases WHERE database != 'INFORMATION_SCHEMA'
ORDER BY
database = 'information_schema',
database = 'system',
database != 'default',
database`,
'', false, generation);
if (!json) return false;
if (current_load_databases_request_num != load_databases_request_num) return false;
if (generation !== connectionGeneration) return false;
let databases_elem = document.getElementById('databases');
while (databases_elem.firstChild) databases_elem.removeChild(databases_elem.firstChild);
// Use natural order for database names
json.data.sort((a, b) => {
// Maintain original order for default, system, information_schema
const getPriority = (name) => {
switch (name) {
case 'default': return 0;
case 'system': return 2;
case 'information_schema': return 3
default: return 1;
}
}
return getPriority(a.database) - getPriority(b.database)
|| a.database.localeCompare(b.database, undefined, { numeric: true });
});
for (let elem of json.data) {
let database = document.createElement('div');
database.className = 'database';
let database_link = document.createElement('button');
if (elem.current) database_link.className = 'current';
database_link.append(elem.database);
database.appendChild(database_link);
let tables = document.createElement('div');
tables.className = 'tables';
database.appendChild(tables);
databases_elem.appendChild(database);
/// Refresh button shown to the right of the database name while it is open.
/// Clicking it reloads the list of tables without collapsing the database.
let refresh_button = document.createElement('button');
refresh_button.className = 'database-refresh';
refresh_button.type = 'button';
refresh_button.title = 'Refresh the list of tables';
refresh_button.textContent = '\u21BB';
/// A per-row generation counter, bumped on every (re)load and on collapse. An in-flight
/// load captures the current value and, once its query returns, checks whether the row's
/// state changed in the meantime (it was collapsed, a newer load superseded it, or the
/// whole panel was closed) so it can bail out before mutating the DOM. This invariant is
/// local to one database row, complementing the per-container token in `loadTables`.
let row_load_generation = 0;
/// Reload the tables of this database, showing the hourglass (and hiding the refresh
/// button) while loading. `set_current` moves the current-database highlight to this row
/// (a manual open or refresh); restoring the previously expanded state passes false so
/// the highlight stays on the actual current database.
const refreshTables = (set_current = true) => {
const generation = ++row_load_generation;
/// A load stays relevant only while its generation is still the latest for this row
/// and the row is still attached to the document. Closing the whole database panel
/// (or reloading the database list) detaches the row without bumping the generation,
/// so the `isConnected` check is what makes such a stale load drop quietly instead of
/// dereferencing a now-detached parent when its query returns.
const is_current = () => generation == row_load_generation && database.isConnected;
refresh_button.style.visibility = 'hidden';
let hourglass = document.createElement('span');
hourglass.className = 'loading-hourglass';
hourglass.textContent = '\u29D7';
database_link.after(hourglass);
return loadTables(server_address, user, password, elem.database, database, set_current, is_current).then(ok => {
hourglass.remove();
/// Only restore the button while this load is still the current one for the row;
/// otherwise the row was collapsed (the button is detached) or a newer refresh owns it.
if (is_current()) refresh_button.style.visibility = '';
return ok;
});
};
refresh_button.addEventListener('click', e => {
e.stopPropagation();
if (database.dataset['opened']) refreshTables();
});
/// Open a collapsed database, or restore a previously expanded one on panel reopen.
/// The database is remembered for the next restore synchronously, as soon as the open
/// starts, rather than after the `system.tables` load succeeds: otherwise closing the
/// whole panel while the load is still in flight detaches the row, `loadTables` bails out
/// through its `isConnected` check, and the user's just-performed open would be forgotten,
/// so reopening the panel would show it collapsed. Once the load settles, the set is
/// reconciled with the row's actual state (see below): a brand-new open that failed is
/// rolled back so a database that never actually expanded is not auto-retried on every
/// panel reopen, but a database that was already remembered before this open began is
/// kept, so a transient restore failure does not forget a previously expanded database.
/// The entry is otherwise removed only on an explicit collapse or a connection change
/// (`resetConnectionScopedState`, which also clears the set and closes the panel, so a
/// stale load can no longer resurrect it). On success the row is marked open and the
/// refresh button is attached. Restoring passes set_current = false so the highlight is
/// left alone.
const openDatabase = (set_current = true) => {
const was_remembered = opened_databases.has(elem.database);
opened_databases.add(elem.database);
return refreshTables(set_current).then(ok => {
if (ok !== false) {
database.dataset['opened'] = 1;
database_link.after(refresh_button);
}
/// Reconcile the restore set with what is actually on screen while the row is
/// still attached: a genuine success (re-)adds it even if a superseded sibling
/// load of the same row rolled it back just before. A failed open never expanded
/// the row, so it is dropped to avoid auto-retrying it on the next panel reopen —
/// but only if it was not already remembered when this open began: a restore (or
/// a re-open) of a previously expanded database must survive a transient failure,
/// otherwise one failed restore would forget a database the user never collapsed.
/// A panel close instead detaches the row (`isConnected === false`), leaving a
/// just-performed open in the set so it is still restored on reopen.
if (database.isConnected) {
if (database.dataset['opened']) opened_databases.add(elem.database);
else if (!was_remembered) opened_databases.delete(elem.database);
}
return ok;
});
};
database_link.addEventListener('click', e => {
if (!database.dataset['opened']) {
openDatabase();
} else {
/// Collapse: invalidate any in-flight refresh so its late result cannot
/// repopulate the table list of this now-closed row.
++row_load_generation;
delete database.dataset['opened'];
opened_databases.delete(elem.database);
refresh_button.remove();
while (tables.firstChild) tables.removeChild(tables.firstChild);
let count_elem = database_link.querySelector('.database-table-count');
if (count_elem) count_elem.remove();
}
});
/// Restore the expanded state from before the panel was last closed,
/// without moving the current-database highlight.
if (opened_databases.has(elem.database)) {
openDatabase(false);
}
}
}
function makeSVG(viewBox, ...paths)
{
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', viewBox);
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
svg.classList.add('table-icon');
for (const d of paths)
{
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
svg.appendChild(path);
}
return svg;
}
function getTableIcon(engine)
{
const e = engine.replace(/^Replicated|^Shared/, '');
/// Aggregating/Summing - sigma
if (/^(Summing|Aggregating)MergeTree/.test(e))
{
const svg = makeSVG('0 0 24 24',
'M18 4 H6', // top bar
'M6 4 L16 12', // upper diagonal
'M16 12 L6 20', // lower diagonal
'M6 20 H18' // bottom bar
);
return svg;
}
/// Memory - microchip
if (e === 'Memory')
{
const svg = makeSVG('0 0 24 24',
'M6 6 h12 v12 h-12 z', // chip body
'M9 6 v-2', 'M12 6 v-2', 'M15 6 v-2', // top pins
'M9 18 v2', 'M12 18 v2', 'M15 18 v2', // bottom pins
'M6 10 h-2', 'M6 14 h-2', // left pins
'M18 10 h2', 'M18 14 h2' // right pins
);
return svg;
}
/// View - eye
if (e === 'View')
{
const svg = makeSVG('0 0 24 24',
'M1 12 C5 5, 19 5, 23 12 C19 19, 5 19, 1 12 Z', // eye outline
'M12 9 a3 3 0 1 0 0 6 a3 3 0 1 0 0 -6' // pupil
);
return svg;
}
/// MaterializedView - eye over table
if (e === 'MaterializedView')
{
const svg = makeSVG('0 0 24 24',
'M1 8 C5 2, 19 2, 23 8 C19 14, 5 14, 1 8 Z', // eye
'M12 5.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0 -5', // pupil
'M4 17 h16', 'M4 21 h16' // table lines
);
return svg;
}
/// Merge - three overlaid tables
if (e === 'Merge')
{
const svg = makeSVG('0 0 24 24',
'M8 8 h14 v14 h-14 z', 'M8 12 h14', // back table (bottom-right)
'M5 5 h14 v14 h-14 z', 'M5 9 h14', // middle table
'M2 2 h14 v14 h-14 z', 'M2 6 h14' // front table (top-left)
);
svg.setAttribute('fill', 'var(--element-background-color)');
return svg;
}
/// Distributed - fanout
if (e === 'Distributed')
{
const svg = makeSVG('0 0 24 24',
'M2 9.5 h6 v5 h-6 z', // source box
'M16 1 h6 v5 h-6 z', // target top
'M16 9.5 h6 v5 h-6 z', // target middle
'M16 18 h6 v5 h-6 z', // target bottom
'M8 12 L16 3.5', // arrow top
'M8 12 L16 12', // arrow middle
'M8 12 L16 20.5' // arrow bottom
);
return svg;
}
/// StorageProxy - arrow between two boxes
if (e === 'Proxy')
{
const svg = makeSVG('0 0 24 24',
'M1 7 h6 v10 h-6 z', // left box
'M17 7 h6 v10 h-6 z', // right box
'M7 12 h10', // arrow line
'M14 9 l3 3 l-3 3' // arrowhead
);
return svg;
}
/// Default MergeTree family - table
const svg = makeSVG('0 0 24 24',
'M3 4 h18 v16 h-18 z', // border
'M3 9 h18', // header line
'M3 14 h18', // middle line
'M10 9 v11' // column divider
);
return svg;
}
/// Per-container staleness token. Several databases can be loading their tables
/// at the same time (e.g. when restoring previously expanded databases), so the
/// guard must be scoped to each container: a load is only superseded by a newer
/// load of the same database, not by a concurrent load of a different one.
let load_tables_request_nums = new WeakMap();
/// The current-database highlight is a singleton, so the per-container token above is not
/// enough to protect it: it tracks the latest manual database selection. If the user opens
/// `a` and then `b` before `a` finishes loading, the highlight must end up on `b` regardless
/// of which load's query returns first - otherwise a slower earlier open would steal it back.
let current_selection_num = 0;
async function loadTables(server_address, user, password, database, container, set_current = true, is_current = null) {
const request_num = (load_tables_request_nums.get(container) || 0) + 1;
load_tables_request_nums.set(container, request_num);
/// Capture the latest-selection token synchronously, before awaiting, so that the most
/// recent click wins regardless of which load's query returns first. Restoring the
/// expanded state passes set_current = false and must not move the highlight.
const selection_num = set_current ? ++current_selection_num : 0;
/// Capture the connection generation before awaiting: an explicit (non-quiet) open/refresh
/// whose connection is replaced while its `system.tables` query is in flight must not report
/// its result into the main result pane (see `auxiliaryQuery`).
const generation = connectionGeneration;
/// A restore load (set_current = false) is a background panel-maintenance query triggered by
/// merely reopening the panel, so it runs quietly: it must not overwrite the main result pane
/// with its own success or error. An explicit open/refresh (set_current = true) keeps
/// surfacing its error as before.
let json = await auxiliaryQuery(server_address, user, password,
`SELECT table, engine, total_rows, total_bytes FROM system.tables WHERE database = {database:String}
ORDER BY table LIKE '.inner%', table`,
'&param_database=' + encodeURIComponent(database),
!set_current, generation);
if (!json) return false;
if (request_num != load_tables_request_nums.get(container)) return false;
/// The row this load targets may have been collapsed or superseded by a newer refresh while
/// the query was in flight; if so, bail out before touching the DOM so we never repopulate
/// the table list of a row that is no longer open.
if (is_current && !is_current()) return false;
/// A user click marks the database as current; only move the highlight if this is still
/// the user's latest selection, and never move it away from currentDatabase() on restore.
if (set_current && selection_num == current_selection_num) {
let current_elem = container.parentElement.querySelector('.current');
if (current_elem) current_elem.classList.remove('current');
container.firstChild.classList.add('current');
}
let tables_elem = container.querySelector('.tables');
while (tables_elem.firstChild) tables_elem.removeChild(tables_elem.firstChild);
/// Show the number of tables in parentheses next to the database name, e.g. `default (123)`.
let database_link = container.firstChild;
let count_elem = database_link.querySelector('.database-table-count');
if (!count_elem) {
count_elem = document.createElement('span');
count_elem.className = 'database-table-count';
database_link.appendChild(count_elem);
}
count_elem.textContent = ` (${json.data.length})`;
let max_total_bytes = Math.max(...json.data.map(elem => +elem.total_bytes));
// Use natural order for table names; .inner tables go last
json.data.sort((a, b) => a.table.startsWith('.inner') - b.table.startsWith('.inner')
|| a.table.localeCompare(b.table, undefined, { numeric: true }));
for (let elem of json.data) {
let table = document.createElement('div');
table.className = 'table';
let table_link = document.createElement('button');
/// Wrap the icon+name so the balloon opens only when hovering the name, not the
/// empty right part of the full-width button.
let table_name = document.createElement('span');
table_name.className = 'table-name';
table_name.append(getTableIcon(elem.engine), elem.table);
table_link.appendChild(table_name);
if (max_total_bytes && +elem.total_bytes) {
const percent = Number(100 * +elem.total_bytes / max_total_bytes).toFixed(2);
table.style.backgroundImage = `linear-gradient(to right,
var(--table-size-bar-color) 0%,
var(--table-size-bar-color) ${percent}%,
transparent ${percent}%)`;
}
let table_addendum = document.createElement('span');
table_addendum.className = 'table-addendum';
const set_query_or_run = (e, value) => {
if (query_area.value == value) {
/// Double click will run the query.
postOne();
} else {
query_area.value = value;
query_area.dispatchEvent(new Event('input'));
}
/// #main is the scroll container (overflow: auto), so scroll it to bring the query area into view.
document.getElementById('main').scrollTo(0, 0);
e.stopPropagation();
}
const show_table_properties = e => set_query_or_run(e, `SELECT * FROM system.tables WHERE database = '${database}' AND table = '${elem.table}'`);
if (+elem.total_bytes) {
let bytes_elem = document.createElement('span');
bytes_elem.className = 'table-addendum-elem';
bytes_elem.textContent = formatReadableBytes(elem.total_bytes) + '\n';
bytes_elem.addEventListener('click', show_table_properties);
table_addendum.append(bytes_elem);
}
if (+elem.total_rows) {
let rows_elem = document.createElement('span');
rows_elem.className = 'table-addendum-elem';
rows_elem.textContent = formatReadableRows(elem.total_rows) + ' rows\n';
rows_elem.addEventListener('click', show_table_properties);
table_addendum.append(rows_elem);
}
let engine_elem = document.createElement('span');
engine_elem.textContent = elem.engine;
engine_elem.className = 'table-addendum-elem';
engine_elem.addEventListener('click', e => set_query_or_run(e, `SHOW TABLE "${database}"."${elem.table}"`));
table_addendum.append(engine_elem);
/// Make the balloon links reachable from the keyboard (Right arrow from the row).
for (const link of table_addendum.querySelectorAll('.table-addendum-elem')) link.tabIndex = -1;
table_name.addEventListener('mouseenter', () => positionTableBalloon(table));
/// Show the balloon when the row is focused via keyboard navigation, too.
table_link.addEventListener('focus', () => positionTableBalloon(table));
/// Keep the addendum inside the button so the `.table-name ~ .table-addendum` hover
/// selector works; it is position:fixed, so it is not clipped by the button overflow.
table_link.appendChild(table_addendum);
table.appendChild(table_link);
table.addEventListener('click', e => set_query_or_run(e, `SELECT * FROM "${database}"."${elem.table}" LIMIT 100`));
tables_elem.appendChild(table);
}
if (json.data.length == 0) {
let table = document.createElement('div');
table.className = 'no-tables';
table.append('no tables');
tables_elem.append(table);
}
}
let databases_toggle = document.getElementById('databases-toggle');
let menu_elem = document.getElementById('menu');
/// Hide the currently visible table-info balloon on any scroll: it uses
/// position:fixed and would detach from its anchor. Use capture phase so the
/// listener fires regardless of which element scrolls (scroll events do not
/// bubble). Only one balloon can be open at a time (the one the cursor most
/// recently entered), so we track that reference instead of scanning the DOM.
///
/// Also set `suppress-balloons` on the body until the next mousemove, so a
/// different table button scrolled under a stationary cursor cannot reveal its
/// balloon at an unset position via the CSS `:hover` rule (`mouseenter` does
/// not fire without actual mouse movement, so JS positioning would be skipped).
let visible_balloon = null;
function hideTableBalloons() {
if (visible_balloon) {
visible_balloon.style.visibility = 'hidden';
visible_balloon = null;
}
document.body.classList.add('suppress-balloons');
}
/// Anchor a table's balloon to the end of its name (clamped to the button when the
/// name is truncated), so it sits next to the text rather than the far-right edge.
/// Used on hover, on keyboard focus, and to keep it glued to the row across scrolls.
function positionTableBalloon(table) {
const table_name = table.querySelector('.table-name');
const table_link = table.querySelector('button');
const table_addendum = table.querySelector('.table-addendum');
if (!table_name || !table_addendum) return;
const rect = table_name.getBoundingClientRect();
const btn = table_link.getBoundingClientRect();
table_addendum.style.left = Math.min(rect.right, btn.right) + 'px';
table_addendum.style.top = (rect.top + rect.height / 2) + 'px';
table_addendum.style.visibility = '';
visible_balloon = table_addendum;
}
document.addEventListener('scroll', () => {
/// When a table row (or one of its balloon links) is focused via the keyboard,
/// keep its balloon glued to the row across the scroll instead of hiding it.
const focused_table = document.activeElement?.closest?.('.table');
if (focused_table && document.getElementById('databases').contains(focused_table)) {
/// Still suppress hover-opened balloons on *other* rows: a different table
/// button scrolled under a stationary cursor would otherwise reveal its
/// balloon at an unset position via the CSS `:hover` rule (`mouseenter` does
/// not fire without mouse movement). The focused row's balloon is exempted
/// from `suppress-balloons` in CSS, so it stays glued to the row.
document.body.classList.add('suppress-balloons');
positionTableBalloon(focused_table);
} else {
hideTableBalloons();
}
}, { capture: true, passive: true });
document.addEventListener('mousemove', () => {
/// Guard so the no-op case does not touch classList on every mouse pixel.
if (document.body.classList.contains('suppress-balloons')) {
document.body.classList.remove('suppress-balloons');
}
}, { passive: true });
/// Close the database panel and remove every row. Detaching the rows also drops any in-flight
/// `loadTables` load through its `isConnected` check, so a late response cannot repopulate a
/// panel that is no longer shown.
function closeDatabases() {
delete databases_toggle.dataset['opened'];
menu_elem.style.cursor = 'e-resize';
menu_elem.classList.remove('databases-opened');
let databases_elem = document.getElementById('databases');
while (databases_elem.firstChild) databases_elem.removeChild(databases_elem.firstChild);
}
function toggleDatabases() {
if (!databases_toggle.dataset['opened']) {
loadDatabases(url_elem.value, user_elem.value, password_elem.value).then(ok => {
if (ok !== false) {
databases_toggle.dataset['opened'] = 1;
menu_elem.style.cursor = 'w-resize';
menu_elem.classList.add('databases-opened');
}
});
} else {
closeDatabases();
}
}
/// The logo is an <a> element so that Ctrl/Cmd/Shift+click and middle-click
/// natively open the current page (with the query) in a new tab.
/// We update the href on mousedown so it always reflects the current URL.
document.getElementById('databases-icon').addEventListener('mousedown', () => {
document.getElementById('databases-icon').href = window.location.href;
});
databases_toggle.addEventListener('click', e => {
e.stopPropagation();
/// Let the browser handle modifier clicks as a link (open in new tab).
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
/// Normal click: toggle the databases panel instead of following the link.
e.preventDefault();
toggleDatabases();
});
/// The icon is an <a>, which the browser activates with Enter (following the
/// link) but not with Space. Handle both keys here so it can be toggled from the
/// keyboard once focused via Tab, instead of navigating away.
document.getElementById('databases-icon').addEventListener('keydown', e => {
if (e.key !== ' ' && e.key !== 'Enter') return;
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
e.preventDefault();
toggleDatabases();
});
/// The "hide panel" button is only visible while the panel is open, so it always closes it.
document.getElementById('databases-hide').addEventListener('click', e => {
e.stopPropagation();
toggleDatabases();
});
/// Keyboard navigation inside the database panel. The event bubbles here from the
/// focused element.
/// - Up/Down move focus between the rows (databases and expanded tables),
/// mirroring Tab/Shift+Tab but staying within the panel.
/// - Right enters the focused table's balloon (its size/rows/engine links);
/// inside the balloon Up/Down move between links, Left returns to the row,
/// and Enter/Space activate the focused link.
document.getElementById('databases').addEventListener('keydown', e => {
const active = document.activeElement;
/// Within a table's balloon.
if (active.classList && active.classList.contains('table-addendum-elem')) {
const links = [...active.parentElement.querySelectorAll('.table-addendum-elem')];
const i = links.indexOf(active);
if (e.key === 'ArrowDown') { e.preventDefault(); links[Math.min(i + 1, links.length - 1)].focus({ preventScroll: true }); }
else if (e.key === 'ArrowUp') { e.preventDefault(); links[Math.max(i - 1, 0)].focus({ preventScroll: true }); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); active.closest('.table').querySelector('button').focus(); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); active.click(); }
return;
}
/// Right arrow on a table row jumps into its balloon links.
if (e.key === 'ArrowRight') {
const link = active.querySelector && active.querySelector('.table-addendum-elem');
if (link) { e.preventDefault(); link.focus({ preventScroll: true }); }
return;
}
/// Up/Down move focus between the rows.
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
const buttons = [...document.getElementById('databases').querySelectorAll('button')];
if (buttons.length === 0) return;
e.preventDefault();
let index = buttons.indexOf(active);
if (e.key === 'ArrowDown') {
index = index < 0 ? 0 : Math.min(index + 1, buttons.length - 1);
} else {
index = index < 0 ? buttons.length - 1 : Math.max(index - 1, 0);
}
buttons[index].focus();
});
document.getElementById('menu').addEventListener('click', e => {
if (e.ctrlKey || e.metaKey || e.shiftKey) return;
if (!e.target.closest('#github-icon') && !e.target.closest('#databases-toggle') && !e.target.closest('button')) {
toggleDatabases();
}
});
function formatUptime(t) {
if (t < 60) { return t + " sec" }
if (t < 3600) { return Number(t / 60).toFixed() + " min" }
if (t < 86400) { return Number(t / 3600).toFixed() + " hr" }
const days = Number(t / 86400).toFixed();
return days + " day" + (days > 1 ? 's' : '');
}
/// The version/uptime indicator (`#server_info`) floats over the right end of the url input.
/// A short hostname leaves it clearly visible, but a long one would overlap it into an illegible
/// mix. The hostname takes precedence: hide the indicator as soon as the hostname text reaches
/// it. The connection-status dot (the rest of `#url_status`) is left untouched — `visibility`
/// keeps the indicator's width, so hiding it does not shift the dot.
function updateServerInfoVisibility() {
const server_info = document.getElementById('server_info');
if (!server_info.textContent) { return; } /// nothing shown yet
/// Skip while the input is not laid out (e.g. inside a closed drop-down); recomputed on open.
if (!url_elem.offsetWidth) { return; }
/// Width of the hostname text in the input's own font.
const style = getComputedStyle(url_elem);
const ctx = updateServerInfoVisibility.ctx ||
(updateServerInfoVisibility.ctx = document.createElement('canvas').getContext('2d'));
ctx.font = `${style.fontSize} ${style.fontFamily}`;
const text_width = ctx.measureText(url_elem.value).width;
const url_rect = url_elem.getBoundingClientRect();
const text_end = url_rect.left + parseFloat(style.borderLeftWidth) + parseFloat(style.paddingLeft) + text_width;
const indicator_left = document.getElementById('url_status').getBoundingClientRect().left;
/// A few pixels of breathing room before they would touch.
server_info.style.visibility = (text_end + 4 > indicator_left) ? 'hidden' : 'visible';
}
window.addEventListener('resize', updateServerInfoVisibility);
let check_credentials_request_num = 0;
function checkCredentials() {
++check_credentials_request_num;
const current_check_credentials_request_num = check_credentials_request_num;
getServerStatus(url_elem.value, user_elem.value, password_elem.value).then(json => {
if (current_check_credentials_request_num == check_credentials_request_num) {
if (json) {
[user_elem, password_elem].forEach(e => { e.classList.remove('fail'); e.classList.add('ok'); });
document.getElementById('server_info').innerText = `v${json.v}, uptime ${formatUptime(json.t)} `;
updateServerInfoVisibility();
} else {
[user_elem, password_elem].forEach(e => { e.classList.remove('ok'); e.classList.add('fail'); });
/// The credentials didn't work — surface the connection settings so they can be
/// fixed. Only relevant when they live in the drop-down (tab bar shown); inline
/// (single tab) they are already visible, so there is nothing to open.
if (!connection_key_elem.hidden) { openConnectionDropdown(); }
}
}
});
}
function resetCredentialsStatus() {
[user_elem, password_elem].forEach(e => { e.classList.remove('ok'); e.classList.remove('fail'); });
}
user_elem.addEventListener('input', resetCredentialsStatus);
password_elem.addEventListener('input', resetCredentialsStatus);
/// The password is usually copy-pasted.
/// To not worry about extracting the clipboard data, but use the value instead,
/// and not worry too much about input/paste race, add the timeout.
user_elem.addEventListener('paste', () => setTimeout(checkCredentials, 100));
password_elem.addEventListener('paste', () => setTimeout(checkCredentials, 100));
function queryToColor(str) {
let hash = 2166136261;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
/// Limited range to avoid violet colors.
return 50 + ((hash >>> 0) % 200);
}
function toBase64(str) {
const encoder = new TextEncoder();
const bytes = encoder.encode(str);
const binary = Array.from(bytes, byte => String.fromCharCode(byte)).join('');
return btoa(binary);
}
function fromBase64(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); }
const decoder = new TextDecoder();
return decoder.decode(bytes);
}
let elapsed_ns = 0;
const default_format = 'JSONStringsEachRowWithProgress';
let controller = null;
/// Execute a single query, rendering results into the given targetResultEl.
/// Returns { format, reply, response_ok, is_table, is_raw, is_chart, is_image, is_error }.
/// The caller is responsible for top-level UI (download buttons, history, favicon).
/// `abortController` is an AbortController that can be used to cancel this query.
async function postImpl(posted_request_num, query, targetResultEl, abortController, paramValuesSnapshot)
{
const user = user_elem.value;
const password = password_elem.value;
const server_address = url_elem.value;
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
/// Ask server to allow cross-domain requests.
'add_http_cors_header=1';
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
url += '&default_format=' + default_format + '&enable_http_compression=1';
/// We will put the result in the query cache, so it can be used when download is initiated later.
/// But we don't use cached results, so the results that we get here, are always fresh.
url += '&use_query_cache=1&enable_reads_from_query_cache=0&enable_writes_to_query_cache=1&query_cache_ttl=600&query_cache_nondeterministic_function_handling=save&query_cache_system_table_handling=ignore';
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
/// Extremes only if the format is likely to be unchanged. This is imprecise.
if (!query.match(/\bFORMAT\s+\w+/i)) url += '&extremes=1';
targetResultEl.queryText = query;
let is_table = false;
let is_raw = false;
let is_chart = false;
let is_image = false;
let is_error = false;
let response, reply, format;
reply = '';
try {
response = await fetch(url, { method: "POST", body: query, signal: abortController.signal,
headers: { 'Authorization': 'never' } });
format = response.headers.get('X-ClickHouse-Format') ?? default_format;
/// Detect image results (e.g. `FORMAT PNG`) by the response `Content-Type` rather than by
/// parsing the query, so any format that emits an image is rendered inline as a picture.
const content_type = response.headers.get('Content-Type') ?? '';
if (!response.ok) {
is_error = true;
reply = await response.text();
if (posted_request_num != request_num) { return { cancelled: true }; }
let has_exception = false;
for (const line of reply.split('\n')) {
if (line.startsWith(`{"exception":`)) {
targetResultEl.update(JSON.parse(line));
has_exception = true;
break;
}
}
if (!has_exception) {
targetResultEl.renderError(reply);
}
return { format, reply, response_ok: false, is_table, is_raw, is_chart, is_image, is_error };
} else if (content_type.startsWith('image/')) {
is_image = true;
const blob = await response.blob();
if (posted_request_num != request_num) { return { cancelled: true }; }
targetResultEl.renderImage(blob);
} else if (format == default_format) {
is_table = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (posted_request_num != request_num) { return { cancelled: true }; }
if (done) break;
let new_content = decoder.decode(value, { stream: true });
buffer += new_content;
reply += new_content;
let lines = buffer.split('\n');
let fragment = document.createDocumentFragment();
let cont = true;
for (const line of lines.slice(0, -1)) {
const data = JSON.parse(line);
cont = targetResultEl.update(data, fragment);
if (!cont) { break; }
}
targetResultEl.flushFragment(fragment);
if (!cont)
{
abortController.abort();
break;
}
buffer = lines[lines.length - 1];
}
} else if (format == 'JSONCompactColumns') {
is_chart = true;
reply = await response.text();
if (posted_request_num != request_num) { return { cancelled: true }; }
/// Await the chart render and let it bail inside (after the uPlot load, before
/// touching the DOM) if a tab switch superseded this request meanwhile.
await targetResultEl.renderChart(JSON.parse(reply), () => posted_request_num === request_num);
} else {
is_raw = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
let new_content = decoder.decode(value, { stream: true });
reply += new_content;
if (posted_request_num != request_num) { return { cancelled: true }; }
targetResultEl.updateRaw(new_content);
}
}
if (targetResultEl.isExplainGraph) {
/// Bail inside renderGraph (after the Viz load, before mutating the DOM) if a tab
/// switch superseded this request during the load. Kept inside the `try` so a Viz
/// loader failure (like the uPlot one for charts) is reported as a normal error.
await targetResultEl.renderGraph(() => posted_request_num === request_num);
} else {
/// Render totals before coloring, so the totals row (which `_colorTargetRows`
/// includes) is colored on the first paint too.
if (is_table) targetResultEl.renderTotals();
targetResultEl.applyColumnColors();
if (is_table) targetResultEl.transposeIfNeeded();
}
/// A tab switch during an awaited renderer (chart/graph loader) makes the render bail
/// without painting; report cancellation too, so the caller does not then update the
/// now-active tab's shared progress/download/history state for this stale run.
if (posted_request_num != request_num) { return { cancelled: true }; }
return { format, reply, response_ok: true, is_table, is_raw, is_chart, is_image, is_error };
} catch (e) {
if (posted_request_num != request_num) { return { cancelled: true }; }
console.log(e);
if (e instanceof TypeError) {
reply = "Network error";
} else if (e.name === 'AbortError') {
reply = "Query was cancelled";
} else {
reply = e.toString();
}
targetResultEl.renderError(reply);
return { format, reply, response_ok: false, is_table: false, is_raw: false, is_chart: false, is_image: false, is_error: true };
}
}
/// Execute a single query (legacy path, used for single-query mode).
/// When `history_query_text` is provided, it overrides `query` for browser history/URL storage,
/// preserving the full textarea content when running a selected query in multi-query mode.
async function postSingle(posted_request_num, query, history_query_text, params_restore_pending)
{
const paramValuesSnapshot = resolveRunParams(params_restore_pending);
last_query_start = performance.now();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
clear();
progressEl.start();
controller = new AbortController();
const result = await postImpl(posted_request_num, query, resultEl, controller, paramValuesSnapshot);
if (result.cancelled) return;
if (result.is_error || !result.response_ok) {
logoEl.style.display = 'none';
progressEl.clear();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
/// Persist the error as this tab's result snapshot. The restore path renders an
/// `ok: false` snapshot as the exception, so switching away/back or reloading shows
/// the failed query's error; without this, the failed run leaves the tab's previous
/// successful result in place and that stale result would resurrect under the failed
/// query. Guarded on the request number like the success path below (and like
/// `postMulti`), so a superseded run (tab switch / newer run) does not overwrite the
/// now-active tab's snapshot.
if (posted_request_num == request_num) {
saveHistory({
query: history_query_text || query,
resultQuery: query,
format: result.format,
ok: result.response_ok,
data: result.reply.length > 100000 ? null : result.reply,
elapsed_ns: 1e6 * (performance.now() - last_query_start),
params: paramValuesSnapshot,
});
}
return;
}
/// `postImpl` already performed `renderGraph`/`applyColumnColors`/`renderTotals`/`transposeIfNeeded`;
/// the only step specific to the single-query path is the final table layout pass.
if (result.is_table) resultEl._changeTableLayout();
progressEl.finish();
document.getElementById('download-div').style.display = 'block';
document.getElementById('copy-div').style.display = result.is_raw ? 'block' : 'none';
last_query_for_download = query;
last_params_for_download = paramValuesSnapshot;
if (result.is_raw || result.is_image) {
resultEl.elapsedNs = 1e6 * (performance.now() - last_query_start);
elapsed_ns = resultEl.elapsedNs;
} else {
elapsed_ns = resultEl.elapsedNs;
}
progressEl.updateText(resultEl.rowCount, resultEl.elapsedNs, resultEl.incompleteResult);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(query));
if (posted_request_num != request_num) { return; }
/// Persist the result snapshot on the active tab and record an entry in browser
/// history + the URL. In multi-query "Run selected" mode `history_query_text` carries
/// the full editor content (the editor text + URL), while `resultQuery` is the single
/// statement that actually produced this result (used for rendering and downloads).
/// The per-column color coding modes travel with the snapshot (see `saveHistory`).
saveHistory({
query: history_query_text || query,
resultQuery: query,
format: result.format,
ok: result.response_ok,
data: result.reply.length > 100000 ? null : result.reply,
elapsed_ns: elapsed_ns,
params: paramValuesSnapshot,
});
}
/// Container for dynamically created result elements in multi-query mode.
let multiQueryContainer = null;
/// Array of AbortControllers for all in-flight queries in multi-query mode.
let multiQueryControllers = [];
/// Execute multiple queries with parallelization of consecutive SELECTs.
/// `parsed` is an array of { query, is_select } objects from splitAllQueries.
async function postMulti(posted_request_num, parsed, params_restore_pending)
{
const paramValuesSnapshot = resolveRunParams(params_restore_pending);
last_query_start = performance.now();
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
clear();
progressEl.start();
/// Hide the default single result element and the logo.
resultEl.style.display = 'none';
logoEl.style.display = 'none';
/// Create a container for multiple results if it doesn't exist.
if (!multiQueryContainer) {
multiQueryContainer = document.createElement('div');
multiQueryContainer.id = 'multi-query-results';
resultEl.parentElement.insertBefore(multiQueryContainer, resultEl);
}
multiQueryContainer.innerHTML = '';
multiQueryContainer.style.display = 'block';
multiQueryControllers = [];
/// Create result elements for each query.
const resultElements = [];
for (let i = 0; i < parsed.length; ++i) {
const el = document.createElement('query-result');
multiQueryContainer.appendChild(el);
resultElements.push(el);
}
/// Track aggregated progress across all concurrent queries.
const progressState = new Map(); /// query index -> { read_rows, read_bytes, total_rows, elapsed_ns }
function updateAggregatedProgress() {
let total_read_rows = 0, total_read_bytes = 0, total_total_rows = 0, max_elapsed_ns = 0;
for (const [, p] of progressState) {
total_read_rows += p.read_rows;
total_read_bytes += p.read_bytes;
total_total_rows += p.total_rows;
max_elapsed_ns = Math.max(max_elapsed_ns, p.elapsed_ns);
}
progressEl.updateProgress({
read_rows: String(total_read_rows),
read_bytes: String(total_read_bytes),
total_rows_to_read: String(total_total_rows),
elapsed_ns: String(max_elapsed_ns),
});
}
/// Listen for progress events from each result element.
for (let i = 0; i < resultElements.length; ++i) {
resultElements[i].addEventListener('query-progress', (e) => {
progressState.set(i, {
read_rows: +e.detail.read_rows,
read_bytes: +e.detail.read_bytes,
total_rows: +e.detail.total_rows_to_read,
elapsed_ns: +e.detail.elapsed_ns,
});
updateAggregatedProgress();
});
}
/// Group consecutive parallelizable queries together.
/// Each group is either a batch of SELECTs (run in parallel) or a single non-SELECT (barrier).
const groups = [];
let current_group = null;
for (let i = 0; i < parsed.length; ++i) {
if (parsed[i].is_select) {
if (!current_group || !current_group.parallelizable) {
current_group = { parallelizable: true, indices: [] };
groups.push(current_group);
}
current_group.indices.push(i);
} else {
current_group = { parallelizable: false, indices: [i] };
groups.push(current_group);
}
}
/// Execute groups sequentially; within a parallelizable group, execute all queries concurrently.
/// Keep each statement's result so a small run can be snapshotted for restore (see below).
let had_error = false;
const collected = new Array(parsed.length).fill(null);
for (const group of groups) {
if (posted_request_num != request_num) return;
if (had_error) break;
const promises = group.indices.map(i => {
const ac = new AbortController();
multiQueryControllers.push(ac);
return postImpl(posted_request_num, parsed[i].query, resultElements[i], ac, paramValuesSnapshot)
.then(result => ({ index: i, result }));
});
const results = await Promise.all(promises);
for (const { index, result } of results) {
if (result.cancelled) return;
collected[index] = result;
if (result.is_error || !result.response_ok) {
had_error = true;
}
}
}
if (posted_request_num != request_num) return;
/// Compute aggregated stats.
let total_rows = 0;
let max_elapsed_ns = 0;
let any_incomplete = false;
for (const el of resultElements) {
total_rows += el.rowCount;
max_elapsed_ns = Math.max(max_elapsed_ns, el.elapsedNs);
if (el.incompleteResult) any_incomplete = true;
}
if (!had_error) {
logoEl.style.display = 'block';
}
progressEl.finish();
elapsed_ns = max_elapsed_ns || 1e6 * (performance.now() - last_query_start);
progressEl.updateText(total_rows, elapsed_ns, any_incomplete);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(parsed[0].query));
if (posted_request_num != request_num) return;
/// Snapshot the per-statement results so switching tabs / reloading restores the whole
/// "Run all" output (not just the queries), the same way a single query's result is kept.
/// Mirror the single-query 100 KB cap, but on the TOTAL size across all statements: above
/// it we store no data (`multi: null`) and the run is query-only (re-running reproduces it).
/// Images stream as a blob (their `reply` stays empty) and cannot be re-rendered from text,
/// so they are stored with `data: null` and restore as empty, exactly like a single image.
let multi = collected.map((r, i) => ({
query: parsed[i].query,
format: r ? r.format : null,
ok: r ? !!r.response_ok : false,
data: (r && !r.is_image) ? r.reply : null,
}));
let multi_size = 0;
for (const s of multi) multi_size += s.data ? s.data.length : 0;
if (multi_size > 100000) multi = null;
/// Save all queries to browser history, using the full textarea content.
saveHistory({
query: query_area.value,
format: null,
ok: !had_error,
data: null,
multi: multi,
elapsed_ns: elapsed_ns,
params: paramValuesSnapshot,
}, parsed[0].query);
}
/// Render one saved multi-query statement snapshot into `el`, mirroring the live per-statement
/// rendering in `postImpl` (table / chart / EXPLAIN graph / raw / error), minus the streaming.
/// `token` guards the awaited chart/graph loaders: a superseding activation bails before touching
/// the DOM, and a loader failure (e.g. a blocked CDN) is shown as an error for that statement
/// rather than throwing out of the whole restore.
async function renderSnapshotIntoElement(el, snap, token) {
el.queryText = snap.query;
if (!snap.data) return;
const format = snap.format;
if (!snap.ok) {
let has_exception = false;
for (const line of snap.data.split('\n')) {
if (line.startsWith(`{"exception":`)) {
el.update(JSON.parse(line));
has_exception = true;
break;
}
}
if (!has_exception) el.renderError(snap.data);
return;
}
let is_table = false;
if (format == default_format) {
is_table = true;
let fragment = document.createDocumentFragment();
for (const line of snap.data.split('\n')) {
if (line.length == 0) continue;
if (!el.update(JSON.parse(line), fragment)) { break; }
}
el.flushFragment(fragment);
} else if (format == 'JSONCompactColumns') {
if (token !== request_num) return;
try {
await el.renderChart(JSON.parse(snap.data), () => token === request_num);
} catch (e) {
if (token !== request_num) return;
console.log(e);
el.renderError(e.toString());
return;
}
if (token !== request_num) return;
} else {
el.updateRaw(snap.data);
}
if (el.isExplainGraph) {
try {
await el.renderGraph(() => token === request_num);
} catch (e) {
if (token !== request_num) return;
console.log(e);
el.renderError(e.toString());
return;
}
if (token !== request_num) return;
} else {
/// Render totals before coloring, so the totals row (which `_colorTargetRows`
/// includes) is colored on the first paint too.
if (is_table) el.renderTotals();
el.applyColumnColors();
if (is_table) el.transposeIfNeeded();
}
}
/// Persist the per-column color coding modes so reloads, shares, and Back/Forward keep them.
/// The modes belong to the active tab's displayed result, so record them on the tab's result
/// snapshot (persisted to IndexedDB and re-applied when the tab is re-activated) and re-stamp the
/// current history entry + URL in place, without creating a new record.
function persistColorModes() {
const tab = getActiveTab();
if (tab && tab.result) tab.result.color_modes = column_color_modes;
const url = new URL(window.location);
if (Object.keys(column_color_modes).length > 0) {
url.searchParams.set('color_modes', JSON.stringify(column_color_modes));
} else {
url.searchParams.delete('color_modes');
}
/// Keep the tabs state shape: fold the modes into the entry's nested `result` when it has one
/// (matching how `restoreFromHistory` reads them back via `state.result || state`), else at
/// the top level for a legacy flat entry.
const cur = history.state;
if (cur) {
const next = cur.result
? { ...cur, result: { ...cur.result, color_modes: column_color_modes } }
: { ...cur, color_modes: column_color_modes };
try {
history.replaceState(next, document.title, url.toString());
} catch (e) {
/// History manipulation is not allowed for the 'file:' protocol.
}
}
scheduleSave();
}
/// Re-render a saved multi-query ("Run all") result snapshot, rebuilding the multi-query
/// container the same way `postMulti` lays it out: one result element per statement, the single
/// result element and logo hidden, and aggregated progress text. Called from `restoreFromHistory`
/// for a tab whose result carries a `multi` array (small runs; large ones are query-only).
async function restoreMultiQuery(snapshot, token) {
resultEl.style.display = 'none';
logoEl.style.display = 'none';
if (!multiQueryContainer) {
multiQueryContainer = document.createElement('div');
multiQueryContainer.id = 'multi-query-results';
resultEl.parentElement.insertBefore(multiQueryContainer, resultEl);
}
multiQueryContainer.innerHTML = '';
multiQueryContainer.style.display = 'block';
const elements = [];
for (let i = 0; i < snapshot.multi.length; ++i) {
const el = document.createElement('query-result');
multiQueryContainer.appendChild(el);
elements.push(el);
}
for (let i = 0; i < snapshot.multi.length; ++i) {
await renderSnapshotIntoElement(elements[i], snapshot.multi[i], token);
if (token !== request_num) return;
}
/// The logo returns only for a fully successful run, matching the live `postMulti` path.
if (!snapshot.multi.some(s => !s.ok)) logoEl.style.display = 'block';
let total_rows = 0;
let max_elapsed_ns = 0;
let any_incomplete = false;
for (const el of elements) {
total_rows += el.rowCount;
max_elapsed_ns = Math.max(max_elapsed_ns, el.elapsedNs);
if (el.incompleteResult) any_incomplete = true;
}
progressEl.finish();
elapsed_ns = snapshot.elapsed_ns || max_elapsed_ns;
progressEl.updateText(total_rows, elapsed_ns, any_incomplete);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(snapshot.multi[0].query));
}
/// `token` ties this render to one activation/history navigation (the value of
/// `request_num` at the time). After each await we bail if it changed, so a slow
/// activation cannot apply its state on top of a newer one.
async function restoreFromHistory(state, token) {
if (token === undefined) token = request_num;
/// The editor (textarea + param inputs) is restored from the top-level query/params.
/// The result is rendered from `state.result`, which carries the query/params that
/// produced it; for flat history entries (editor == result) it defaults to `state`.
const result = state.result || state;
/// Restore the per-column color coding modes saved with this result before rendering, so the
/// table is colored on the first paint. Reads `result.color_modes`, which covers both nested
/// per-tab snapshots and legacy flat entries (where `result === state`, carrying a top-level
/// `color_modes`). A result-less entry leaves the modes empty.
column_color_modes = result.color_modes || {};
clear();
/// Reset the result-action targets, so an empty/other tab can't download or copy the
/// previously active tab's query/result. Shown again below if a result renders. They
/// follow the PRODUCING query/params, not the (possibly edited) editor.
document.getElementById('download-div').style.display = 'none';
document.getElementById('copy-div').style.display = 'none';
last_query_for_download = result.query || '';
last_params_for_download = result.params || {};
query_area.value = state.query;
query_area.dispatchEvent(new Event('input'));
/// Graph detection keys off the producing query, so a snapshot still renders as a graph
/// even if the editor query was changed without rerunning.
resultEl.queryText = result.query ?? state.query;
/// Restore query parameter inputs and their values. Replace the FULL snapshot rather than
/// overlaying `state.params`: when the previous and restored tabs share a placeholder name,
/// `updateQueryParams` keeps the existing input (the detected param list is unchanged), so a
/// value from the previous tab would leak into a param the restored tab leaves unset. Reset
/// every detected param explicitly (absent -> ''), then capture the snapshot into the active
/// tab so its stored params match what is shown and will run.
/// `updateQueryParams` awaits tokenization, and `activateTab` has already made this tab active,
/// but the `param_*` inputs still hold the previous tab's values until `setParamValues` below.
/// Mark the restore as in flight so a `captureActiveTab` running during the await (the debounced
/// `persist`, or a quick second navigation) does not snapshot those stale inputs into this tab.
params_restore_pending_token = token;
const params_ok = await updateQueryParams();
if (token !== request_num) return;
const restored_params = {};
if (params_ok)
{
for (const param of currentQueryParams)
{
restored_params[param.name] = state.params?.[param.name] ?? '';
}
setParamValues(restored_params);
}
else
{
/// Tokenization did not rebuild the parameter list (e.g. a query too large for the WASM
/// lexer, or no lexer at all), so `currentQueryParams` may still describe the PREVIOUS
/// tab. Deriving params from it would inject that tab's placeholder names into this one
/// and persist them into history / `IndexedDB`. Fall back to this tab's own saved
/// snapshot, and clear the stale param inputs (and the stale list) so the previous tab's
/// names/values cannot linger in the UI or leak through `getParamValues`.
queryParamsContainer.innerHTML = '';
currentQueryParams = [];
Object.assign(restored_params, state.params || {});
}
const active_tab_for_params = getActiveTab();
if (active_tab_for_params) active_tab_for_params.params = restored_params;
/// Parameters are now committed to both the DOM inputs and the tab; capture is safe again.
params_restore_pending_token = null;
/// A snapshotted multi-query ("Run all") result is restored into the multi-query
/// container, rebuilding one result element per statement, rather than into `resultEl`.
if (result.multi && result.multi.length) {
await restoreMultiQuery(result, token);
return;
}
if (!result.data) return;
let is_table = false;
let is_raw = false;
let is_chart = false;
let is_error = false;
let format = result.format;
if (!result.ok) {
is_error = true;
/// Hide the idle logo for the whole error branch, matching the live single-query error
/// path (`postSingle` hides it once at the top of its `!result.response_ok` block, for
/// both JSON exceptions and plain-text errors). Restoring a JSON exception previously
/// left the logo showing, so a reloaded/switched-back error tab looked different from the
/// original run.
logoEl.style.display = 'none';
let has_exception = false;
for (const line of result.data.split('\n')) {
if (line.startsWith(`{"exception":`)) {
resultEl.update(JSON.parse(line));
has_exception = true;
break;
}
}
if (!has_exception) {
resultEl.renderError(result.data);
}
progressEl.clear();
return;
} else if (format == default_format) {
is_table = true;
let lines = result.data.split('\n');
let fragment = document.createDocumentFragment();
for (const line of lines) {
if (line.length == 0) continue;
if (!resultEl.update(JSON.parse(line), fragment)) { break; }
}
resultEl.flushFragment(fragment);
} else if (format == 'JSONCompactColumns') {
is_chart = true;
if (token !== request_num) { return; }
/// renderChart awaits the uPlot loader on first use; the predicate makes it bail
/// inside (right after the load, before mutating the DOM) if a tab switch superseded
/// this activation, and we re-check here too.
try {
await resultEl.renderChart(JSON.parse(result.data), () => token === request_num);
} catch (e) {
/// The external chart library failed to load (e.g. CDN blocked). Show the error
/// for this saved snapshot instead of throwing out of the activation half-rendered.
if (token !== request_num) return;
console.log(e);
resultEl.renderError(e.toString());
logoEl.style.display = 'none';
progressEl.clear();
return;
}
if (token !== request_num) { return; }
} else {
is_raw = true;
resultEl.updateRaw(result.data);
}
if (resultEl.isExplainGraph) {
try {
await resultEl.renderGraph(() => token === request_num);
} catch (e) {
/// The Viz loader failed; show the error instead of throwing out of the activation.
if (token !== request_num) return;
console.log(e);
resultEl.renderError(e.toString());
logoEl.style.display = 'none';
progressEl.clear();
return;
}
if (token !== request_num) return;
} else {
/// Render totals before coloring, so the totals row is colored on first paint too.
if (is_table) resultEl.renderTotals();
resultEl.applyColumnColors();
if (is_table) resultEl.transposeIfNeeded();
if (is_table) resultEl._changeTableLayout();
}
progressEl.finish();
/// A result is shown for this tab, so its download/copy actions apply to this query.
document.getElementById('download-div').style.display = 'block';
document.getElementById('copy-div').style.display = is_raw ? 'block' : 'none';
if (is_raw) {
resultEl.elapsedNs = result.elapsed_ns;
}
elapsed_ns = resultEl.elapsedNs;
progressEl.updateText(resultEl.rowCount, resultEl.elapsedNs, resultEl.incompleteResult);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(state.query));
}
let query_area = document.getElementById('query');
appendTextareaResizer();
/// ===== Tabs =====================================================================
/// A tab bundles a query, its title, query parameters and a result snapshot.
/// We keep one editor + one result element and swap content on activation.
const tab_bar_elem = document.getElementById('tab-bar');
const tabs_elem = document.getElementById('tabs');
/// The connection parameters (url/user/password) live in the `#inputs` element. When the tab
/// bar is shown they clutter the top of every tab, so we relocate that same element into a
/// drop-down behind a 🔑 button in the top-right corner. Relocating the existing element (rather
/// than duplicating the inputs) keeps every reference and event listener bound to it valid. The
/// three inputs carry `form="controls"`, so once moved out of the form's subtree they still count
/// as its fields: pressing `Enter` in url/user/password keeps submitting `#controls` (running the
/// query), matching the inline single-tab layout.
const inputs_elem = document.getElementById('inputs');
const controls_elem = document.getElementById('controls');
const connection_menu_elem = document.getElementById('connection-menu');
const connection_key_elem = document.getElementById('connection-key');
const connection_dropdown_elem = document.getElementById('connection-dropdown');
function openConnectionDropdown() {
connection_dropdown_elem.hidden = false;
connection_key_elem.setAttribute('aria-expanded', 'true');
/// The url input was unmeasurable while the drop-down was display:none; recompute now.
updateServerInfoVisibility();
}
function closeConnectionDropdown() {
connection_dropdown_elem.hidden = true;
connection_key_elem.setAttribute('aria-expanded', 'false');
}
/// Place the `#inputs` element either inline in `#controls` (no tab bar) or inside the
/// drop-down behind the 🔑 button (tab bar shown). Called from `renderTabBar` whenever the
/// tab bar's visibility is recomputed.
function syncConnectionUI() {
const tabsShown = !tab_bar_elem.hidden;
/// Capture whether the 🔑 button owns focus before the next line may hide it: hiding a focused
/// element strands keyboard focus, so on the drop-down→inline transition we hand focus to a
/// visible control below (the mirror of the inline→drop-down handoff).
const keyHadFocus = document.activeElement === connection_key_elem;
connection_key_elem.hidden = !tabsShown;
if (tabsShown) {
if (inputs_elem.parentElement !== connection_dropdown_elem) {
/// If focus is inside `#inputs` when the tab bar first appears (e.g. the user pressed
/// Enter in url/user/password to run the first query — reachable because those fields
/// keep `form="controls"` and submit implicitly), moving the subtree into the hidden
/// drop-down would strand focus on a hidden element. Move focus to the now-visible 🔑
/// button first — as the Escape handler does — so keyboard users keep a visible focus
/// target and the inputs stay one key press away behind that button.
const refocus = inputs_elem.contains(document.activeElement);
connection_dropdown_elem.appendChild(inputs_elem);
if (refocus) connection_key_elem.focus();
}
} else {
/// Restore the inline position (first child of the form) and make sure the drop-down
/// is closed so a later reappearance of the tab bar starts collapsed.
if (inputs_elem.parentElement !== controls_elem) {
controls_elem.insertBefore(inputs_elem, controls_elem.firstChild);
}
closeConnectionDropdown();
/// The `#connection-key` button was just hidden; if it owned focus, hand focus to the
/// now-visible inline `url` input — the first connection field the button gated — so
/// keyboard focus stays visible and the next Tab continues from the connection row.
if (keyHadFocus) url_elem.focus();
}
/// The url input's width differs between the inline bar and the drop-down, so the point at
/// which the hostname reaches the version/uptime indicator changes: recompute its visibility.
updateServerInfoVisibility();
}
connection_key_elem.addEventListener('click', (e) => {
/// Keep the toggle click from reaching the result-selection handler on `document`.
e.stopPropagation();
if (connection_dropdown_elem.hidden) openConnectionDropdown(); else closeConnectionDropdown();
});
/// A click inside the drop-down (e.g. typing in the inputs) must neither close it nor clear the
/// result selection, so keep it from bubbling to `document`.
connection_dropdown_elem.addEventListener('click', (e) => e.stopPropagation());
/// Close the drop-down on a click anywhere outside `#connection-menu` (which holds the 🔑 button
/// and the drop-down). Test the target in the capture phase instead of relying on the click
/// bubbling to `document`: several outside controls in this file (the download menu, the
/// databases-panel toggles, the terminal toggle, the tab-close button, the per-column toggles)
/// call `stopPropagation()` in their own bubble-phase click handlers, so a bubbling listener
/// would never see those clicks and the popover would stay open. A capture-phase listener runs
/// before all of them, so every outside interaction dismisses it.
///
/// A `click` fires on the nearest common ancestor of the `mousedown` and `mouseup` targets, so a
/// text selection that starts inside an input (e.g. selecting the user name) and is dragged past
/// the drop-down's border before release lands its `click` on an ancestor outside `#connection-menu`
/// — which would wrongly dismiss the popover mid-selection. Remember where the pointer went down
/// (capture phase, so it is unaffected by `stopPropagation()` on inner handlers) and only close
/// when the interaction both started and ended outside the menu.
let connection_pointerdown_inside = false;
document.addEventListener('mousedown', (e) => {
connection_pointerdown_inside = connection_menu_elem.contains(e.target);
}, true);
document.addEventListener('click', (e) => {
/// The latch is meaningful only for the pointer press that produced this `click`, and a press
/// arms it only when it goes on to emit a `click` (`detail !== 0`). Two kinds of `click` must
/// therefore ignore it: a keyboard or synthetic activation (Space/Enter on a focused control, or
/// a programmatic `.click()`) reports `detail === 0` and has no preceding `mousedown`; and a
/// mouse interaction that ends without a `click` — a right-click's `contextmenu`, a middle-click,
/// or a drag released outside the window — leaves a stale latch that no `click` ever consumes.
/// In both cases a stale `true` would otherwise wrongly suppress the outside dismissal, so the
/// latch counts only for a real pointer `click`.
const started_inside = e.detail !== 0 && connection_pointerdown_inside;
if (!connection_menu_elem.contains(e.target) && !started_inside) closeConnectionDropdown();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !connection_dropdown_elem.hidden) {
/// Closing hides the subtree that may own focus (a connection input). Return focus to
/// the 🔑 button — as the download menu does with its trigger — so keyboard users keep a
/// visible focus target and the next Tab continues from the tab bar, not the page top.
const refocus = connection_dropdown_elem.contains(document.activeElement);
closeConnectionDropdown();
if (refocus) connection_key_elem.focus();
}
});
/// The connection inputs keep `form="controls"`, so native constraint validation (e.g. the
/// `type="url"` check on `#url`) still runs on submit while they sit in the closed drop-down.
/// A hidden invalid control blocks the submit with no visible bubble, leaving the Run button
/// dead. Open the drop-down when a connection field reports invalid, so the browser shows its
/// validation bubble on a now-visible control. `invalid` does not bubble, so listen in the
/// capture phase. In the single-tab layout the 🔑 button is hidden and the inputs are already
/// visible inline, so nothing is opened.
inputs_elem.addEventListener('invalid', () => {
if (!connection_key_elem.hidden && connection_dropdown_elem.hidden) openConnectionDropdown();
}, true);
/// Generate the next default title: "Query A" .. "Query Z", "Query AA", ...
/// Skips titles already in use so closing/reopening doesn't produce duplicates.
function nextDefaultTitle() {
const used = new Set(tabs.map(t => t.title));
for (;;) {
let n = tabTitleSeq++;
let letters = '';
do { letters = String.fromCharCode(65 + (n % 26)) + letters; n = Math.floor(n / 26) - 1; } while (n >= 0);
const title = 'Query ' + letters;
if (!used.has(title)) return title;
}
}
/// Tab titles double as the URL `tab=` key and the startup-reconciliation lookup key, so
/// they must stay unique: two tabs sharing a title would let a shared/reloaded URL match —
/// and overwrite — the wrong tab. If `desired` collides with another tab (ignoring the tab
/// `excludeId`, i.e. the one being renamed), disambiguate by appending " (2)", " (3)", …
function uniqueTitle(desired, excludeId) {
const taken = new Set(tabs.filter(t => t.id !== excludeId).map(t => t.title));
if (!taken.has(desired)) return desired;
for (let i = 2; ; ++i) {
const candidate = desired + ' (' + i + ')';
if (!taken.has(candidate)) return candidate;
}
}
function makeTab(title, query) {
return {
id: 't' + (++tabSeq),
title: title || nextDefaultTitle(),
query: query || '',
params: {},
result: null, /// last result snapshot (history-state shape) or null
lastSavedQuery: undefined,
};
}
function getActiveTab() {
return tabs.find(t => t.id === activeTabId) || null;
}
/// Serialize the live editor (query text + parameter values) into the active tab.
/// The result snapshot is written separately by `saveHistory` after a run.
function captureActiveTab() {
const tab = getActiveTab();
if (!tab) return;
tab.query = query_area.value;
/// While an activation is restoring this tab's parameter inputs (after the `updateQueryParams`
/// await in `restoreFromHistory`, before `setParamValues`), the `param_*` DOM inputs still hold
/// the previously active tab's values. Snapshotting them now would overwrite this tab's own
/// parameters with the previous tab's and later reload/run with the wrong values. Skip the
/// parameter capture until the restore commits. `tab.query` is safe: `restoreFromHistory` sets
/// the editor to this tab's query before the await. The guard lifts automatically once a run or
/// a newer activation advances `request_num`, so it can never suppress capture indefinitely.
if (params_restore_pending_token !== request_num) tab.params = getParamValues();
}
/// Invalidate any in-flight query and abort its HTTP requests, so a late response cannot
/// render or be saved into the now-active tab. Bumping `request_num` also supersedes any
/// run still suspended on an await (it captured the old value) and any in-progress tab
/// activation (`restoreFromHistory` bails when the token no longer matches `request_num`).
function invalidateInFlight() {
++request_num;
if (controller) controller.abort();
for (const ac of multiQueryControllers) ac.abort();
multiQueryControllers = [];
if (typeof in_flight !== 'undefined' && in_flight) endFlight();
}
/// Load a tab's content into the editor and re-render its stored result.
/// Routed through `restoreFromHistory` so the syntax backdrop, params and run
/// buttons resync (it dispatches an 'input' event), exactly like history navigation.
async function activateTab(id) {
const tab = tabs.find(t => t.id === id);
if (!tab) return;
activeTabId = id;
invalidateInFlight();
const token = request_num;
/// Restore the editor from the live tab.query/tab.params (latest edits), while rendering
/// the stored result from tab.result, which keeps its own producing query/params.
await restoreFromHistory({ query: tab.query, params: tab.params, result: tab.result }, token);
if (token !== request_num) return; /// a newer activation/run superseded this one
/// Always recolor the page (and thus the active tab slab) to this tab's query,
/// even when there is no result to render (restoreFromHistory skips this otherwise).
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(tab.query));
renderTabBar();
query_area.focus();
}
/// Switch tabs in response to a user click: capture the current tab, activate the
/// target, and push a browser-history entry so Back/Forward moves between tabs.
async function switchToTab(id) {
if (id === activeTabId) return;
captureActiveTab();
/// Fold the captured editor state of the tab we are leaving back into its own history
/// entry, so a later Back/Forward to it restores the latest edit rather than the stale
/// last-run/URL snapshot.
refreshCurrentHistoryEntry(getActiveTab());
await activateTab(id);
syncHistory();
}
function addTab() {
markBootstrapDirty(); /// user created a tab; reconciliation must preserve it
captureActiveTab();
refreshCurrentHistoryEntry(getActiveTab());
const tab = makeTab();
tabs.push(tab);
activeTabId = tab.id;
/// A brand new tab has no result; clear the editor and result area.
activateTab(tab.id).then(syncHistory);
scheduleSave();
}
function closeTab(id) {
const idx = tabs.findIndex(t => t.id === id);
if (idx === -1) return;
const wasActive = id === activeTabId;
tabs.splice(idx, 1);
if (tabs.length === 0) {
/// Never leave zero tabs: start a fresh default one.
const tab = makeTab();
tabs.push(tab);
activeTabId = tab.id;
activateTab(tab.id).then(syncHistory);
} else if (wasActive) {
/// Prefer the right neighbour, otherwise the left.
const next = tabs[Math.min(idx, tabs.length - 1)];
activeTabId = next.id;
activateTab(next.id).then(syncHistory);
} else {
renderTabBar();
}
scheduleSave();
}
/// Begin inline editing of the active tab's title. The title `span` itself becomes
/// editable (rather than swapping in an `input`), so it grows with its content and the
/// tab width does not jump when editing starts.
function startTitleEdit(tab, titleEl) {
titleEl.contentEditable = 'true';
titleEl.spellcheck = false;
titleEl.classList.add('editing');
/// Mark the tab so it drops its max-width and flexes to fit the title being typed.
titleEl.closest('.tab').classList.add('editing');
/// Select the whole title for easy replacement.
const range = document.createRange();
range.selectNodeContents(titleEl);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
titleEl.focus();
let done = false;
const commit = (save) => {
if (done) return;
done = true;
if (save) {
/// Collapse any pasted newlines/runs of whitespace into single spaces.
const v = titleEl.textContent.replace(/\s+/g, ' ').trim();
/// Keep titles unique (they key the URL `tab=` reconciliation), disambiguating
/// a collision rather than letting two tabs share a name.
if (v) tab.title = uniqueTitle(v, tab.id);
}
renderTabBar(); /// rebuilds the bar, replacing this editable span with a static one
syncHistory();
scheduleSave();
};
titleEl.addEventListener('keydown', (e) => {
/// Keep keys (Space/Enter) from reaching the tab-level switch handler.
e.stopPropagation();
if (e.key === 'Enter') { e.preventDefault(); commit(true); }
else if (e.key === 'Escape') { e.preventDefault(); commit(false); }
});
titleEl.addEventListener('blur', () => commit(true));
}
/// Rebuild the whole tab bar. Cheap (few nodes) so we never diff.
function renderTabBar() {
const active = getActiveTab();
/// Hidden when there is a single tab that has never produced a result from a genuine
/// run, so the page looks exactly as before tabs existed. Keying off `result.ran`
/// (rather than merely `result != null`) also ignores any stale/fabricated result
/// snapshot that an older build may have persisted to IndexedDB.
tab_bar_elem.hidden = (tabs.length <= 1 && !(active && active.result && active.result.ran));
syncConnectionUI();
tabs_elem.textContent = '';
for (const tab of tabs) {
const el = document.createElement('div');
el.className = 'tab' + (tab.id === activeTabId ? ' active' : '');
el.dataset.id = tab.id;
el.tabIndex = 0;
/// Inactive tabs are tinted by the hash of their own query text.
el.style.setProperty('--tab-hue', queryToColor(tab.query));
const titleEl = document.createElement('span');
titleEl.className = 'tab-title';
titleEl.textContent = tab.title;
el.appendChild(titleEl);
const closeEl = document.createElement('span');
closeEl.className = 'tab-close';
closeEl.title = 'Close';
closeEl.textContent = '×';
el.appendChild(closeEl);
el.addEventListener('click', (e) => {
if (e.target === closeEl) { e.stopPropagation(); closeTab(tab.id); return; }
if (tab.id === activeTabId) {
if (e.target === titleEl) startTitleEdit(tab, titleEl);
} else {
switchToTab(tab.id);
}
});
el.addEventListener('keydown', (e) => {
/// Ignore keys coming from the inline title editor (so Space/Enter type normally).
if (e.target !== el) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); switchToTab(tab.id); }
});
tabs_elem.appendChild(el);
}
}
document.getElementById('tab-add').addEventListener('click', addTab);
/// Mouse wheel over the tab panel switches tabs: scrolling up or left -> previous (left)
/// tab, down or right -> next (right) tab. The dominant axis is used, so both vertical and
/// horizontal wheels/trackpad gestures work. Deltas are accumulated (and normalized for
/// line-mode) so one mouse notch switches one tab and a trackpad swipe switches once per
/// threshold; switching stops at the ends.
let wheel_accum = 0;
tab_bar_elem.addEventListener('wheel', (e) => {
if (tabs.length < 2) return; /// nothing to switch; let the page scroll
e.preventDefault();
const scale = e.deltaMode === 1 ? 16 : 1;
const dx = e.deltaX * scale, dy = e.deltaY * scale;
wheel_accum += Math.abs(dx) > Math.abs(dy) ? dx : dy;
if (Math.abs(wheel_accum) < 40) return;
const dir = wheel_accum > 0 ? 1 : -1; /// down/right (+) -> right, up/left (-) -> left
wheel_accum = 0;
const idx = tabs.findIndex(t => t.id === activeTabId);
const next = idx + dir;
if (next >= 0 && next < tabs.length) switchToTab(tabs[next].id);
}, { passive: false });
/// ----- IndexedDB persistence ----------------------------------------------------
/// Stores the workspace (tabs, titles, active tab, order and small result snapshots)
/// so it survives reloads. Best-effort: any failure degrades to in-memory-only tabs.
const DB_NAME = 'clickhouse-play';
const TAB_STORE = 'tabs';
const META_STORE = 'meta';
function openDb() {
return new Promise((resolve) => {
let req;
try {
req = indexedDB.open(DB_NAME, 1);
} catch (e) {
resolve(null);
return;
}
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(TAB_STORE)) db.createObjectStore(TAB_STORE, { keyPath: 'id' });
if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE, { keyPath: 'key' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
}
async function loadFromDb() {
const db = await dbReady;
if (!db) return null;
return new Promise((resolve) => {
try {
const tx = db.transaction([TAB_STORE, META_STORE], 'readonly');
const tabsReq = tx.objectStore(TAB_STORE).getAll();
const metaReq = tx.objectStore(META_STORE).get('state');
tx.oncomplete = () => resolve({ records: tabsReq.result || [], meta: metaReq.result || null });
tx.onerror = () => resolve(null);
} catch (e) {
resolve(null);
}
});
}
let save_timer = null;
function scheduleSave() {
if (save_timer) return;
save_timer = setTimeout(() => { save_timer = null; persist(); }, 400);
}
async function persist() {
const db = await dbReady;
if (!db) return;
captureActiveTab();
try {
const tx = db.transaction([TAB_STORE, META_STORE], 'readwrite');
const tabStore = tx.objectStore(TAB_STORE);
tabStore.clear();
for (const tab of tabs) {
tabStore.put({
id: tab.id,
title: tab.title,
query: tab.query,
params: tab.params,
result: tab.result,
/// The query as of the last URL/history sync. Persisting it lets startup
/// reconciliation tell a stale reload (the URL hash is this tab's own last
/// synced query, but the tab was edited afterwards) from a fresh external URL.
lastSavedQuery: tab.lastSavedQuery,
});
}
tx.objectStore(META_STORE).put({
key: 'state',
activeTabId: activeTabId,
tabOrder: tabs.map(t => t.id),
tabSeq: tabSeq,
tabTitleSeq: tabTitleSeq,
});
} catch (e) {
/// Ignore persistence failures (private mode, quota, etc.).
}
}
/// ----- Browser history + URL ----------------------------------------------------
/// Build the query-string parameters (user/run/url/param_*/tab) shared by history
/// entries. Connection settings are global; the active tab name is appended so a
/// shared URL can be reconciled against saved tabs on load.
/// `fromRun` marks an entry that a genuine run produced (see `writeHistoryEntry`).
function buildHistoryParams(params, tabName, fromRun, colorModes) {
const out = [];
const user = user_elem.value;
const server_address = url_elem.value;
if (user) out.push('user=' + encodeURIComponent(user));
/// Only carry `run=1` for an entry whose query/params were produced by a genuine run and
/// still match it (`fromRun`). `saveHistory` sets it on the run itself; the editor-only
/// writers (`syncHistory`/`refreshCurrentHistoryEntry`, driven by tab switch/rename,
/// parameter edits and startup sync) recompute it from `tabReflectsRun`, preserving the
/// marker while the editor is unchanged (so a shared `run=1` link whose result is not
/// snapshotted — an image or oversized result — stays reproducible across a tab switch or
/// rename), but dropping it the moment the editor diverges from the produced result. An
/// unrun edit must never keep `run=1`: reloading would auto-execute it (e.g. a
/// `DROP`/`INSERT`), because startup treats a `run=1` URL as authoritative.
if (run_immediately && fromRun) out.push('run=1');
if (server_address != location.origin) out.push('url=' + encodeURIComponent(server_address));
for (const [name, value] of Object.entries(params || {})) {
/// Serialize every parameter, including empty strings: an empty value is valid for a
/// String parameter and must be distinguishable from "absent" on load (read back with
/// URLSearchParams.get, which returns '' for present-empty and null for absent).
if (value === undefined || value === null) continue;
out.push('param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value));
}
/// Carry the result's per-column color coding modes so a shared/reloaded URL restores them.
if (colorModes && Object.keys(colorModes).length > 0) {
out.push('color_modes=' + encodeURIComponent(JSON.stringify(colorModes)));
}
if (tabName) out.push('tab=' + encodeURIComponent(tabName));
return out;
}
/// Write a browser-history entry + URL derived from the tab. The top-level query/params
/// are the EDITOR state (also the URL hash and the de-dup key); `result` is the snapshot
/// that produced the rendered result, carrying its OWN query/params so rendering/download
/// stay tied to the producing query even if the editor was edited without rerunning.
/// `titleForDoc` overrides the document title text. When `forceReplace` is set, the current
/// entry is replaced in place even if the query changed (used to refresh the entry of a tab
/// being left so it carries the latest editor edit rather than the last run/URL snapshot).
/// `fromRun` is set only by `saveHistory` (an actual run) so the URL carries `run=1`; every
/// editor-only caller leaves it unset so an unrun edit is never re-stamped as auto-runnable.
function writeHistoryEntry(tab, titleForDoc, forceReplace, fromRun) {
/// Skip a fresh entry if neither the active tab nor its query changed.
const cur = history.state;
const sameTab = cur && cur.tabId === tab.id;
const unchanged = sameTab && cur.query === tab.query;
const state = {
query: tab.query,
params: tab.params,
result: tab.result || null,
tabId: tab.id,
tabName: tab.title,
};
const title = "ClickHouse Query: " + (titleForDoc || tab.query);
const history_params = buildHistoryParams(tab.params, tab.title, fromRun, tab.result && tab.result.color_modes);
let history_url = window.location.pathname;
if (history_params.length > 0) history_url += '?' + history_params.join('&');
history_url += '#' + toBase64(tab.query);
if (!cur || unchanged || (forceReplace && sameTab)) {
history.replaceState(state, title, history_url);
} else {
history.pushState(state, title, history_url);
}
document.title = title;
tab.lastSavedQuery = tab.query;
renderTabBar();
scheduleSave();
}
/// True when the tab's editor (query + params) still exactly matches the query/params that
/// produced its current result, i.e. re-running the editor would reproduce what is shown. The
/// editor-only history writers use it to decide whether to keep `run=1` on the entry: a shared
/// `run=1` link whose result is not snapshotted (an image or an oversized result) must stay
/// re-runnable across a tab switch or rename, but the marker must be dropped the moment the
/// editor diverges from the produced result so an unrun edit is never auto-executed on reload.
/// `result.query` is the FULL editor text for a single "Run" and for a multi-query "Run all"
/// (both store the editor into `tab.query` and into the snapshot), so the two are equal while
/// the tab is clean; a "Run selected" result stores only the selected statement, so it compares
/// unequal to the full editor and conservatively drops the marker (its URL hash is the whole
/// editor and could not reproduce that single-statement result anyway).
function tabReflectsRun(tab) {
return !!(tab && tab.result && tab.result.ran
&& tab.query === tab.result.query
&& JSON.stringify(tab.params || {}) === JSON.stringify(tab.result.params || {}));
}
/// Refresh the current browser-history entry in place from the given tab's captured editor
/// state. Called when leaving a tab, and on every trusted editor edit, so a later Back/Forward
/// to its entry (or a reload) restores the latest edit instead of the last run/URL snapshot.
/// Without this, an edit made after the last run lives only in memory, and the next `popstate`
/// would restore the stale entry (still holding the old query) and overwrite it. The `run=1`
/// marker is preserved while the editor still reflects the produced run (`tabReflectsRun`) and
/// dropped once it diverges, so an unrun edit never becomes auto-runnable while a clean tab
/// stays reproducible from a shared `run=1` link.
///
/// When an entry already exists it must belong to this tab — refreshing another tab's entry
/// would clobber it, so bail in that case. When there is NO state yet we still seed one: a
/// fresh load leaves `history.state` null until the first `syncHistory`/`postAll`, and the very
/// first trusted edit can land before `reconcileStartup` runs any of those (the no-saved-tabs
/// bare-URL path and the dirty-startup merge never call them). That initial entry is the page's
/// own and conceptually belongs to the active tab, so replace it in place (`writeHistoryEntry`
/// uses `replaceState` when there is no state, never pushing a spurious entry). Seeding records
/// the edit in `history.state`, the URL hash and `lastSavedQuery`; otherwise the stale URL hash
/// stays authoritative and the next reload either reopens a stale tab (bare URL) or overwrites
/// the persisted live tab (dirty-startup merge) with the pre-edit query.
function refreshCurrentHistoryEntry(tab) {
if (!tab) return;
const cur = history.state;
if (cur && cur.tabId !== tab.id) return;
writeHistoryEntry(tab, undefined, /*forceReplace=*/ true, /*fromRun=*/ tabReflectsRun(tab));
}
/// Called after an actual run: stash the result snapshot on the active tab (this is
/// what makes the tab "have a result", hence visible in the bar) and record history.
/// The snapshot records the query/params that produced it.
function saveHistory(stateData, titleForDoc) {
const tab = getActiveTab();
if (!tab) return;
tab.query = stateData.query;
tab.result = {
ran: true, /// marks a genuine run; the tab bar's visibility keys off this
/// The single statement that produced this result (in multi-query mode it differs
/// from the editor text in tab.query); used for rendering/graph-detection/downloads.
query: stateData.resultQuery ?? stateData.query,
params: stateData.params, /// the params that produced this result
format: stateData.format,
ok: stateData.ok,
data: stateData.data,
/// Per-statement snapshots for a multi-query ("Run all") result; null for single-query
/// runs and for multi-query runs too large to snapshot (see `postMulti`).
multi: stateData.multi ?? null,
/// Per-column color coding modes in effect when this result was produced. Travels with the
/// snapshot so re-activating the tab (or navigating to its entry / reloading) restores and
/// re-applies the same coloring; a later toggle updates it via `persistColorModes`.
color_modes: column_color_modes,
elapsed_ns: stateData.elapsed_ns,
};
/// This entry was produced by an actual run, so it may carry `run=1` (a reload/shared link
/// re-runs it). Editor-only writers (`syncHistory`/`refreshCurrentHistoryEntry`) never do.
writeHistoryEntry(tab, titleForDoc, /*forceReplace=*/ false, /*fromRun=*/ true);
}
/// Record a history entry for the active tab on tab switch / rename. Reuses the tab's
/// existing result (or none); does NOT fabricate a result, so a never-run single tab
/// stays result-less and the tab bar stays hidden. Keeps the entry's `run=1` marker while the
/// editor still reflects the produced run (`tabReflectsRun`) so switching INTO a run-backed tab
/// leaves its shared `run=1` URL as re-runnable as `saveHistory` left it; a result-less or
/// edited tab reports false and the marker is dropped.
function syncHistory() {
const tab = getActiveTab();
if (!tab) return;
writeHistoryEntry(tab, undefined, /*forceReplace=*/ false, /*fromRun=*/ tabReflectsRun(tab));
}
/// Resolve which tab a history state refers to. The `tabId` is the AUTHORITATIVE, stable
/// identity: when the state carries one it wins, and we never fall back to matching by title.
/// A same-titled tab may be an unrelated one the user has since renamed to the closed tab's
/// old title, and adopting it would let Back/Forward overwrite that live tab's query/result.
/// The title fallback exists ONLY for legacy entries predating `tabId`. When a state's
/// `tabId` names a tab that was closed, recreate a fresh tab (disambiguating its title) so
/// the navigation is non-destructive instead of hijacking an existing tab.
function resolveTabForState(state) {
let tab = state.tabId && tabs.find(t => t.id === state.tabId);
if (!tab && !state.tabId && state.tabName) tab = tabs.find(t => t.title === state.tabName);
if (!tab) {
const title = state.tabId ? uniqueTitle(state.tabName) : state.tabName;
tab = makeTab(title, state.query);
tabs.push(tab);
}
return tab;
}
window.onpopstate = function(event) {
if (!event.state) return;
captureActiveTab();
/// Back/Forward can switch tabs while a query is in flight; invalidate it just like a
/// click switch, so a late response is not saved into the tab we are navigating to.
invalidateInFlight();
const token = request_num;
const tab = resolveTabForState(event.state);
/// Keep this history entry's identity in sync with the tab it actually resolves to, restamping
/// BOTH the state's `tabId`/`tabName` and the URL's `tab=` key. Two cases need it:
/// - The entry named a now-closed tab and we recreated a fresh one (`tabId` changed): adopt
/// the new id so repeated Back/Forward lands on the same recreated tab instead of spawning
/// a duplicate each time.
/// - The entry's `tabId` still resolves to a live tab, but that tab's title has since changed
/// (e.g. the startup dirty-merge de-conflicted a live bootstrap tab's title): the entry/URL
/// still hold the stale `tabName`.
/// In both cases, without the restamp a later reload of this URL would make startup
/// reconciliation match a persisted tab by the stale title and overwrite it with this entry's
/// query. (Normal and legacy title-only entries already match and are left untouched.)
if (event.state.tabId && (event.state.tabId !== tab.id || event.state.tabName !== tab.title)) {
const restamped_url = new URL(location.href);
restamped_url.searchParams.set('tab', tab.title);
history.replaceState({ ...event.state, tabId: tab.id, tabName: tab.title },
document.title, restamped_url.href);
}
activeTabId = tab.id;
tab.query = event.state.query;
/// This history entry is now the tab's URL-synced query, so it is also the query the saved
/// tab is "in sync" with: keep `lastSavedQuery` aligned with it. Otherwise a later edit and
/// reload (while the URL still holds this entry) would be misread by startup reconciliation
/// as a fresh external URL and overwrite the edit (`lastSavedQuery` would still point at the
/// newer entry that `writeHistoryEntry` last recorded, not at this one).
tab.lastSavedQuery = event.state.query;
if (event.state.params) tab.params = event.state.params;
/// Adopt the entry's result snapshot (its own producing query/params), or clear it. This
/// avoids leaving a stale snapshot attached to a different query. Modern entries nest the
/// snapshot under `result`; legacy flat entries (predating per-tab state) store the produced
/// result at the TOP level, and `restoreFromHistory` still renders them via `state.result ||
/// state`. Normalize such a flat entry into a nested snapshot rather than treating it as "no
/// result" — otherwise the tab bar would hide despite a visible result and the next
/// persist/switch would drop the restored snapshot. A truly result-less entry stays null.
if (event.state.result) {
tab.result = event.state.result;
} else if (event.state.data != null || (event.state.multi && event.state.multi.length)) {
const flat = event.state;
tab.result = {
ran: true,
query: flat.query,
params: flat.params || {},
format: flat.format,
ok: flat.ok,
data: flat.data ?? null,
multi: flat.multi ?? null,
color_modes: flat.color_modes || {},
elapsed_ns: flat.elapsed_ns,
};
} else {
tab.result = null;
}
restoreFromHistory(event.state, token).then(() => {
if (token !== request_num) return; /// a newer navigation/run superseded this one
/// Mirror `activateTab`: always recolor the page (and thus the active-tab slab) to this
/// tab's query. `restoreFromHistory` only recolors on its full-result path, so a
/// Back/Forward into a result-less, error, or large/image-result tab would otherwise keep
/// the previously active tab's hue even though the active tab has changed.
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(tab.query));
renderTabBar();
});
scheduleSave();
};
if (window.location.hash) {
query_area.value = fromBase64(window.location.hash.substr(1));
query_area.dispatchEvent(new Event('input'));
}
let in_flight = false;
// Favicon changes to gray while a query is running, to indicate progress on the browser tab.
const favicon_default = document.querySelector('link[rel="icon"]').href;
const favicon_running = 'data:image/svg+xml;base64,' + btoa(atob(favicon_default.split(',')[1]).replace('#ff0', '#bbb'));
function setFavicon(href) { document.querySelector('link[rel="icon"]').href = href; }
function beginFlight() {
in_flight = true;
setFavicon(favicon_running);
document.getElementById('run').innerText = 'Stop';
document.getElementById('run-all').style.display = 'none';
/// Hide any open autocompletion when a query starts running.
closeCompletions();
}
function endFlight() {
in_flight = false;
document.getElementById('run').blur();
updateRunButtons(isMultiQuery);
setFavicon(favicon_default);
}
/// Run the query under cursor, or all queries intersecting the selection.
async function postOne()
{
/// Capture the request id now; `getQueryUnderCursor`/`splitAllQueries` await the lexer
/// (WASM) load, during which a tab switch may bump `request_num`. Reading the global
/// after the await would let this stale run execute/save in the wrong tab, so bail if
/// it changed and pass the captured id (not the global) to the post functions.
///
/// A tab activation / Back-Forward restore already in flight for this generation has not
/// yet written the destination tab's saved parameters to the `param_*` inputs (that write
/// happens only after `restoreFromHistory`'s tokenization await, which the `++request_num`
/// below aborts). Detect it before the bump so the run sources its parameters from the
/// destination tab's own saved snapshot instead of the previous tab's stale DOM inputs.
const params_restore_pending = params_restore_pending_token === request_num;
const my_request_num = ++request_num;
document.body.scrollTo(0, 0);
beginFlight();
const has_selection = query_area.selectionStart !== query_area.selectionEnd;
if (has_selection && isMultiQuery) {
/// Find all queries that intersect the selection and run them.
const all_text = query_area.value;
const parsed = await splitAllQueries(all_text);
if (my_request_num !== request_num) return;
const selStart = query_area.selectionStart;
const selEnd = query_area.selectionEnd;
/// Use start/end offsets from splitAllQueries to find which queries overlap with the selection.
const selected = [];
for (let i = 0; i < parsed.length; ++i) {
if (parsed[i].end > selStart && parsed[i].start < selEnd) {
selected.push(parsed[i]);
}
}
if (selected.length > 1) {
await postMulti(my_request_num, selected, params_restore_pending);
} else if (selected.length === 1) {
await postSingle(my_request_num, selected[0].query, all_text, params_restore_pending);
}
} else {
const query = await getQueryUnderCursor();
if (my_request_num !== request_num) return;
/// In multi-query mode, preserve the full textarea content in history/URL
/// even when running a single query under cursor (so refresh restores all queries).
await postSingle(my_request_num, query, isMultiQuery ? query_area.value : undefined, params_restore_pending);
}
/// Only reset the run/favicon/cancel state if this entrypoint still owns the in-flight
/// state; a newer run (e.g. after a tab switch) may have taken over while we awaited.
if (my_request_num === request_num) endFlight();
}
/// Run all queries from the textarea.
async function postAll()
{
/// See `postOne`: detect an in-flight tab activation / Back-Forward parameter restore for
/// this generation before `++request_num` aborts it, so the run reads the destination tab's
/// saved parameters rather than the previously active tab's stale DOM inputs.
const params_restore_pending = params_restore_pending_token === request_num;
const my_request_num = ++request_num;
document.body.scrollTo(0, 0);
beginFlight();
const all_text = query_area.value;
const parsed = await splitAllQueries(all_text);
if (my_request_num !== request_num) return;
if (parsed.length > 1) {
await postMulti(my_request_num, parsed, params_restore_pending);
} else {
const query = await getQueryUnderCursor();
if (my_request_num !== request_num) return;
await postSingle(my_request_num, query, undefined, params_restore_pending);
}
/// Only reset the run/favicon/cancel state if this entrypoint still owns the in-flight
/// state; a newer run (e.g. after a tab switch) may have taken over while we awaited.
if (my_request_num === request_num) endFlight();
}
async function cancel()
{
/// Invalidate in-flight requests so postImpl/postSingle/postMulti will
/// see a stale posted_request_num and bail out before saving history.
++request_num;
/// Cancel single-query mode controller.
if (controller) controller.abort();
/// Cancel all multi-query mode controllers.
for (const ac of multiQueryControllers) {
ac.abort();
}
multiQueryControllers = [];
endFlight();
}
function storeCredentials() {
if (password_elem.value) {
const cred = new PasswordCredential({
id: user_elem.value,
password: password_elem.value,
name: url_elem.value,
});
navigator.credentials.store(cred);
}
}
/// The "Run" / "Run one" button submits the form; handle it here.
document.getElementById('controls').addEventListener('submit', e =>
{
e.preventDefault();
markBootstrapDirty(e); /// a manual run before reconciliation must keep this tab's state
if (in_flight) {
cancel();
} else {
postOne();
storeCredentials();
}
});
/// The "Run all" button.
document.getElementById('run-all').addEventListener('click', e =>
{
e.preventDefault();
markBootstrapDirty(e);
if (!in_flight) {
postAll();
storeCredentials();
}
});
/// On Mac, the shortcut is Cmd+Enter; elsewhere it is Ctrl+Enter. Show only the relevant modifier in the hint.
/// Match case-insensitively: UA Client Hints reports macOS as "macOS" and iOS as "iOS",
/// while the legacy navigator.platform reports "MacIntel", "iPhone", "iPad", etc.
const isMac = /mac|iphone|ipad|ipod|ios/i.test(navigator.userAgentData?.platform || navigator.platform || '');
document.getElementById('hint-modifier').innerText = isMac ? 'Cmd' : 'Ctrl';
document.addEventListener('keydown', event => {
/// Firefox has code 13 for Enter and Chromium has code 10.
if ((event.metaKey || event.ctrlKey) && (event.keyCode == 13 || event.keyCode == 10)) {
markBootstrapDirty(event);
if (in_flight) {
cancel();
} else if (event.shiftKey && isMultiQuery) {
/// Ctrl/Cmd+Shift+Enter runs all queries in multi-query mode.
postAll();
storeCredentials();
} else {
postOne();
storeCredentials();
}
event.preventDefault();
}
});
/// Pressing Tab in textarea will increase indentation.
/// But for accessibility reasons, we will fall back to tab navigation if the user already used Tab for that.
let user_prefers_tab_navigation = false;
[...document.querySelectorAll('input')].map(elem => {
elem.onkeydown = (e) => {
if (e.key == 'Tab') { user_prefers_tab_navigation = true; }
};
});
query_area.onkeydown = (e) => {
if (e.key == 'Tab' && !event.shiftKey && !user_prefers_tab_navigation) {
document.execCommand('insertText', false, ' ');
e.preventDefault();
return false;
} else if (e.key === 'Enter' && !(event.metaKey || event.ctrlKey)) {
// If the user presses Enter, and the previous line starts with spaces,
// then we will insert the same number of spaces.
const elem = e.target;
if (elem.selectionStart !== elem.selectionEnd) {
// If there is a selection, then we will not insert spaces.
return;
}
const cursor_pos = elem.selectionStart;
const elem_value = elem.value;
const text_before_cursor = elem_value.substring(0, cursor_pos);
const text_after_cursor = elem_value.substring(cursor_pos);
const prev_lines = text_before_cursor.split('\n');
const prev_line = prev_lines.pop();
const lead_spaces = prev_line.match(/^\s*/)[0];
if (!lead_spaces) {
return;
}
// Add leading spaces to the current line.
elem.value = text_before_cursor + '\n' + lead_spaces + text_after_cursor;
elem.selectionStart = cursor_pos + lead_spaces.length + 1;
elem.selectionEnd = elem.selectionStart;
// Notify listeners (e.g. the syntax-highlighting backdrop) that the textarea
// value changed — direct assignment to `value` does not fire `input`.
elem.dispatchEvent(new Event('input', { bubbles: true }));
e.preventDefault();
return false;
} else if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
// Comment/uncomment selected lines on Ctrl+/ or Cmd+/
const elem = e.target;
const start = elem.selectionStart;
const end = elem.selectionEnd;
const value = elem.value;
let lineStart = value.lastIndexOf('\n', start - 1) + 1;
let lineEnd = value.indexOf('\n', end);
if (lineEnd === -1) lineEnd = value.length;
let selectedText = value.substring(lineStart, lineEnd);
let lines = selectedText.split('\n');
// Comment out each line, or uncomment if all lines are already commented
const allCommented = lines.every(line => /^\s*--/.test(line));
// If all lines are commented out, uncomment them
if (allCommented) {
lines = lines.map(line => line.replace(/^(\s*)-- ?/, '$1'));
} else {
lines = lines.map(line => {
if (/^\s*$/.test(line)) {
return line;
}
return line.replace(/^(\s*)/, '$1-- ');
});
}
const replacedText = lines.join('\n');
elem.value = value.slice(0, lineStart) + replacedText + value.slice(lineEnd);
// Adjust selection to keep the user in flow and enable quick reversal
elem.selectionStart = lineStart;
elem.selectionEnd = lineStart + replacedText.length;
// Notify listeners (e.g. the syntax-highlighting backdrop) that the textarea
// value changed — direct assignment to `value` does not fire `input`.
elem.dispatchEvent(new Event('input', { bubbles: true }));
e.preventDefault();
return false;
}
};
function clear()
{
resultEl.clear();
resultEl.style.display = '';
progressEl.clear();
logoEl.style.display = 'block';
elapsed_ns = 0;
/// Clean up multi-query results.
if (multiQueryContainer) {
multiQueryContainer.innerHTML = '';
multiQueryContainer.style.display = 'none';
}
}
function formatReadable(number = 0, decimals = 2, units = []) {
const k = 1000;
const i = number ? Math.floor(Math.log(number) / Math.log(k)) : 0;
const unit = units[i];
const dm = unit ? decimals : 0;
return Number(number / Math.pow(k, i)).toFixed(dm) + unit;
}
function formatReadableBytes(bytes) {
const units = [' B', ' KB', ' MB', ' GB', ' TB', ' PB', ' EB', ' ZB', ' YB'];
return formatReadable(bytes, 2, units);
}
function formatReadableRows(rows) {
const units = ['', ' thousand', ' million', ' billion', ' trillion', ' quadrillion'];
return formatReadable(rows, 2, units);
}
/* Clicking outside data cells deactivates the selected cell */
document.addEventListener('click', e => {
if (e.target.closest('query-result')) return;
resultEl.clearSelection();
});
function appendTextareaResizer() {
const queryDiv = document.getElementById('query_div');
const textarea = document.getElementById('query');
let drag_state = {
elem: null,
is_dragging: false,
offset_y: null,
offset_height: null
};
const start = (e) => {
if (e.button !== 0) { return; }
drag_state.offset_y = e.clientY;
drag_state.offset_height = textarea.offsetHeight;
drag_state.is_dragging = true;
drag_state.elem = e.target;
drag_state.elem.classList.add('textarea-resizer-dragging');
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', stop);
document.addEventListener('pointercancel', stop);
}
const move = (e) => {
if (!drag_state.is_dragging) { return; }
const dy = e.clientY - drag_state.offset_y;
const newHeight = drag_state.offset_height + dy;
textarea.style.height = `${newHeight}px`;
}
const stop = (e) => {
drag_state.is_dragging = false;
drag_state.elem.classList.remove('textarea-resizer-dragging');
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', stop);
document.removeEventListener('pointercancel', stop);
}
const resizer = document.createElement('div');
resizer.className = 'textarea-resizer';
resizer.addEventListener('pointerdown', start);
resizer.addEventListener('touchstart', (e) => e.preventDefault());
queryDiv.appendChild(resizer);
}
function resizeQueryAreaIfSlightlyOverflowing()
{
const textarea = document.getElementById('query');
const current_height = textarea.offsetHeight;
if (!current_height) { return; }
const style = window.getComputedStyle(textarea);
const vertical_border = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
const required_height = textarea.scrollHeight + vertical_border;
const max_auto_resize_height = current_height * 1.3;
if (required_height > current_height && required_height <= max_auto_resize_height)
{
textarea.style.height = `${required_height}px`;
}
}
let lexer_module;
async function loadLexer() {
// base64 -w0 build/src/Parsers/Lexer.wasm
const lexer_base64 = "AGFzbQEAAAABHAVgAX8Bf2ACf38AYAAAYAR/f39/AGADf39/AX8DCQgCAQEDBAAAAAUDAQACBkULfwFBkIgEC38AQYAIC38AQYAIC38AQYQIC38AQZAIC38AQZCIBAt/AEGACAt/AEGQiAQLfwBBgIAIC38AQQALfwBBAQsHlgMTBm1lbW9yeQIAEV9fd2FzbV9jYWxsX2N0b3JzAAAYX1pOMkRCNUxleGVyOW5leHRUb2tlbkV2AAEdX1pOMkRCNUxleGVyMTNuZXh0VG9rZW5JbXBsRXYAAhdjbGlja2hvdXNlX2xleGVyX2NyZWF0ZQADG2NsaWNraG91c2VfbGV4ZXJfbmV4dF90b2tlbgAEJWNsaWNraG91c2VfbGV4ZXJfdG9rZW5faXNfc2lnbmlmaWNhbnQABR9jbGlja2hvdXNlX2xleGVyX3Rva2VuX2lzX2Vycm9yAAYdY2xpY2tob3VzZV9sZXhlcl90b2tlbl9pc19lbmQABxVjbGlja2hvdXNlX2xleGVyX3NpemUDAQxfX2Rzb19oYW5kbGUDAgpfX2RhdGFfZW5kAwMLX19zdGFja19sb3cDBAxfX3N0YWNrX2hpZ2gDBQ1fX2dsb2JhbF9iYXNlAwYLX19oZWFwX2Jhc2UDBwpfX2hlYXBfZW5kAwgNX19tZW1vcnlfYmFzZQMJDF9fdGFibGVfYmFzZQMKDAEBCoguCAMAAQtNAQF/IAAgARACAkACQAJAIAEoAgwiAkUNACAAKAIIIAEoAgAgAmpNDQBBMCECIABBMDoAAAwBCyAALQAAIgJBAkkNAQsgASACOgAQCwu+LAELfyABKAIEIgQgASgCCCIDTwRAIAAgAzYCCCAAIAM2AgQgAEEnOgAADwsgAUEEaiEGAkACQAJAAkACQAJAAkACQAJAAkACQAJAAn8CQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAELQAAIgpBIGsOXgEWBBMgFCQDBgcRDwwQDhICAgICAgICAgICGw0XFRgZHSQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkCB4JGiQFJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQKHAsACyAKQQlrQQVJDQAgCkHiAUcNIyAEQQNqIgEgA0sNHiAELQABQYgBRw0eIAQtAAJBkgFHDR4gACABNgIIIAAgBDYCBCAAQRQ6AAAMLAsgBiAEQQFqIgE2AgACQCABIANPDQADQCABLQAAIgJBCWtBBU8gAkEgR3ENASAGIAFBAWoiATYCACABIANHDQALIAMhAQsMLAsgAS0AEEEPRgRAIAYgBEEBaiIBNgIAIAEgA08NKANAIAEtAAAiAkEwa0H/AXFBCk8EQCACQd8ARw0qIAFBAWoiAiADTw0qIAItAABBMGtB/wFxQQlLDSoLIAYgAUEBaiIBNgIAIAEgA0cNAAsgAyEBDCgLIApBMEcgBEECaiIBIANPcg0kIARBAWohAgJAIAQtAAEiBUHhAE0EQCAFQcIARg0gIAVB2ABGDQEMJgsgBUHiAEYNHyAFQfgARw0lC0EBIQUgAS0AACIJQTBrQf8BcUEKSQ0jQQAgCUHBAGsiCUH/AXFBJk8NJRpCv4CAgPAHIAmtiKdBAXFFDSYMIwsgBiAGKAIAQQFqIgI2AgACQANAAkAgAiADRg0AA0AgAi0AACIBQSdGIAFB3ABGcg0BIAJBAWoiAiADRw0ACyADIQILIAYgAjYCAEEqIQUCQCACIANPDQACQCACLQAAIgFB3ABHBEAgAUEnRw0DIAYgAkEBaiIBNgIAQQQhBSABIANPDQQgAS0AAEEnRg0BDAQLIAYgAkEBaiIBNgIAIAEgA08NAQsgBiACQQJqIgI2AgAMAQsLIAMhAQsgACABNgIIIAAgBDYCBCAAIAU6AAAPCyAGIAYoAgBBAWoiAjYCAAJAA0ACQCACIANGDQADQCACLQAAIgFBIkYgAUHcAEZyDQEgAkEBaiICIANHDQALIAMhAgsgBiACNgIAQSshBQJAIAIgA08NAAJAIAItAAAiAUHcAEcEQCABQSJHDQMgBiACQQFqIgE2AgBBBSEFIAEgA08NBCABLQAAQSJGDQEMBAsgBiACQQFqIgE2AgAgASADTw0BCyAGIAJBAmoiAjYCAAwBCwsgAyEBCyAAIAE2AgggACAENgIEIAAgBToAAA8LIAYgBigCAEEBaiICNgIAAkADQAJAIAIgA0YNAANAAkAgAi0AAEHcAGsOBQIAAAACAAsgAkEBaiICIANHDQALIAMhAgsgBiACNgIAQSwhBQJAIAIgA08NAAJAAkACQCACLQAAQdwAaw4FAQQEBAAECyAGIAJBAWoiATYCAEEFIQUgASADTw0EIAEtAABB4ABGDQEMBAsgBiACQQFqIgE2AgAgASADTw0BCyAGIAJBAmoiAjYCAAwBCwsgAyEBCyAAIAE2AgggACAENgIEIAAgBToAAA8LIAAgBDYCBCAAQQY6AAAMKAsgACAENgIEIABBBzoAAAwnCyAAIAQ2AgQgAEEIOgAADCYLIAAgBDYCBCAAQQk6AAAMJQsgACAENgIEIABBCjoAAAwkCyAAIAQ2AgQgAEELOgAADCMLIAAgBDYCBCAAQQw6AAAMIgsgACAENgIEIABBDToAAAwhCwJAIAQgASgCAE0NAAJAIARBAWoiAiADTw0AIAItAABBMGtB/wFxQQlLDQAgAS0AECIBQQlLQQEgAXRBrAVxRXINAQsgACACNgIIIAAgBDYCBCAAQQ86AAAgBiACNgIADwsgBiAEQQFqIgI2AgAgBCEFIAIgA08NFiAEQQJqIQFBASEFA0AgAUEBay0AACICQTBrQf8BcUEKTwRAIAUgAkHfAEdyQQFxIAEgA09yDRUgAS0AAEEwa0H/AXFBCUsNFQsgBiABNgIAIAEgA0ZBACEFIAFBAWohAUUNAAsgAUECayEFIAMhAgwWCyAAIAQ2AgQgAEETOgAADB8LIAYgBEEBaiICNgIAAkAgAiADTw0AIAItAAAiAUEtRwRAIAFBPkcNASAAIAQ2AgQgAEEXOgAADCELDBsLIAAgAjYCCCAAIAQ2AgQgAEEUOgAADwsgACAENgIEIABBEDoAAAwdCyAGIARBAWoiAjYCAAJAIAIgA08NACACLQAAIgFBKkcEQCABQS9HDQEMGgsgBiAEQQJqIgE2AgAgAyAEQQRqIgJPBEBBASEIA0ACQAJAAkACQCABLQAAIgVBKkcEQCAFQS9HDQMgAS0AAUEqRw0DIAYgAjYCACAIQQFqIQgMAQsgAS0AAUEvRw0CIAYgAjYCACAIQQFrIghFDQELIAIhAQwCCyAAIAI2AgggACAENgIEIABBAToAAA8LIAYgAUEBaiIBNgIACyABQQJqIgIgA00NAAsLIAAgAzYCCCAAIAQ2AgQgAEEpOgAAIAYgAzYCAA8LIAAgAjYCCCAAIAQ2AgQgAEEVOgAADwsgBiAEQQFqIgE2AgACQCABIANPDQAgAS0AAEH+AXFBIEcNAAJAA0AgAS0AAEEKRg0BIAFBAWoiASADRw0ACyADIQELDBkLDB0LIAAgBDYCBCAAQRY6AAAMGgsgBiAEQQFqIgE2AgACQCABIANPDQAgAS0AAEE9Rw0AIAYgBEECaiIBNgIACyAAIAE2AgggACAENgIEIABBHDoAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBPUcNACAAIAQ2AgQgAEEdOgAADBoLIAAgATYCCCAAIAQ2AgQgAEEtOgAADwsgBiAEQQFqIgI2AgACQCAEQQJqIgEgA08NACACLQAAQT1HDQAgAS0AAEE+Rw0AIAAgBDYCBCAAQSI6AAAgACAEQQNqIgA2AggMGwsCQCACIANPDQACQAJAIAItAABBPWsOAgABAgsgACABNgIIIAAgBDYCBCAAQSA6AAAMFwsgACABNgIIIAAgBDYCBCAAQR06AAAMFgsgACACNgIIIAAgBDYCBCAAQR46AAAPCyAGIARBAWoiATYCAAJAIAEgA08NACABLQAAQT1HDQAgACAENgIEIABBIToAAAwYCyAAIAE2AgggACAENgIEIABBHzoAAA8LIAAgBDYCBCAAQRg6AAAMFQsgACAENgIEIABBGjoAAAwUCyAGIARBAWoiATYCAAJAIAEgA08NACABLQAAQTpHDQAgACAENgIEIABBGzoAAAwVCyAAIAE2AgggACAENgIEIABBGToAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABB/ABHDQAgACAENgIEIABBJDoAAAwUCyAAIAE2AgggACAENgIEIABBIzoAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBwABHDQAgACAENgIEIABBJjoAAAwTCyAAIAE2AgggACAENgIEIABBJToAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBxwBHDQAgACAENgIEIABBDjoAAAwSCwwSCyAEQQVqIANPDQQgBC0AAUGAAUcNBAJAIAQtAAIiB0GYAWsOBQAFBQUABQsgBiABNgIAIAAhAkEEQQUgB0GYAUYiABshAUEqQSsgABshACAGKAIAIQUgB0EBasBB/wFxIQkDQAJAAkAgAyAFRg0AA0AgBS0AAEHiAUYNASAFQQFqIgUgA0cNAAsgAyEFCyAGIAU2AgACQAJAIAMgBUECaiIHTQRAIAAhAQwBCyAFLQAAQeIBRw0BIAUtAAFBgAFHDQEgBy0AACAJRw0BIAYgBUEDaiIDNgIACyACIAM2AgggAiAENgIEIAIgAToAAAwBCyAGIAVBAWoiBTYCAAwBCwsPCyAEQQFqIgIhBQJAIAIgA0YNACACIQUDQCAFLQAAQSRGDQEgBUEBaiIFIANHDQALIAMhBQsgAyAFRg0CIAVBAWoiCSAEayEHIAIhAQNAIAEgBUkEQCABLQAAIgtBMGshCCABQQFqIQEgC0HfAEYgCEH/AXFBCklyIAtB3wFxQcEAa0H/AXFBGklyDQEMBAsLAn8CQCAHIAMgCSIBayILTQRAIAdBAWohCANAQQAhBQJAIAdFDQADQCABIAVqLQAAIAQgBWotAABHDQEgByAFQQFqIgVHDQALDAMLIAUgB0YNAiABQQFqIQEgCCAMaiAMQQFqIQwgC00NAAsLQX8MAQsgDAsiAUF/Rg0CIAAgBDYCBCAAQRE6AAAgACABIAlqIAdqIgA2AggMEQtBACEFIAEtAABB/gFxQTBGDQQMBwsgAUEBayECIAFBAmshBQwCCyACIANJBEBBASEFIAItAAAiAUHfAEYgAUE6a0H/AXFB9QFLciABQd8BcUHbAGtB/wFxQeUBS3INAQsgACACNgIIIAAgBDYCBCAAQRI6AAAgBiACNgIADwsCQCAEQQJqIANPDQAgBC0AAUEnRw0AAkAgCkHhAE0EQCAKQcIARiAKQdgARnINAQwCCyAKQfgARg0AIApB4gBHDQELIAAhASAGKAIAIgAtAAAhAiAGIABBAmoiADYCAAJAIAJBIHJB+ABGBEAgACADTw0BA0AgAC0AACICQTBrQf8BcUEKSSACQeEAa0EGSXJFIAJBwQBrQQVLcQ0CIAYgAEEBaiIANgIAIAAgA0cNAAsgAyEADAELAkAgACADRg0AA0AgAC0AAEH+AXFBMEcNASAAQQFqIgAgA0cNAAsgAyEACyAGIAA2AgALQSohAiAAIANJBEAgAEEBaiADIAAtAABBJ0YiABshA0EEQSogABshAgsgASADNgIIIAEgBDYCBCABIAI6AAAgBiADNgIADwsgCkEwa0H/AXFBCkkgCkHfAXFBwQBrQf8BcUEaSXIgCkHfAEZyIAVyQQFGBEAgBEEBaiEFA0ACQCAGIAUiATYCACABIANPDQAgAUEBaiEFIAEtAAAiB0EwayECIAdBJEYgB0HfAEZyIAJB/wFxQQpJIAdB3wFxQcEAa0H/AXFBGklycg0BCwsMEAsCQCADIAQiAU0NAANAAn9BASABLQAAIgJBCWtBBUkNABpBASACQSBGDQAaIAJBwgFHIAFBAWoiByADT3JFBEBBAiAHLQAAIgJBhQFGIAJBoAFGcg0BGgwDCyABQQJqIgUgA08NAgJAAkACQAJAAkAgAkHhAWsOAwECAwALIAJB7wFHDQYgBy0AAEG7AUcNBiAFLQAAQb8BRg0DDAYLIActAABBoAFHDQUgBS0AAEGOAUYNAgwFCwJAAkAgBy0AAEGAAWsOAgABBgtBAyAFLAAAIgJBi39IIAJBfnFBqH9Gcg0DGkEDIAJB/wFxIgJBiwFrQQNJIAJBrwFGcg0DGgwFCyAFLQAAQeEAakH/AXFBAkkNAQwECyAHLQAAQYABRw0DIAUtAABBgAFHDQMLQQMLIAFqIgEgA0kNAAsLIAYgATYCACABIARNBEAgAUEBaiECA0ACQCAGIAIiATYCACABIANPDQAgAUEBaiECIAEsAABBQEgNAQsLDA0LDAkLAkAgBUECaiIBIANPDQAgAi0AAEEgckHlAEcNACAGIAE2AgACQCAFQQNqIgIgA08NAAJAIAEtAABBK2sOAwABAAELIAYgAjYCACACIQELAkAgASADTw0AQQEhAgNAIAEtAAAiBUEwa0H/AXFBCk8EQCACIAVB3wBHckEBcQ0CIAFBAWoiAiADTw0CIAItAABBMGtB/wFxQQlLDQILIAYgAUEBaiIBNgIAQQAhAiABIANHDQALIAMhAgwBCyABIQILIAAgAjYCCAwNC0EBIQggASECIAUhBwwCCyAEQQFqIQJBAAshBwsgBiACNgIAAkACQCACIANPDQADQCACLQAAIgVBMGshAQJAAkAgBwRAIAFB/wFxQQpJIAVBwQBrQQZJcg0CIAVB4QBrQQZPDQEMAgsgAUH/AXFBCkkNAQsgCCAFQd8AR3JBAXFFBEACQAJAIAJBAWoiCSADTw0AIAktAAAiBUEwayEBIAdFDQEgAUH/AXFBCkkgBUHBAGtBBklyDQAgBUHhAGtBBk8NBgsgAyAJRw0CDAQLIAFB/wFxQQpJDQEMBAsgBUEuRw0CIAYgAkEBaiICNgIAIAIgA08NAkEBIQgDQCACLQAAIgVBMGshAQJAAkAgBwRAIAFB/wFxQQpJIAVBwQBrQQZJcg0CIAVB4QBrQQZPDQEMAgsgAUH/AXFBCkkNAQsgCCAFQd8AR3JBAXENBAJAIAJBAWoiCSADTw0AIAktAAAiBUEwayEBIAcEQCABQf8BcUEKSSAFQcEAa0EGSXINASAFQeEAa0EGTw0HDAELIAFB/wFxQQlLDQYLIAMgCUYNBAsgBiACQQFqIgI2AgBBACEIIAIgA0kNAAsMAgsgBiACQQFqIgI2AgBBACEIIAIgA0cNAAsLIAJBAWoiASADTw0AIAItAAAhBQJAIAcEQCAFQSByQfAARw0CDAELIAVBIHJB5QBHDQELIAYgATYCAAJAIAJBAmogA08NAAJAIAEtAABBK2sOAwABAAELIAYgAUEBaiIBNgIAIAEgA08NAgtBASECA0AgAS0AACIFQTBrQf8BcUEKTwRAIAIgBUHfAEdyQQFxDQMgAUEBaiICIANPDQMgAi0AAEEwa0H/AXFBCUsNAwsgBiABQQFqIgE2AgBBACECIAEgA0cNAAsMAQsgAiEBCwJ/AkACQCABIANPDQAgAS0AACICQd8ARiACQTBrQf8BcUEKSXJFIAJB3wFxQcEAa0H/AXFBGUtxDQAgAUEBaiEFA0AgBiAFIgE2AgAgASADTw0CIAFBAWohBSABLQAAIgdBMGshAiAHQd8ARiACQf8BcUEKSXIgB0HfAXFBwQBrQf8BcUEaSXINAAsgAUEBawwCCyAAIAE2AggMCgsgAUEBawshAwJAIAEgBE0NACAEQQFrIQUDQCAFQQFqIgUtAAAiAkEkRiACQd8ARnIgAkEwa0H/AXFBCklyRSACQd8BcUHBAGtB/wFxQRpPcUUEQCADIAVLDQEMAgsLIAAgATYCCCAAIAQ2AgQgAEEvOgAADwsMCQsgBiAEQQJqIgE2AgACQCABIANGDQADQCABLQAAQQpGDQEgAUEBaiIBIANHDQALIAMhAQsLIAAgATYCCCAAIAQ2AgQgAEEBOgAACyAGIAE2AgAPCyAAIAE2AgggACAENgIEIABBADoAAA8LIAAgBEEBaiIANgIIDAILIAAgBEECaiIANgIIDAELIAAgATYCCCAAIAQ2AgQgAEEoOgAADwsgBiAANgIADwsgACAENgIEIABBAzoAAA8LIAAgATYCCCAAIAQ2AgQgAEECOgAACyUAIABBADoAECAAIAM2AgwgACACNgIIIAAgATYCBCAAIAE2AgALNgEBfyMAQRBrIgMkACADQQRqIAAQASABIAMoAgg2AgAgAiADKAIMNgIAIAMtAAQgA0EQaiQACwcAIABBAUsLBwAgAEEnSwsHACAAQSdGCwsIAQBBgAgLARQAeQlwcm9kdWNlcnMBDHByb2Nlc3NlZC1ieQEFY2xhbmdZMjAuMC4wZ2l0IChnaXRAZ2l0aHViLmNvbTpsbHZtL2xsdm0tcHJvamVjdC5naXQgNmNiYzY0ZWQ5MjJjYzY5YmMyOTJkMzk0YmE1YzY4MWZhMzA5ZjQwNCkAaw90YXJnZXRfZmVhdHVyZXMGKw9tdXRhYmxlLWdsb2JhbHMrE25vbnRyYXBwaW5nLWZwdG9pbnQrC2J1bGstbWVtb3J5KwhzaWduLWV4dCsPcmVmZXJlbmNlLXR5cGVzKwptdWx0aXZhbHVl";
if (!lexer_module) {
const binary = atob(lexer_base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
lexer_module = await WebAssembly.instantiate(bytes);
}
}
async function tokenize(query) {
await loadLexer();
let exports = lexer_module.instance.exports;
let buffer = exports.memory.buffer;
let memory_offset = 0;
/// Allocate memory for the lexer object
const lexer = new Uint8Array(buffer, memory_offset, exports.clickhouse_lexer_size);
memory_offset += exports.clickhouse_lexer_size;
/// Allocate the query
const bytes = new TextEncoder().encode(query);
const query_array = new Uint8Array(buffer, memory_offset, bytes.length);
query_array.set(bytes);
const query_begin = memory_offset;
memory_offset += bytes.length;
const query_end = memory_offset;
/// Initialize the lexer
exports.clickhouse_lexer_create(lexer, query_begin, query_end, 65536);
/// Allocate the out ptrs
const token_begin = memory_offset;
memory_offset += 4;
const token_end = memory_offset;
memory_offset += 4;
let result = [];
while (true) {
const token_type = exports.clickhouse_lexer_next_token(lexer, token_begin, token_end);
if (exports.clickhouse_lexer_token_is_error(token_type) || exports.clickhouse_lexer_token_is_end(token_type)) {
break;
}
const view = new DataView(buffer);
const begin = view.getUint32(token_begin, true);
const end = view.getUint32(token_end, true);
const token_bytes = new Uint8Array(buffer, begin, end - begin);
let token = new TextDecoder().decode(token_bytes);
result.push({type: token_type, significant: exports.clickhouse_lexer_token_is_significant(token_type), token: token});
}
return result;
}
/// Regression coverage for this function lives in
/// `src/Parsers/tests/gtest_play_get_query_under_cursor.cpp` (a port of the token-walking
/// algorithm on top of the same `DB::Lexer`). Keep the two in sync when changing the logic.
async function getQueryUnderCursor() {
const all_queries = query_area.value;
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') {
return all_queries;
}
const tokens = await tokenize(all_queries);
const should_select_query = getQueryBoundaries(tokens, all_queries).length > 1;
const cursor_position = query_area.selectionStart;
let current_query_start = 0;
let current_offset = 0;
/// Track the most recently completed non-empty query so that if the cursor
/// is past the last `;` (with only whitespace/comments after it, or only
/// further delimiter-only segments like `;;`), we run that query instead
/// of returning an empty string or a bare delimiter.
let last_query_start = -1;
let last_query_end = -1;
for (const elem of tokens) {
if (current_query_start == current_offset && !elem.significant) {
current_query_start += elem.token.length;
}
const offset_before_token = current_offset;
current_offset += elem.token.length;
if (elem.token == ';') {
/// If `current_query_start == offset_before_token`, the segment up
/// to this `;` has no significant content (only whitespace/comments
/// that the leading-skip above has consumed, or nothing at all).
/// Treat such a delimiter-only segment as empty: do not run it and
/// do not record it as the last completed query.
const has_content = current_query_start < offset_before_token;
if (has_content && current_offset >= cursor_position) {
if (should_select_query) query_area.setSelectionRange(current_query_start, current_offset);
query_area.focus();
return all_queries.substring(current_query_start, current_offset);
}
if (has_content) {
last_query_start = current_query_start;
last_query_end = current_offset;
}
current_query_start = current_offset;
}
}
/// The cursor is past the last `;`. If the tail has no significant content
/// (only whitespace/comments, which the leading-skip loop above will have
/// advanced `current_query_start` past until it equals `current_offset`),
/// fall back to the query that ended at the last `;` rather than returning
/// an empty string. This is what handles the "single query ending in `;`
/// with the cursor placed after it" case.
if (current_query_start === current_offset && current_offset === all_queries.length && last_query_end >= 0) {
if (should_select_query) query_area.setSelectionRange(last_query_start, last_query_end);
query_area.focus();
return all_queries.substring(last_query_start, last_query_end);
}
/// Cursor is in the last query (no trailing semicolon).
const tail = all_queries.substring(current_query_start);
if (should_select_query) query_area.setSelectionRange(current_query_start, all_queries.length);
query_area.focus();
return tail;
}
const PARALLELIZABLE_KEYWORDS = new Set([
'SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXISTS', 'EXPLAIN', 'WITH'
]);
/// Split the textarea content into individual queries using the lexer.
/// Returns an array of { query, is_select } objects.
/// `is_select` is true for queries that start with a parallelizable keyword
/// (after skipping whitespace, comments, and opening brackets).
async function splitAllQueries(text) {
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') {
return [{ query: text, is_select: PARALLELIZABLE_KEYWORDS.has(text.trimStart().split(/\s/)[0].toUpperCase()), start: 0, end: text.length }];
}
const tokens = await tokenize(text);
const queries = [];
let current_query_start = 0;
let current_offset = 0;
let first_keyword = null; /// First significant non-bracket token of the current query.
let has_significant = false;
let first_significant_offset = -1;
let last_significant_end = -1;
for (const elem of tokens) {
current_offset += elem.token.length;
if (elem.token == ';') {
if (has_significant) {
const q = text.substring(current_query_start, current_offset).trim();
if (q && q !== ';') {
queries.push({
query: q,
is_select: first_keyword !== null && PARALLELIZABLE_KEYWORDS.has(first_keyword),
start: first_significant_offset,
end: last_significant_end,
});
}
}
current_query_start = current_offset;
first_keyword = null;
has_significant = false;
first_significant_offset = -1;
last_significant_end = -1;
} else if (elem.significant) {
if (!has_significant) {
first_significant_offset = current_offset - elem.token.length;
}
last_significant_end = current_offset;
has_significant = true;
if (first_keyword === null && elem.token !== '(') {
first_keyword = elem.token.toUpperCase();
}
}
}
/// Last query (no trailing semicolon)
if (has_significant) {
const last = text.substring(current_query_start).trim();
if (last && last !== ';') {
queries.push({
query: last,
is_select: first_keyword !== null && PARALLELIZABLE_KEYWORDS.has(first_keyword),
start: first_significant_offset,
end: last_significant_end,
});
}
}
return queries;
}
/// Return an array of { start, end } character offsets for each non-empty query.
/// Includes surrounding whitespace/semicolons so spans cover the full text without gaps.
/// Used by updateQueryParams to detect multi-query mode and to highlight query regions.
function getQueryBoundaries(tokens, text) {
const boundaries = [];
let current_query_start = 0;
let current_offset = 0;
let has_significant = false;
let first_significant_offset = -1;
let last_significant_end = -1;
for (const elem of tokens) {
current_offset += elem.token.length;
if (elem.token == ';') {
if (has_significant) {
boundaries.push({ start: first_significant_offset, end: last_significant_end });
}
current_query_start = current_offset;
has_significant = false;
first_significant_offset = -1;
last_significant_end = -1;
} else if (elem.significant) {
if (!has_significant) {
first_significant_offset = current_offset - elem.token.length;
}
last_significant_end = current_offset;
has_significant = true;
}
}
/// Last query (no trailing semicolon).
if (has_significant) {
boundaries.push({ start: first_significant_offset, end: last_significant_end });
}
return boundaries;
}
/// Show/hide the "Run all" button and relabel "Run" depending on multi-query mode and selection.
let isMultiQuery = false;
function updateRunButtons(multi) {
isMultiQuery = multi;
const runBtn = document.getElementById('run');
const runAllBtn = document.getElementById('run-all');
const has_selection = query_area.selectionStart !== query_area.selectionEnd;
const selects_all = has_selection
&& query_area.selectionStart === 0
&& query_area.selectionEnd >= query_area.value.length;
if (!in_flight) {
if (!multi) {
runBtn.innerText = 'Run';
} else {
runBtn.innerText = has_selection ? 'Run selected' : 'Run one';
}
}
runAllBtn.style.display = (multi && !selects_all) ? 'inline-block' : 'none';
document.getElementById('hint-run-all').style.display = multi ? '' : 'none';
}
/// Update the backdrop div behind the textarea: paint per-query active backgrounds
/// and per-token syntax-highlighting colors. The textarea above is in
/// `mix-blend-mode: difference`, so the colors here mix with the user's text.
const queryBackdrop = document.getElementById('query-backdrop');
function escapeHTML(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/// Numeric TokenType values, matching the order of the C++ enum in src/Parsers/Lexer.h.
/// Only the categories we actually classify are named; everything else is treated as
/// "default" (unstyled) and renders in the canonical text color via the diff blend.
const TT = {
Whitespace: 0, Comment: 1, BareWord: 2, Number: 3, StringLiteral: 4, QuotedIdentifier: 5,
OpeningRoundBracket: 6, ClosingRoundBracket: 7,
OpeningSquareBracket: 8, ClosingSquareBracket: 9,
OpeningCurlyBrace: 10, ClosingCurlyBrace: 11,
Semicolon: 13, Asterisk: 16, HereDoc: 17, DollarSign: 18,
Plus: 19, Minus: 20, Slash: 21, Percent: 22, Arrow: 23,
QuestionMark: 24, Colon: 25, Caret: 26, DoubleColon: 27,
Equals: 28, NotEquals: 29, Less: 30, Greater: 31,
LessOrEquals: 32, GreaterOrEquals: 33, Spaceship: 34,
PipeMark: 35, Concatenation: 36, At: 37, DoubleAt: 38,
};
/// SQL keywords recognized for highlighting. The lexer reports them as BareWord, so we
/// disambiguate identifiers from keywords here. Comparisons are case-insensitive.
const SQL_KEYWORDS = new Set([
'ADD', 'AFTER', 'ALL', 'ALTER', 'AND', 'ANTI', 'ANY', 'ARRAY', 'AS', 'ASC', 'ASCENDING',
'ASOF', 'AST', 'ASYNC', 'ATTACH', 'BACKUP', 'BEGIN', 'BETWEEN', 'BOTH', 'BY',
'CACHE', 'CASCADE', 'CASE', 'CAST', 'CHANGE', 'CHANGED', 'CHECK', 'CLEAR', 'CLUSTER',
'CODEC', 'COLLATE', 'COLUMN', 'COLUMNS', 'COMMENT', 'COMMIT', 'CONSTRAINT', 'CREATE',
'CROSS', 'CUBE', 'CURRENT',
'DATABASE', 'DATABASES', 'DAY', 'DEDUPLICATE', 'DEFAULT', 'DELETE', 'DESC', 'DESCENDING',
'DESCRIBE', 'DETACH', 'DICTIONARIES', 'DICTIONARY', 'DISK', 'DISTINCT', 'DISTRIBUTED',
'DROP', 'ELSE', 'END', 'ENGINE', 'ESTIMATE', 'EVENTS', 'EXCEPT', 'EXCHANGE', 'EXISTS',
'EXPLAIN', 'EXPRESSION', 'EXTENDED', 'EXTRACT',
'FALSE', 'FETCH', 'FETCHES', 'FILE', 'FILESYSTEM', 'FINAL', 'FIRST', 'FLUSH', 'FOLLOWING',
'FOR', 'FOREIGN', 'FORMAT', 'FREEZE', 'FROM', 'FULL', 'FUNCTION',
'GLOBAL', 'GRANT', 'GROUP', 'GROUPS', 'HAVING', 'HIERARCHICAL', 'HOUR',
'ID', 'IDENTIFIED', 'IF', 'ILIKE', 'IN', 'INDEX', 'INF', 'INHERIT', 'INJECTIVE',
'INNER', 'INSERT', 'INTERSECT', 'INTERVAL', 'INTO', 'INVISIBLE', 'IS', 'IS_OBJECT_ID',
'JOIN', 'KEY', 'KEYED', 'KILL',
'LAST', 'LATERAL', 'LAYOUT', 'LEADING', 'LEFT', 'LIFETIME', 'LIKE', 'LIMIT', 'LIMITS',
'LIVE', 'LOCAL', 'LOGS',
'MATERIALIZE', 'MATERIALIZED', 'MAX', 'MERGES', 'MICROSECOND', 'MILLISECOND', 'MIN',
'MINUTE', 'MODIFY', 'MONTH', 'MOVE', 'MUTATION',
'NAN_SQL', 'NEXT', 'NO', 'NONE', 'NOT', 'NULL', 'NULLS',
'OFFSET', 'ON', 'ONLY', 'OPTIMIZE', 'OPTION', 'OR', 'ORDER', 'OUTER', 'OUTFILE', 'OVER',
'PARTITION', 'PASTE', 'PERMANENTLY', 'PLAN', 'POPULATE', 'PRECEDING', 'PRECISION',
'PREWHERE', 'PRIMARY', 'PROFILE', 'PROJECTION', 'QUARTER', 'QUERY', 'QUOTA',
'RANDOMIZED', 'RANGE', 'RECURSIVE', 'REFRESH', 'REGEXP', 'RELOAD', 'REMOTE', 'RENAME',
'REPLACE', 'REPLICA', 'REPLICAS', 'RESET', 'RESTORE', 'RESTRICT', 'RESTRICTIVE',
'RETURNS', 'REVOKE', 'RIGHT', 'ROLE', 'ROLLBACK', 'ROLLUP', 'ROW', 'ROWS',
'SAMPLE', 'SECOND', 'SELECT', 'SEMI', 'SENDS', 'SET', 'SETS', 'SETTINGS', 'SHARD',
'SHOW', 'SIGNED', 'SOURCE', 'SQL_SECURITY', 'START', 'STEP', 'STORAGE', 'STRICT',
'STRICTLY_ASCENDING', 'SUBPARTITION', 'SUBSTRING', 'SUSPEND', 'SYNC', 'SYNTAX', 'SYSTEM',
'TABLE', 'TABLES', 'TEMPORARY', 'TEST', 'THEN', 'TIES', 'TIMESTAMP', 'TO', 'TOP',
'TOTALS', 'TRACKING', 'TRAILING', 'TRANSACTION', 'TRIGGER', 'TRIM', 'TRUE', 'TRUNCATE',
'TYPE',
'UNBOUNDED', 'UNFREEZE', 'UNION', 'UNIQUE', 'UNSIGNED', 'UPDATE', 'USE', 'USING',
'UUID', 'VALUES', 'VARYING', 'VIEW', 'VIRTUAL', 'VISIBLE',
'WATCH', 'WEEK', 'WHEN', 'WHERE', 'WINDOW', 'WITH', 'WORK', 'WRITABLE',
'XOR', 'YEAR', 'ZKPATH',
]);
/// Bracket token types, grouped by kind. Used for rainbow parentheses (coloring by
/// nesting depth) and matched-bracket highlighting.
const OPENING_BRACKETS = new Set([TT.OpeningRoundBracket, TT.OpeningSquareBracket, TT.OpeningCurlyBrace]);
const CLOSING_BRACKETS = new Set([TT.ClosingRoundBracket, TT.ClosingSquareBracket, TT.ClosingCurlyBrace]);
/// The closing type that matches each opening type.
const BRACKET_PAIR = {
[TT.OpeningRoundBracket]: TT.ClosingRoundBracket,
[TT.OpeningSquareBracket]: TT.ClosingSquareBracket,
[TT.OpeningCurlyBrace]: TT.ClosingCurlyBrace,
};
/// Number of distinct rainbow colors before the cycle repeats (see the --syntax-bracket-* vars).
const RAINBOW_BRACKET_COUNT = 7;
/// Compute, for every token, its rainbow-bracket depth (-1 for non-brackets and for
/// unmatched brackets) and the set of bracket tokens to embolden as the matched pair around
/// the cursor.
///
/// Depth is assigned with a type-aware stack, but only once a bracket has a real mate: the
/// matching opening and closing brackets both take the pair's nesting depth so they share a
/// color. A bracket without a counterpart — a lone `(`, a stray `)`, or a mismatched pair —
/// is left at depth -1 so it keeps the default color instead of a rainbow one, mirroring
/// `clickhouse-client`, which colors a bracket only after it finds its match.
///
/// Once the nesting is broken (a closer that does not match the innermost opener), the
/// outstanding openers are discarded so a later closer cannot reach over the break and
/// retroactively match an opener that was never properly closed — e.g. `SELECT ([)]` colors
/// nothing. A `;` is not treated as a statement boundary: like `clickhouse-client` (whose
/// rainbow loop skips `;` without clearing its bracket stack), brackets still match across it,
/// so `SELECT (; SELECT )` pairs the `(` with the later `)`.
///
/// The matched pair is the bracket adjacent to the cursor (preferring the one immediately
/// before the caret, as most editors do) together with its counterpart.
function computeBracketInfo(tokens, cursor, focused) {
const depth = new Array(tokens.length).fill(-1);
const matchOf = new Array(tokens.length).fill(-1);
const starts = new Array(tokens.length).fill(0);
const stack = [];
let offset = 0;
for (let i = 0; i < tokens.length; ++i) {
starts[i] = offset;
offset += tokens[i].token.length;
const type = tokens[i].type;
if (OPENING_BRACKETS.has(type)) {
/// Depth stays -1 until a matching closing bracket is found below.
stack.push(i);
} else if (CLOSING_BRACKETS.has(type)) {
const top = stack.length ? stack[stack.length - 1] : -1;
if (top >= 0 && BRACKET_PAIR[tokens[top].type] === type) {
stack.pop();
matchOf[i] = top;
matchOf[top] = i;
/// The pair's nesting depth is the stack size after popping; the opening and
/// closing brackets share it so they render with the same rainbow color.
depth[top] = depth[i] = stack.length;
} else if (top >= 0) {
/// A closing bracket that does not match the innermost opener breaks the
/// nesting (e.g. the `)` in `([)]`). Discard every outstanding opener so a
/// later closer cannot reach over the mismatch and retroactively "match" an
/// opener that was never properly closed. The mismatched closer itself keeps
/// depth -1.
stack.length = 0;
}
/// A closing bracket with no match keeps depth -1 (rendered with the default color).
}
}
const bold = new Set();
if (focused) {
/// Prefer the bracket ending at the cursor (the char just before the caret), then
/// fall back to the bracket starting at the cursor (the char just after).
let found = -1;
for (let i = 0; i < tokens.length; ++i) {
const type = tokens[i].type;
if (!OPENING_BRACKETS.has(type) && !CLOSING_BRACKETS.has(type)) continue;
if (starts[i] + tokens[i].token.length === cursor) { found = i; break; }
}
if (found < 0) {
for (let i = 0; i < tokens.length; ++i) {
const type = tokens[i].type;
if (!OPENING_BRACKETS.has(type) && !CLOSING_BRACKETS.has(type)) continue;
if (starts[i] === cursor) { found = i; break; }
}
}
if (found >= 0 && matchOf[found] >= 0) {
bold.add(found);
bold.add(matchOf[found]);
}
}
return { depth, bold };
}
/// Map a single token to a CSS class. For BareWords we also peek at the next
/// non-whitespace, non-comment token to distinguish a function call (`foo(`) from a plain
/// identifier — the lexer alone cannot tell them apart. Skipping comments as well as
/// whitespace matches `highlightWithLexer` in `src/Client/ClientBaseHelpers.cpp`, so
/// `sum/*c*/(x)` is a function name here too.
function tokenClass(tokens, i) {
const elem = tokens[i];
switch (elem.type) {
case TT.Comment: return 'q-com';
case TT.Number: return 'q-num';
case TT.StringLiteral:
case TT.HereDoc: return 'q-str';
case TT.QuotedIdentifier: return 'q-qid';
case TT.BareWord: {
if (SQL_KEYWORDS.has(elem.token.toUpperCase())) return 'q-kw';
for (let j = i + 1; j < tokens.length; ++j) {
if (tokens[j].type === TT.Whitespace || tokens[j].type === TT.Comment) continue;
return tokens[j].type === TT.OpeningRoundBracket ? 'q-fn' : 'q-id';
}
return 'q-id';
}
case TT.Asterisk: case TT.Plus: case TT.Minus: case TT.Slash: case TT.Percent:
case TT.Arrow: case TT.QuestionMark: case TT.Colon: case TT.DoubleColon: case TT.Caret:
case TT.Equals: case TT.NotEquals:
case TT.Less: case TT.Greater: case TT.LessOrEquals: case TT.GreaterOrEquals:
case TT.Spaceship: case TT.PipeMark: case TT.Concatenation:
case TT.At: case TT.DoubleAt: case TT.DollarSign:
return 'q-op';
default:
return '';
}
}
/// A token is a matchable identifier if it renders as a plain identifier (`q-id`) or a
/// quoted identifier (`q-qid`) — i.e. not a keyword, function name, number, etc.
function isMatchableIdentifier(tokens, i) {
const cls = tokenClass(tokens, i);
return cls === 'q-id' || cls === 'q-qid';
}
/// If the cursor is inside an identifier, return the set of token indices of every identifier
/// with the same text, so they can be underlined together (as `clickhouse-client` does).
function computeMatchingIdentifiers(tokens, cursor, focused) {
const result = new Set();
if (!focused) return result;
let offset = 0;
let target = -1;
for (let i = 0; i < tokens.length; ++i) {
const start = offset;
offset += tokens[i].token.length;
/// The caret must be strictly inside the token (`start <= cursor < end`), matching the
/// CLI: sitting right after an identifier does not trigger the highlight.
if (start <= cursor && cursor < offset && isMatchableIdentifier(tokens, i)) {
target = i;
break;
}
}
if (target < 0) return result;
const text = tokens[target].token;
for (let i = 0; i < tokens.length; ++i) {
if (tokens[i].token === text && isMatchableIdentifier(tokens, i)) result.add(i);
}
return result;
}
/// For a plain decimal number token, return the set of character indices to underline as
/// digit-group separators. Mirrors `clickhouse-client`: only the integer part of a regular
/// base-10 number (no exponent, hex/bin prefix, or `_` separators) is considered, and only
/// when it has at least 5 digits; then one digit is underlined at each group-of-three boundary
/// counting from the right (before the decimal point).
function digitGroupUnderlines(token) {
const result = new Set();
let is_regular = true;
let finished = false; /// Passed the decimal point.
let first = -1;
let last = -1;
for (let i = 0; i < token.length; ++i) {
const c = token[i];
if (c === '-') {
/// Skip (the lexer emits `-` as a separate token, but be safe).
} else if (c >= '0' && c <= '9') {
if (!finished) {
if (first < 0) first = i;
last = i;
}
} else if (c === '.') {
finished = true;
} else {
/// Exponent, hex/bin, or `_` separators: not a plain number, do not highlight.
is_regular = false;
break;
}
}
if (is_regular && first >= 0 && last >= 0) {
const length = 1 + last - first;
if (length >= 5) {
for (let off = length - 4; off >= 0; off -= 3) result.add(first + off);
}
}
return result;
}
/// Render a number token's inner HTML, underlining the digit-group separators.
function renderNumberContent(token) {
const underlines = digitGroupUnderlines(token);
if (underlines.size === 0) return escapeHTML(token);
let html = '';
for (let i = 0; i < token.length; ++i) {
const ch = escapeHTML(token[i]);
html += underlines.has(i) ? '<span class="q-underline">' + ch + '</span>' : ch;
}
return html;
}
/// Cached state for cursor-tracking re-renders that do not re-tokenize.
let currentTokens = [];
let currentBoundaries = [];
let currentBackdropText = '';
/// Guard so that the backdrop re-renders triggered by the completion code (to update the
/// reserved spacer) do not recurse back into `updateCompletions`.
let inRenderBackdrop = false;
function updateQueryBackdrop(text, tokens, boundaries) {
currentTokens = tokens || [];
currentBoundaries = boundaries || [];
currentBackdropText = text || '';
renderQueryBackdrop();
}
function renderQueryBackdrop() {
const text = currentBackdropText;
const tokens = currentTokens;
const boundaries = currentBoundaries;
/// Update button labels to reflect current selection state.
updateRunButtons(boundaries.length > 1);
/// No content, or the WASM lexer is not available — fall back to the unstyled textarea.
if (!text || tokens.length === 0) {
queryBackdrop.style.display = 'none';
query_area.classList.remove('has-backdrop');
if (!inRenderBackdrop) updateCompletions();
return;
}
/// When the completion list is open, reserve blank space at the cursor for the selected
/// candidate's suffix, so the text to the right shifts as if it had been typed.
const spacerText = completionCandidates.length
? completionCandidates[completionSelected].substring(completionPrefix.length)
: null;
const spacerOffset = completionPrefixStart + completionPrefix.length;
const cursor = query_area.selectionStart;
const focused = document.activeElement === query_area;
/// Find which query contains the cursor (only when textarea is focused and we are
/// actually in multi-query mode).
let activeStart = -1;
let activeEnd = -1;
if (focused && boundaries.length > 1) {
for (let i = 0; i < boundaries.length; ++i) {
if (cursor >= boundaries[i].start && cursor <= boundaries[i].end) {
activeStart = boundaries[i].start;
activeEnd = boundaries[i].end;
break;
}
}
}
/// Rainbow-parentheses depths and the matched-bracket pair around the cursor.
const { depth: bracketDepth, bold: bracketBold } = computeBracketInfo(tokens, cursor, focused);
/// Every occurrence of the identifier under the cursor (underlined together).
const matchingIdentifiers = computeMatchingIdentifiers(tokens, cursor, focused);
let html = '';
let offset = 0;
let activeOpen = false;
for (let i = 0; i < tokens.length; ++i) {
const elem = tokens[i];
const tokStart = offset;
const tokEnd = offset + elem.token.length;
offset = tokEnd;
const inActive = activeStart >= 0 && tokEnd > activeStart && tokStart < activeEnd;
if (inActive && !activeOpen) {
html += '<span class="q-active">';
activeOpen = true;
} else if (!inActive && activeOpen) {
html += '</span>';
activeOpen = false;
}
/// Brackets are colored by nesting depth (rainbow) rather than by the generic
/// token class; the matched pair around the cursor is additionally emboldened.
let cls;
if (bracketDepth[i] >= 0) {
cls = 'q-br' + (bracketDepth[i] % RAINBOW_BRACKET_COUNT);
} else {
cls = tokenClass(tokens, i);
}
if (bracketBold.has(i)) {
cls = cls ? cls + ' q-br-match' : 'q-br-match';
}
/// Every occurrence of the identifier under the cursor is underlined.
if (matchingIdentifiers.has(i)) {
cls = cls ? cls + ' q-underline' : 'q-underline';
}
/// Numbers get per-digit-group underlines; everything else is plain escaped text.
const content = elem.type === TT.Number ? renderNumberContent(elem.token) : escapeHTML(elem.token);
if (cls) {
html += '<span class="' + cls + '">' + content + '</span>';
} else {
html += content;
}
/// The cursor sits at the end of the prefix identifier (a token boundary); insert the
/// reserved spacer right there.
if (spacerText !== null && offset === spacerOffset) {
html += '<span class="completion-spacer">' + escapeHTML(spacerText) + '</span>';
}
}
if (activeOpen) {
html += '</span>';
}
/// Any tail not covered by tokens (lexer hit an error or reached the size limit)
/// is shown with the error style.
if (offset < text.length) {
html += '<span class="q-err">' + escapeHTML(text.substring(offset)) + '</span>';
}
/// Trailing newline fix: browsers collapse a trailing \n in a div.
if (html.endsWith('\n')) html += ' ';
queryBackdrop.innerHTML = html;
queryBackdrop.style.display = 'block';
query_area.classList.add('has-backdrop');
/// Match the scrollbar: reserve extra right padding so text wraps identically.
/// clientWidth excludes borders and scrollbar; offsetWidth includes borders.
/// The border is 1px on each side, so scrollbar = offsetWidth - clientWidth - 2.
const scrollbarWidth = query_area.offsetWidth - query_area.clientWidth - 2;
queryBackdrop.style.paddingRight = scrollbarWidth > 0 ? 'calc(0.25rem + ' + scrollbarWidth + 'px)' : '';
queryBackdrop.scrollTop = query_area.scrollTop;
queryBackdrop.scrollLeft = query_area.scrollLeft;
/// Keep autocompletion in sync with the freshly rendered text/cursor (unless this render
/// was itself triggered by the completion code, which would recurse).
if (!inRenderBackdrop) updateCompletions();
}
/// Re-render backdrop when cursor moves or selection changes.
query_area.addEventListener('click', renderQueryBackdrop);
query_area.addEventListener('keyup', renderQueryBackdrop);
query_area.addEventListener('select', renderQueryBackdrop);
query_area.addEventListener('focus', renderQueryBackdrop);
query_area.addEventListener('blur', renderQueryBackdrop);
/// Hide autocompletion when the textarea loses focus.
query_area.addEventListener('blur', closeCompletions);
/// Keep backdrop scroll in sync with textarea.
query_area.addEventListener('scroll', () => {
queryBackdrop.scrollTop = query_area.scrollTop;
queryBackdrop.scrollLeft = query_area.scrollLeft;
/// Reposition the completion overlay to follow the scrolled caret.
renderCompletionOverlay();
});
/// ----------------------------------------------------------------------------
/// Autocompletion.
///
/// Completions are loaded lazily from `system.completions` the first time the user
/// focuses the textarea (mirroring how `clickhouse-client` loads its suggestions).
/// The feature only activates when the WASM lexer is available, because we reuse the
/// token stream to find the identifier under the cursor and the backdrop/mirror
/// machinery to position the ghost text.
/// ----------------------------------------------------------------------------
/// The visible window around the selected candidate: up to this many above and below (so up to
/// 1 + 4 + 4 entries are shown at once). The rest are reachable by navigating; a "…" marks more.
const MAX_COMPLETIONS_ABOVE = 4;
const MAX_COMPLETIONS_BELOW = 4;
const MAX_COMPLETION_CANDIDATES = 100; /// Upper bound on the full candidate list we keep.
const MIN_COMPLETION_PREFIX = 1;
const completionMirror = document.getElementById('completion-mirror');
const completionOverlay = document.getElementById('completion-overlay');
/// Sorted (by lowercased word) array of { w, lw } for prefix lookup.
let completionWords = [];
let completionsLoadStarted = false;
/// Bumped whenever the connection settings change, to invalidate anything loaded for the previous
/// connection. Both `system.completions` (filtered by the connected user's privileges) and the set
/// of expanded databases are connection-scoped, so an in-flight load must not apply its result
/// after the connection it was started for is gone.
let connectionGeneration = 0;
/// How many times each completion was accepted in this session — accepted completions
/// bubble to the top of the candidate list on subsequent uses.
let completionUsage = new Map();
/// Currently displayed candidates and which one is selected.
let completionCandidates = [];
let completionSelected = 0;
let completionPrefix = '';
let completionPrefixStart = -1;
/// When the user dismisses with Esc, suppress re-opening for the exact same caret state.
let completionSuppressedKey = null;
/// Set when the user types a word character; gates *opening* the completion list so that
/// clicks, focus, and cursor navigation do not pop it up on their own.
let completionOpenRequested = false;
/// True while `acceptCompletion` is inserting text, so the resulting `input` event is not
/// mistaken for the user typing a new word (which would reopen the list on the accepted word).
let completionAccepting = false;
function wasmAvailable() {
return typeof WebAssembly === 'object' && typeof WebAssembly.instantiate === 'function';
}
/// Build the request URL the same way `postImpl` does, but for the completion query.
function buildCompletionUrl() {
const user = user_elem.value;
const password = password_elem.value;
const server_address = url_elem.value;
let url = server_address + (server_address.indexOf('?') >= 0 ? '&' : '?') + 'add_http_cors_header=1';
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
url += '&default_format=TSVRaw';
return url;
}
/// The completion query mirrors `getLoadSuggestionQueryUsingSystemCompletionsTable` in
/// `src/Client/Suggest.cpp`: static contexts are loaded in full, while the catalog-sized
/// contexts (database/table/column/dictionary) are capped per context so that focusing the
/// editor does not enumerate and transfer the whole catalog on large installations. Then it
/// extracts identifier-like words from the resulting completion entries.
const COMPLETION_SUGGESTION_LIMIT = 10000;
const COMPLETION_QUERY =
"SELECT DISTINCT arrayJoin(extractAll(word, '[\\\\w]{2,}')) AS res FROM (" +
"SELECT word FROM system.completions WHERE context IN " +
"('function', 'table engine', 'format', 'table function', 'data type', 'merge tree setting', " +
"'setting', 'aggregate function combinator pair', 'keyword', 'cluster', 'macro', 'policy') " +
"UNION ALL SELECT word FROM (" +
"SELECT word, ROW_NUMBER() OVER (PARTITION BY context ORDER BY word) AS rn FROM " +
"(SELECT DISTINCT word, context FROM system.completions WHERE context IN " +
"('database', 'table', 'column', 'dictionary'))" +
") WHERE rn <= " + COMPLETION_SUGGESTION_LIMIT +
") WHERE notEmpty(res)";
async function loadCompletions() {
if (completionsLoadStarted || !wasmAvailable()) {
return;
}
completionsLoadStarted = true;
const generation = connectionGeneration;
try {
const response = await fetch(buildCompletionUrl(), {
method: "POST",
body: COMPLETION_QUERY,
headers: { 'Authorization': 'never' },
});
/// The connection settings changed while loading — discard this stale result.
if (generation !== connectionGeneration) {
return;
}
if (!response.ok) {
/// Older servers without `system.completions`, or no connection — the feature
/// simply stays inactive. This is an optional enhancement, not a fallback that
/// hides an error from a primary operation. Allow a retry on a later focus (e.g.
/// once the user has filled in the connection details).
completionsLoadStarted = false;
return;
}
const text = await response.text();
if (generation !== connectionGeneration) {
return;
}
const seen = new Set();
const words = [];
for (const line of text.split('\n')) {
const w = line.trim();
if (w && !seen.has(w)) {
seen.add(w);
words.push({ w, lw: w.toLowerCase() });
}
}
words.sort((a, b) => (a.lw < b.lw ? -1 : a.lw > b.lw ? 1 : 0));
completionWords = words;
/// The user may have already typed something — show completions right away.
updateCompletions();
} catch (e) {
/// Network error or aborted — allow a retry on a later focus.
if (generation === connectionGeneration) {
completionsLoadStarted = false;
}
console.error('Failed to load completions:', e);
}
}
/// Invalidate the connection-scoped state when the connection settings change.
/// Cached completions are reloaded (with the new privileges) on the next focus and any
/// in-flight load is discarded. The remembered set of expanded databases is also cleared:
/// it describes what the user expanded on the previous connection, so keeping it would
/// auto-expand same-named databases (and fire `system.tables` queries) on a different
/// server or user when the panel is reopened.
function resetConnectionScopedState() {
connectionGeneration++;
completionsLoadStarted = false;
completionWords = [];
closeCompletions();
opened_databases.clear();
/// A panel that is already open keeps showing the previous connection's databases, and every
/// row handler closes over the url/user/password captured when the list was loaded, so
/// clicking a row (or its refresh button) would keep querying the previous server. Close the
/// panel so it is rebuilt against the new connection on the next open; this also detaches the
/// rows, dropping any in-flight table load through its `isConnected` check.
if (databases_toggle.dataset['opened']) closeDatabases();
}
[url_elem, user_elem, password_elem].forEach(el => el.addEventListener('input', resetConnectionScopedState));
/// Lower-bound binary search for the first word whose lowercased form is >= `key`.
function completionLowerBound(key) {
let lo = 0;
let hi = completionWords.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (completionWords[mid].lw < key) lo = mid + 1;
else hi = mid;
}
return lo;
}
/// Find the bare-word identifier that ends exactly at the cursor (so the cursor is at the
/// end of the identifier), using the cached lexer tokens. Returns { prefix, start } or null.
function getIdentifierBeforeCursor() {
/// Only valid when the cached tokens describe the current textarea contents.
if (currentBackdropText !== query_area.value) {
return null;
}
const cursor = query_area.selectionStart;
let offset = 0;
for (let i = 0; i < currentTokens.length; ++i) {
const elem = currentTokens[i];
const start = offset;
const end = offset + elem.token.length;
offset = end;
if (elem.type === TT.BareWord && end === cursor && start < cursor) {
return { prefix: elem.token, start };
}
}
return null;
}
/// Collect the identifier-like words already present in the query (aliases, column/table names,
/// etc.), excluding the identifier currently being typed at `excludeStart`. These are both a
/// priority signal and a source of candidates, so a word introduced in the query can be
/// completed even if it is not in `system.completions`. Returns unique words in original case.
function queryIdentifierWords(excludeStart) {
const words = [];
const seen = new Set();
let offset = 0;
for (const elem of currentTokens) {
const start = offset;
offset += elem.token.length;
if (elem.type !== TT.BareWord) continue;
if (start === excludeStart) continue; /// The identifier being typed is not a suggestion.
if (elem.token.length < 2) continue; /// Match the `{2,}` convention used for completions.
if (seen.has(elem.token)) continue;
seen.add(elem.token);
words.push(elem.token);
}
return words;
}
/// Compute the candidate list for the given prefix (up to MAX_COMPLETION_CANDIDATES), applying priorities:
/// 1. an exact match (a word equal to what was typed) — shown as an empty completion, always first
/// 2. previously accepted completions (usage count, desc)
/// 3. identifier-like words already present in the query
/// 4. case-exact prefix match (so the ghost text continues the typed casing)
/// 5. shorter words first, then alphabetical
function findCandidates(prefix, prefixStart) {
const lprefix = prefix.toLowerCase();
const queryWords = queryIdentifierWords(prefixStart);
const present = new Set(queryWords.map(w => w.toLowerCase()));
const matches = [];
for (let i = completionLowerBound(lprefix); i < completionWords.length; ++i) {
const entry = completionWords[i];
if (!entry.lw.startsWith(lprefix)) break;
/// A word equal to the prefix is kept as an "empty completion" (its suffix is empty),
/// rather than skipped, so the user sees their typed word is a recognized one.
matches.push(entry.w);
}
/// Also offer identifier-like words from the query itself (deduplicated by suffix below).
for (const w of queryWords) {
if (w.toLowerCase().startsWith(lprefix)) {
matches.push(w);
}
}
/// Do not open a popup that would only offer the already-typed word (an empty completion
/// on its own has nothing to show and would needlessly capture Tab/Enter). The empty
/// completion is shown only alongside at least one real extension.
if (!matches.some(w => w.length > prefix.length)) {
return [];
}
const score = (w) => ({
exact: w.length === prefix.length ? 1 : 0,
usage: completionUsage.get(w) || 0,
present: present.has(w.toLowerCase()) ? 1 : 0,
caseExact: w.startsWith(prefix) ? 1 : 0,
});
matches.sort((a, b) => {
const sa = score(a);
const sb = score(b);
if (sa.exact !== sb.exact) return sb.exact - sa.exact;
if (sa.usage !== sb.usage) return sb.usage - sa.usage;
if (sa.present !== sb.present) return sb.present - sa.present;
if (sa.caseExact !== sb.caseExact) return sb.caseExact - sa.caseExact;
if (a.length !== b.length) return a.length - b.length;
return a < b ? -1 : a > b ? 1 : 0;
});
/// Deduplicate by the displayed suffix so that, e.g., case variants of the exact match
/// (`array`/`ARRAY`/`Array`, all an empty completion) collapse into a single entry and do
/// not crowd out the real extensions.
const result = [];
const seenSuffix = new Set();
for (const w of matches) {
const suffix = w.substring(prefix.length);
if (seenSuffix.has(suffix)) continue;
seenSuffix.add(suffix);
result.push(w);
if (result.length >= MAX_COMPLETION_CANDIDATES) break;
}
return result;
}
/// Pixel coordinates (relative to the overlay's padding box) of the caret at `pos`.
function getCaretCoordinates(pos) {
const text = query_area.value;
completionMirror.textContent = text.substring(0, pos);
const marker = document.createElement('span');
marker.textContent = '​';
completionMirror.appendChild(marker);
completionMirror.appendChild(document.createTextNode(text.substring(pos)));
/// Match the scrollbar reservation the backdrop uses, so wrapping is identical.
const scrollbarWidth = query_area.offsetWidth - query_area.clientWidth - 2;
completionMirror.style.paddingRight = scrollbarWidth > 0 ? 'calc(0.25rem + ' + scrollbarWidth + 'px)' : '';
const top = marker.offsetTop;
const left = marker.offsetLeft;
const height = marker.offsetHeight;
return { top, left, height };
}
/// Recompute whether completions should be shown and update the overlay.
///
/// Completions only *open* right after the user types a word character (tracked by
/// `completionOpenRequested`). Cursor-only events — clicks, focus, arrow navigation — may
/// reposition or close an already-open list, but never open a new one.
function updateCompletions() {
/// Completions are not loaded yet (the lazy `system.completions` load may still be in
/// flight). Keep any pending "just typed a word" request so it can fire once
/// `loadCompletions` finishes and calls back here — otherwise the first word typed during
/// the initial load would get no suggestions.
if (!completionWords.length) {
return;
}
if (document.activeElement !== query_area
|| query_area.selectionStart !== query_area.selectionEnd) {
completionOpenRequested = false;
closeCompletions();
return;
}
/// The cached tokens do not yet describe the current text (tokenization is still in
/// flight after an edit). Leave any pending open request intact so the follow-up call,
/// made once tokens are fresh, can act on it.
if (currentBackdropText !== query_area.value) {
return;
}
/// Consume the "just typed a word character" request now that we have fresh tokens.
const allowOpen = completionOpenRequested;
completionOpenRequested = false;
const id = getIdentifierBeforeCursor();
if (!id || id.prefix.length < MIN_COMPLETION_PREFIX) {
closeCompletions();
return;
}
/// Respect an Esc dismissal for this exact caret state.
const key = id.start + ':' + id.prefix;
if (completionSuppressedKey === key) {
closeCompletions();
return;
}
completionSuppressedKey = null;
const candidates = findCandidates(id.prefix, id.start);
if (!candidates.length) {
closeCompletions();
return;
}
/// Do not pop up the list on a mere cursor move; only open after a typed word character.
if (!completionCandidates.length && !allowOpen) {
return;
}
/// Preserve the selection if the candidate list is unchanged (e.g. on a cursor-only event).
const sameList = candidates.length === completionCandidates.length
&& candidates.every((c, i) => c === completionCandidates[i]);
if (!sameList || id.prefix !== completionPrefix || id.start !== completionPrefixStart) {
completionSelected = 0;
}
completionCandidates = candidates;
completionPrefix = id.prefix;
completionPrefixStart = id.start;
renderCompletions();
}
/// Redraw both the backdrop (to refresh the reserved spacer for the selected candidate) and
/// the overlay. The backdrop render is guarded so it does not recurse into `updateCompletions`.
function renderCompletions() {
inRenderBackdrop = true;
renderQueryBackdrop();
inRenderBackdrop = false;
renderCompletionOverlay();
}
function renderCompletionOverlay() {
if (!completionCandidates.length) {
completionOverlay.style.display = 'none';
return;
}
/// `caretEnd` is the cursor (end of the typed prefix); `caretStart` is the start of the
/// identifier. The selected candidate shows only its suffix at the cursor (its prefix is
/// the real typed text). The other candidates show the full word aligned at the identifier
/// start, so their shared prefix lines up with the typed prefix and their suffixes line up
/// at the cursor column.
const caretEnd = getCaretCoordinates(completionPrefixStart + completionPrefix.length);
const caretStart = getCaretCoordinates(completionPrefixStart);
const scrollTop = query_area.scrollTop;
const scrollLeft = query_area.scrollLeft;
const total = completionCandidates.length;
/// A window around the selection: up to MAX_COMPLETIONS_ABOVE items above and
/// MAX_COMPLETIONS_BELOW below. Navigating moves the selection (and the window) through the
/// full list; the selected item always stays on the cursor line.
const winStart = Math.max(0, completionSelected - MAX_COMPLETIONS_ABOVE);
const winEnd = Math.min(total - 1, completionSelected + MAX_COMPLETIONS_BELOW);
const rowTop = (row) => (caretEnd.top + row * caretEnd.height - scrollTop) + 'px';
completionOverlay.innerHTML = '';
for (let i = winStart; i <= winEnd; ++i) {
const word = completionCandidates[i];
const selected = i === completionSelected;
const entry = document.createElement('div');
entry.className = 'completion-entry' + (selected ? ' completion-selected' : '');
entry.textContent = selected ? word.substring(completionPrefix.length) : word;
/// The selected candidate is always shown right at the cursor; the others are placed
/// above/below by their distance from the selection, so navigating moves the whole
/// block vertically while the selected one stays on the cursor line.
entry.style.top = rowTop(i - completionSelected);
entry.style.left = ((selected ? caretEnd.left : caretStart.left) - scrollLeft) + 'px';
entry.dataset.index = i;
/// Use mousedown (not click) and preventDefault so the textarea keeps focus.
entry.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
acceptCompletion(i);
});
completionOverlay.appendChild(entry);
}
/// "…" at the bottom when there are more candidates below the visible window.
if (winEnd < total - 1) {
const more = document.createElement('div');
more.className = 'completion-entry completion-more';
more.textContent = '…';
more.style.top = rowTop(winEnd - completionSelected + 1);
more.style.left = (caretStart.left - scrollLeft) + 'px';
completionOverlay.appendChild(more);
}
completionOverlay.style.display = 'block';
}
function closeCompletions() {
if (completionCandidates.length) {
completionCandidates = [];
completionSelected = 0;
completionPrefix = '';
completionPrefixStart = -1;
completionOverlay.style.display = 'none';
completionOverlay.innerHTML = '';
/// Redraw the backdrop to drop the reserved spacer (unless we are already inside a
/// backdrop render).
if (!inRenderBackdrop) {
inRenderBackdrop = true;
renderQueryBackdrop();
inRenderBackdrop = false;
}
}
}
function acceptCompletion(index) {
if (index === undefined) index = completionSelected;
if (index < 0 || index >= completionCandidates.length) return;
const word = completionCandidates[index];
/// Capture the prefix and its position before closeCompletions() resets them.
const prefix = completionPrefix;
const start = completionPrefixStart;
closeCompletions();
/// Nothing to do when the completion is the already-typed word itself: either the exact
/// same text, or the same word in a different canonical case (an "empty completion" whose
/// suffix is empty — every candidate starts with the prefix, so an equal length means an
/// equal word ignoring case). Accepting it must not rewrite the user's casing, e.g. typing
/// `hash` and pressing Right/Tab/Enter (with `HASH` offered) must leave `hash` unchanged.
if (word.length === prefix.length) {
return;
}
/// Record the acceptance only when we actually replace text, so the equal-word no-op
/// above stays a true no-op: accepting `HASH` for a typed `hash` must not bump its usage
/// and thus re-rank later suggestions (e.g. make `HASH` win the next time `ha` is typed).
completionUsage.set(word, (completionUsage.get(word) || 0) + 1);
/// Replace the whole typed identifier with the canonical-cased completion word, so the
/// already-typed prefix also takes the suggestion's case (e.g. "se" + accept -> "SELECT").
/// Done via a selection + execCommand so it participates in the undo history and fires an
/// `input` event (which refreshes the backdrop and recomputes completions).
///
/// The `input` event from this programmatic insertion carries the inserted word as
/// `e.data`, which would otherwise re-arm the open request and reopen the list (e.g.
/// accepting `count` would reopen on `countIf`). Suppress that for the duration of the edit.
completionAccepting = true;
query_area.focus();
query_area.setSelectionRange(start, start + prefix.length);
document.execCommand('insertText', false, word);
completionAccepting = false;
}
/// Force the completion list to open at the current cursor (used as a manual Tab trigger),
/// bypassing the "only open after typing a word character" and Esc-dismissal gates. Returns
/// true if it opened.
function tryOpenCompletions() {
completionSuppressedKey = null;
completionOpenRequested = true;
updateCompletions();
if (!completionCandidates.length) {
completionOpenRequested = false;
return false;
}
return true;
}
/// Indent (or, with `dedent`, unindent) every line touched by the current selection by 4
/// spaces. Uses `execCommand('insertText')` on the whole line block so the change is a single
/// undo step and fires an `input` event (which refreshes the backdrop and completions).
function indentSelection(elem, dedent) {
const INDENT = ' ';
const value = elem.value;
/// Expand the selection to whole lines (same line semantics as the comment shortcut).
const blockStart = value.lastIndexOf('\n', elem.selectionStart - 1) + 1;
let blockEnd = value.indexOf('\n', elem.selectionEnd);
if (blockEnd === -1) blockEnd = value.length;
const block = value.substring(blockStart, blockEnd);
const newBlock = block.split('\n').map((line) => {
if (dedent) {
/// Remove up to 4 leading spaces, or a single leading tab.
const m = line.match(/^( {1,4}|\t)/);
return m ? line.substring(m[0].length) : line;
}
/// Do not add trailing whitespace to blank lines (empty or whitespace-only).
return /^\s*$/.test(line) ? line : INDENT + line;
}).join('\n');
if (newBlock === block) return; /// Nothing to unindent — leave the selection untouched.
elem.setSelectionRange(blockStart, blockEnd);
document.execCommand('insertText', false, newBlock);
/// Reselect the whole (re)indented block so repeated Tab / Shift+Tab keeps working.
elem.setSelectionRange(blockStart, blockStart + newBlock.length);
}
/// Capture-phase key handling so it runs before the textarea's own `onkeydown` (which would
/// otherwise treat Tab as "insert spaces").
query_area.addEventListener('keydown', (e) => {
/// Tab / Shift+Tab with a non-empty selection indents / unindents the selected lines by 4
/// spaces. Handled here (before the completion logic and the space-insert `onkeydown`) so a
/// selection takes priority.
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey && !user_prefers_tab_navigation
&& query_area.selectionStart !== query_area.selectionEnd) {
e.preventDefault();
e.stopImmediatePropagation();
indentSelection(query_area, e.shiftKey);
return;
}
if (!completionCandidates.length) {
/// Completions are closed: Tab at the end of a word manually opens them if a
/// continuation is possible; otherwise let the default Tab (indentation) happen.
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (tryOpenCompletions()) {
e.preventDefault();
e.stopImmediatePropagation();
}
}
return;
}
/// The selected candidate whose full text equals the already-typed word (an "empty
/// completion" — same length as the prefix, since every candidate starts with it) would
/// accept to a no-op. In that case Right / Enter must keep their native meaning — move the
/// caret one character, or insert a newline — instead of being swallowed by a completion
/// that changes nothing. This matters e.g. after typing `f()` with the caret between `f`
/// and `(`: Right should step into the parens, not "accept" the already-complete `f`.
const selectedIsNoop = completionCandidates[completionSelected].length === completionPrefix.length;
/// Plain Enter accepts the completion; Ctrl/Cmd+Enter is left alone so it still runs the query.
if (e.key === 'ArrowRight' || (e.key === 'Enter' && !e.ctrlKey && !e.metaKey)) {
if (selectedIsNoop) {
/// Nothing to accept — dismiss the list and let the key act natively.
closeCompletions();
return;
}
e.preventDefault();
e.stopImmediatePropagation();
acceptCompletion(completionSelected);
} else if (e.key === 'Tab') {
e.preventDefault();
e.stopImmediatePropagation();
acceptCompletion(completionSelected);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopImmediatePropagation();
completionSelected = (completionSelected + 1) % completionCandidates.length;
renderCompletions();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopImmediatePropagation();
completionSelected = (completionSelected - 1 + completionCandidates.length) % completionCandidates.length;
renderCompletions();
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
completionSuppressedKey = completionPrefixStart + ':' + completionPrefix;
closeCompletions();
}
}, true);
/// Arm the completion list to open only when the user actually types word characters.
/// This listener is registered before the one that drives tokenization, so the flag is set
/// by the time the post-tokenize `updateCompletions` runs. `e.data` holds the inserted text
/// (null for deletions); composition input also carries the composed text.
query_area.addEventListener('input', (e) => {
if (completionAccepting) {
/// Programmatic insertion from accepting a completion — do not treat as typing.
completionOpenRequested = false;
return;
}
const typed = e.data;
completionOpenRequested = typeof typed === 'string' && /^\w+$/.test(typed);
});
/// Clicking outside the completion entries closes the list.
document.addEventListener('mousedown', (e) => {
if (completionCandidates.length && !e.target.closest('.completion-entry')) {
closeCompletions();
}
});
/// Load completions the first time the textarea is focused.
query_area.addEventListener('focus', loadCompletions);
/// Query parameters support: detect {name:Type} placeholders and show input fields.
let queryParamsContainer = document.getElementById('query-params');
let currentQueryParams = [];
let updateQueryParamsGeneration = 0;
function extractQueryParams(tokens)
{
const params = [];
const seen = new Set();
const significant = tokens.filter(t => t.significant);
for (let i = 0; i < significant.length - 3; i++)
{
if (significant[i].token === '{' && significant[i + 2].token === ':')
{
const name = significant[i + 1].token;
if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name) && !seen.has(name))
{
for (let j = i + 3; j < significant.length; j++)
{
if (significant[j].token === '}')
{
const type = significant.slice(i + 3, j).map(t => t.token).join('');
params.push({name: name, type: type});
seen.add(name);
break;
}
}
}
}
}
return params;
}
function getParamValues()
{
const values = {};
for (const param of currentQueryParams)
{
const input = document.getElementById('param-' + param.name);
if (input)
{
values[param.name] = input.value;
}
}
return values;
}
function setParamValues(values)
{
if (!values) return;
for (const [name, value] of Object.entries(values))
{
const input = document.getElementById('param-' + name);
if (input)
{
input.value = value;
input.dispatchEvent(new Event('input'));
}
}
}
/// Resolve the parameter values a run must execute with, and keep the `param_*` inputs in sync.
///
/// Normally the live DOM inputs are authoritative. But when a tab activation / Back-Forward
/// restore is still pending for this run's generation, the destination tab's saved parameters
/// have not yet been written to the inputs — that write happens only after `restoreFromHistory`'s
/// tokenization await, which `++request_num` in `postOne`/`postAll` has just aborted — so the
/// inputs still hold the previously active tab's values. Sourcing them would execute this tab's
/// query against the wrong bindings (a correctness bug, not just a stale UI). Take the values
/// from the destination tab's own saved `params` instead (parameters are tab-owned), and write
/// them back so the inputs reflect what actually ran.
///
/// The parameter NAMES must also come from the destination tab's snapshot, NOT from the global
/// `currentQueryParams`: that list is only rewritten by `updateQueryParams` after its own
/// `await tokenize(...)`, which the still-pending restore has not reached, so it can still describe
/// the PREVIOUS tab's query. Enumerating it here would send the wrong parameter set — e.g. for a
/// switch from `SELECT {x}` to `SELECT {y}`, it would send `param_x` and drop `param_y`, running
/// the destination query with blank/default bindings. The tab's saved `params` already lists this
/// tab's own parameters (captured when it was last committed / restored), so use its keys.
function resolveRunParams(params_restore_pending)
{
if (!params_restore_pending) return getParamValues();
const tab = getActiveTab();
const saved = (tab && tab.params) || {};
const values = {};
for (const name of Object.keys(saved))
{
values[name] = saved[name] !== undefined ? saved[name] : '';
}
setParamValues(values);
return values;
}
/// Rebuild the syntax highlighting, run buttons and query-parameter inputs from the current
/// editor text. Returns `true` when `currentQueryParams` now reflects that text (tokenized
/// successfully, or the query is empty), and `false` when it could not be rebuilt (WASM lexer
/// unavailable, the query too large to tokenize, or a newer generation superseded this call).
/// Callers that then read `currentQueryParams` to restore a tab's parameters (`restoreFromHistory`
/// and the startup URL reconciliation) MUST honor a `false` result: on failure the list may still
/// describe the PREVIOUS tab, so deriving params from it would attach that tab's placeholder names
/// to this one and persist them into history / `IndexedDB`.
async function updateQueryParams()
{
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function')
{
return false;
}
/// Bump the generation up-front so that any in-flight older runs (which only
/// check this counter after their `await tokenize`) see a newer value and bail
/// out — including the empty-query fast path below, where we would otherwise
/// clear state and return, and a stale older run could then overwrite it.
const generation = ++updateQueryParamsGeneration;
const query = query_area.value;
if (!query)
{
queryParamsContainer.innerHTML = '';
currentQueryParams = [];
updateRunButtons(false);
updateQueryBackdrop('', [], []);
return true; /// the empty list correctly reflects a query with no parameters
}
/// If tokenization or rendering throws (e.g. the query is larger than the WASM
/// memory window currently allocated to the lexer), fall back to the unstyled
/// textarea instead of leaving the previous `.has-backdrop` state in place —
/// otherwise `#query.has-backdrop` keeps the real text invisible
/// (`color: transparent`) while the stale backdrop is shown.
let tokens;
try
{
tokens = await tokenize(query);
}
catch (e)
{
if (generation !== updateQueryParamsGeneration) { return false; }
console.error('Tokenization failed, disabling syntax highlighting:', e);
updateQueryBackdrop('', [], []);
return false; /// `currentQueryParams` was NOT rebuilt; it may still describe another query
}
if (generation !== updateQueryParamsGeneration) { return false; }
const params = extractQueryParams(tokens);
/// Detect multi-query mode from the same token stream.
const boundaries = getQueryBoundaries(tokens, query);
updateRunButtons(boundaries.length > 1);
try
{
updateQueryBackdrop(query, tokens, boundaries);
}
catch (e)
{
console.error('Rendering syntax highlighting failed, disabling backdrop:', e);
updateQueryBackdrop('', [], []);
}
if (JSON.stringify(params) === JSON.stringify(currentQueryParams))
{
return true; /// unchanged, but `currentQueryParams` already reflects this query
}
const oldValues = getParamValues();
currentQueryParams = params;
queryParamsContainer.innerHTML = '';
for (const param of params)
{
const wrapper = document.createElement('span');
wrapper.className = 'query-param';
const label = document.createElement('label');
label.textContent = param.name + ':';
label.setAttribute('for', 'param-' + param.name);
wrapper.appendChild(label);
let placeholder = param.type;
if (/^Date$/.test(param.type)) placeholder = 'YYYY-MM-DD';
else if (/^DateTime/.test(param.type)) placeholder = 'YYYY-MM-DD hh:mm:ss';
const input = document.createElement('input');
input.type = 'text';
input.id = 'param-' + param.name;
input.className = 'monospace shadow';
input.spellcheck = false;
input.placeholder = placeholder;
if (oldValues[param.name] !== undefined)
{
input.value = oldValues[param.name];
}
input.addEventListener('input', function(e)
{
/// Resize to fit the value, clamped between 10em and 20em.
this.style.width = '10em';
if (this.scrollWidth > this.clientWidth)
{
this.style.width = Math.min(this.scrollWidth, this.clientWidth * 2) + 'px';
}
/// Only react to genuine user edits: `updateQueryParams` (on query edits) and
/// `setParamValues` (on restore) dispatch synthetic input events for the resize
/// above, and syncing on those would spam history / fight the restore.
if (!e.isTrusted) return;
markBootstrapDirty(e); /// user edited a parameter before reconciliation
/// Parameters are tab-owned: persist edited values into the active tab, and keep
/// the URL/history entry in sync (replaceState, since the query is unchanged) so a
/// reload's URL params are never stale relative to the saved tab.
const tab = getActiveTab();
if (tab) { tab.params = getParamValues(); syncHistory(); }
});
wrapper.appendChild(input);
queryParamsContainer.appendChild(wrapper);
/// Trigger resize after the element is in the DOM so scrollWidth is valid.
input.dispatchEvent(new Event('input'));
}
return true; /// `currentQueryParams` now reflects the current query
}
query_area.addEventListener('input', (e) =>
{
/// A real keystroke/paste into the editor before reconciliation completes means the live
/// query is newer than the saved workspace; keep it. `restoreFromHistory`/bootstrap dispatch
/// synthetic input events (`isTrusted === false`), which `markBootstrapDirty` ignores.
markBootstrapDirty(e);
resizeQueryAreaIfSlightlyOverflowing();
/// Run the update synchronously instead of with a setTimeout: the heavy work
/// (WASM tokenize, building backdrop HTML) is fast, and the await chain inside
/// `updateQueryParams` runs in microtasks — i.e. it finishes before the browser
/// paints the next frame. If we deferred this with `setTimeout`, the browser
/// would render one frame with the textarea's own text hidden (`color:
/// transparent` from `.has-backdrop`) and the backdrop still showing the
/// pre-edit contents, so the freshly typed characters would be invisible.
updateQueryParams();
/// Keep the active tab and its persisted copy in sync with the editor. The hue of
/// the active tab follows the page background, so no per-tab recolor is needed here.
const tab = getActiveTab();
if (tab) {
tab.query = query_area.value;
/// Fold the edit into the current browser-history entry (and its URL) right away, not
/// only on a click-driven tab switch (`switchToTab`/`addTab`). Otherwise the entry keeps
/// the query last written by a run/switch, and browser Back/Forward away from this tab and
/// back — or closing it and pressing Back to recreate it — restores that stale text from
/// `history.state` over the newer unrun edit (both `window.onpopstate` and
/// `resolveTabForState` read `event.state.query`). Refreshing here keeps the entry the tab
/// would be restored from in sync with the latest keystroke. Only genuine keystrokes do it:
/// the synthetic 'input' events `restoreFromHistory`/bootstrap dispatch merely replay
/// already-recorded state (`isTrusted === false`, as `markBootstrapDirty` relies on) and
/// must not rewrite the entry. `refreshCurrentHistoryEntry` only touches the current entry
/// when it belongs to this tab (or seeds the page's initial entry when a fresh load has not
/// written one yet — this first trusted edit), always via replaceState, so it never spams
/// new entries, and it drops `run=1` once the edit diverges the editor from the produced
/// result (`tabReflectsRun`) — a genuine keystroke does — so an unrun edit never becomes
/// auto-runnable on reload.
if (e.isTrusted) refreshCurrentHistoryEntry(tab);
scheduleSave();
}
});
function setColorTheme(new_theme, update_preference) {
theme = new_theme;
if (update_preference) {
window.localStorage.setItem('theme', theme);
}
document.documentElement.setAttribute('data-theme', theme);
resultEl.redrawChart();
}
/// First we check if theme is set via the 'theme' GET parameter, if not, we check localStorage, otherwise we check OS preference.
let theme = current_url.searchParams.get('theme');
if (['dark', 'light'].indexOf(theme) === -1) {
theme = window.localStorage.getItem('theme');
}
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
/// Obtain system-level user preference
const media_query_list = window.matchMedia('(prefers-color-scheme: dark)');
if (media_query_list.matches) {
setColorTheme('dark');
} else {
setColorTheme('light');
}
/// There is a rumor that on some computers, the theme is changing automatically on day/night.
media_query_list.addEventListener('change', function(e) {
setColorTheme(e.matches ? 'dark' : 'light');
});
}
/// ----- Tab bootstrap & startup reconciliation -----------------------------------
/// Build the initial in-memory tab from whatever the synchronous load produced (the
/// URL hash query, if any). A single result-less tab keeps the tab bar hidden, so the
/// first paint is identical to the pre-tabs UI (no layout shift).
tabs = [makeTab(undefined, query_area.value)];
activeTabId = tabs[0].id;
const url_tab_name = current_url.searchParams.get('tab');
/// Whether the URL actually carries a query fragment (`#<base64>`), as opposed to no
/// fragment or a bare `#`. `url_query` is '' in all those cases, so we must not treat an
/// absent query the same as an empty one: a bare `?tab=Name` must not wipe the saved query.
const has_url_query = window.location.hash.length > 1;
const url_query = query_area.value; /// already decoded from the hash; may be ''
/// When the URL carries a query, defer the auto-run until after IndexedDB reconciliation,
/// so it runs in the right tab (a switch would otherwise abort an in-flight run).
const defer_run_for_reconcile = run_immediately && has_url_query;
/// Reconcile the freshly loaded URL (query + tab name) against tabs saved in IndexedDB.
async function reconcileStartup() {
const loaded = await loadFromDb();
/// From here on reconciliation owns the workspace; any programmatic edits/runs it triggers
/// below (activateTab's synthetic input, a deferred auto-run) must no longer set the flag.
/// The value captured during the `await` above (real user interaction) is what we act on.
bootstrap_settled = true;
let activated = false;
/// Set when the matched tab's saved query/params are newer than the URL (a stale reload
/// of an edited-but-not-resynced tab). In that case the URL is NOT authoritative, so we
/// keep the saved query and skip the URL-driven parameter reconciliation below.
let preserve_local_query = false;
if (loaded && loaded.records.length) {
/// Order saved tabs by the persisted order, then restore the id/title counters.
const order = (loaded.meta && loaded.meta.tabOrder) || [];
const byId = new Map(loaded.records.map(r => [r.id, r]));
const ordered = [];
for (const id of order) if (byId.has(id)) { ordered.push(byId.get(id)); byId.delete(id); }
for (const r of byId.values()) ordered.push(r);
const savedTabs = ordered.map(r => ({
id: r.id, title: r.title, query: r.query || '',
params: r.params || {}, result: r.result || null, lastSavedQuery: r.lastSavedQuery,
}));
if (loaded.meta) {
tabSeq = Math.max(tabSeq, loaded.meta.tabSeq || 0);
tabTitleSeq = Math.max(tabTitleSeq, loaded.meta.tabTitleSeq || 0);
}
if (bootstrap_dirty) {
/// The user interacted with the bootstrap workspace while IndexedDB was still
/// loading, so the live tabs are NEWER than the saved ones. Do not overwrite them.
/// Merge instead: keep the live workspace (and its active tab) and bring the saved
/// tabs alongside it, so neither the fresh work nor the persisted tabs are lost.
/// Merely skipping the restore is not enough: the trailing `scheduleSave` would
/// `clear()` the store (see `persist`) and drop the saved tabs permanently.
captureActiveTab();
const liveTabs = tabs;
/// Both id sequences start at `t1`, so the saved ids can collide with the live ids.
/// Keep the LIVE ids stable and re-id the colliding restored (saved) tabs instead:
/// any browser-history entries created during the load window reference the live
/// bootstrap ids, and Back/Forward must keep resolving those to the live tabs. The
/// saved tabs have no history entries in this session (just loaded from IndexedDB),
/// so re-id'ing them is safe. Re-id'ing the live tabs instead would leave older,
/// un-remapped live-id entries resolving to same-id saved tabs and overwriting their
/// persisted query/result state — a worse, durable data loss.
const liveIds = new Set(liveTabs.map(t => t.id));
for (const t of savedTabs) {
if (liveIds.has(t.id)) t.id = 't' + (++tabSeq);
}
/// Saved tabs keep their persisted order and come first; the live workspace follows.
tabs = savedTabs.concat(liveTabs);
/// The bootstrap tabs default to the same titles as the persisted workspace
/// (`Query A`, `Query B`, ...), so the merge can leave duplicate titles. Titles double
/// as the unique `tab=` reconciliation key (see `uniqueTitle`), so de-conflict the live
/// titles against the saved ones (saved tabs keep theirs). `tabs` is already the merged
/// list, so `uniqueTitle` sees every title and excludes the tab itself by id.
for (const t of liveTabs) t.title = uniqueTitle(t.title, t.id);
/// Renaming a live tab above changes its title, but the current URL's `tab=` key (and
/// the browser-history entry written during the load window via `syncHistory`) still
/// carries the pre-rename title. A reload of that URL would then make startup
/// reconciliation match the persisted saved tab by the old title and overwrite it.
/// Restamp the current entry/URL to the active live tab's new title. (Older load-window
/// entries keep the stable live `tabId` and are restamped lazily by `onpopstate` on
/// Back/Forward.)
const active_tab = getActiveTab();
if (active_tab) {
const restamp_url = new URL(location.href);
const cur_tab_param = restamp_url.searchParams.get('tab');
if (cur_tab_param !== null && cur_tab_param !== active_tab.title) {
restamp_url.searchParams.set('tab', active_tab.title);
const cur_state = history.state;
const new_state = (cur_state && cur_state.tabId === active_tab.id)
? { ...cur_state, tabName: active_tab.title } : cur_state;
history.replaceState(new_state, document.title, restamp_url.href);
}
}
renderTabBar();
scheduleSave();
return; /// skip URL param reconciliation and auto-run: the user is already editing
}
tabs = savedTabs;
if (!has_url_query && !url_tab_name) {
/// Plain load: restore the saved workspace as-is.
activeTabId = (loaded.meta && savedTabs.some(t => t.id === loaded.meta.activeTabId))
? loaded.meta.activeTabId : savedTabs[0].id;
} else {
let match = null;
if (url_tab_name) {
/// Tabs are normally kept title-unique (see `uniqueTitle`), but legacy persisted
/// state could still hold duplicates. Among same-titled tabs, prefer the one the
/// URL actually came from — its query (or last URL-synced query) equals the URL's
/// — so a shared/reloaded link lands on its originating tab and stays
/// non-destructive, instead of clobbering an unrelated tab that shares the name.
const named = savedTabs.filter(t => t.title === url_tab_name);
match = named.find(t => t.query === url_query)
|| named.find(t => t.lastSavedQuery === url_query)
|| named[0] || null;
}
if (match) {
/// Named URL: substitute the URL query into that tab ONLY when the URL carries
/// a query; a bare ?tab=Name just activates the tab and keeps its saved query.
if (has_url_query && match.query !== url_query) {
if (match.lastSavedQuery !== url_query) {
/// The URL holds a query this tab was never synced to (a fresh/shared
/// link for an existing tab name): the URL wins, substitute it.
match.query = url_query;
match.result = null;
} else {
/// Stale reload: the URL hash is exactly this tab's own last-synced
/// query, but the saved tab was edited (and persisted) afterwards
/// without re-syncing the URL. The IndexedDB value is newer — keep it,
/// and don't let the stale URL parameters override the saved ones.
preserve_local_query = true;
/// The URL's `run=1` was meant to auto-run the (now stale) URL query, not
/// the newer local edit the user never ran. Drop the stale run directive so
/// the edit is neither auto-run below nor re-stamped with `run=1` into the
/// synced URL (which would auto-run it on the next reload). A destructive
/// edit (e.g. `DROP`/`INSERT`) left unrun must stay unrun.
run_immediately = false;
}
}
activeTabId = match.id;
} else if (has_url_query && !url_tab_name && (match = savedTabs.find(t => t.query === url_query))) {
/// Unnamed URL with a query: a tab already holds this exact query, switch to it.
activeTabId = match.id;
} else {
/// Not found: open a new tab carrying the name (if any) and query.
const t = makeTab(url_tab_name || undefined, url_query);
tabs.push(t);
activeTabId = t.id;
}
}
await activateTab(activeTabId);
activated = true;
} else if (url_tab_name) {
/// No saved tabs but the URL named a tab: adopt the name for the bootstrap tab.
const t = getActiveTab();
if (t) {
t.title = url_tab_name;
/// Seed lastSavedQuery (= the query the URL is authoritative for) so a later reload of
/// this same shared link is recognized as a stale reload — the URL hash equals this
/// tab's own last-synced query — rather than a fresh external query. Without it, editing
/// this bootstrap tab and reloading the same URL would discard the edit: the named-tab
/// branch above would see `undefined !== url_query` and overwrite the saved query. The
/// loaded-tab paths get this seeding for free via syncHistory()/postAll(); this branch
/// leaves `activated` false and runs neither, so seed it explicitly here.
if (has_url_query) t.lastSavedQuery = url_query;
}
renderTabBar();
} else {
renderTabBar();
}
/// Rebuild the active query's parameters from the URL for ANY URL carrying a query (not
/// only run=1): activateTab above restored the saved tab's params, but when the URL is
/// authoritative its parameter list must REPLACE them, not merely overlay the values it
/// happens to carry. Otherwise a shared `?tab=Name#<query>` URL with no `param_x` would
/// inherit the matched tab's stale `param_x` and auto-run with it. So each placeholder
/// detected in the query takes its URL value when present and is cleared to empty (its
/// default) when absent. Skipped on a stale reload, where the saved params are newer.
/// Done after reconciliation so it targets the reconciled active tab; the auto-run itself
/// is still gated on run=1 below. Also skipped when the user already edited the bootstrap
/// workspace during the load window (`bootstrap_dirty`): their live params are newer and
/// must not be replaced by the URL's.
if (url_query && !preserve_local_query && !bootstrap_dirty) {
const params_ok = await updateQueryParams();
if (params_ok) {
const urlParamValues = {};
for (const param of currentQueryParams) {
const value = current_url.searchParams.get('param_' + param.name);
urlParamValues[param.name] = value !== null ? value : '';
}
setParamValues(urlParamValues);
const tab = getActiveTab();
if (tab) tab.params = getParamValues();
} else {
/// The URL query could not be tokenized, so `currentQueryParams` may describe a
/// different query; rebuilding from it would attach the wrong placeholder names to
/// the reconciled tab (and auto-run with them). Clear the stale inputs/list and leave
/// the tab's own reconciled params in place rather than deriving bogus ones.
queryParamsContainer.innerHTML = '';
currentQueryParams = [];
}
}
/// Auto-run only when the URL is still authoritative. On a stale reload
/// (`preserve_local_query`), the persisted local edit is newer than the URL and was never
/// run by the user, so the stale `run=1` must not execute it; just sync the restored tab
/// into the URL instead (with the `run=1` directive already dropped above).
if (defer_run_for_reconcile && query_area.value && !preserve_local_query && !bootstrap_dirty) {
postAll(); /// saves history itself on completion
} else if (activated) {
syncHistory(); /// reflect the restored active tab in the URL (replaceState)
}
scheduleSave();
}
/// Render the syntax highlighting and parameter inputs for the URL query on page load.
/// `query_area.value` is populated only from a `#<query>` hash, so reaching this branch means
/// the URL carries a query (`has_url_query`). In that case `reconcileStartup` below is the
/// SOLE owner of URL parameter restoration and the auto-run: it reconciles the URL against the
/// IndexedDB workspace first and, on a stale reload, deliberately preserves a newer saved tab
/// (`preserve_local_query`) and drops the stale `run=1`. Applying the URL `param_*` values or
/// auto-running here as well would race that reconciliation: this callback resumes after
/// tokenization, which can land after `activateTab` has restored the preserved tab, overwriting
/// its params with stale URL values that the scheduled `persist` would then save. So only
/// refresh the editor here and let reconciliation apply the parameters and trigger the run.
if (query_area.value)
{
resizeQueryAreaIfSlightlyOverflowing();
updateQueryParams();
}
else if (run_immediately && !defer_run_for_reconcile)
{
postAll();
}
dbReady = openDb();
reconcileStartup();
document.getElementById('toggle-light').onclick = function() {
setColorTheme('light', true);
}
document.getElementById('toggle-dark').onclick = function() {
setColorTheme('dark', true);
}
/// Make the icon "buttons" (copy, download) and the theme switcher reachable and
/// operable from the keyboard: Tab focuses them, Enter/Space activates them.
/// (Real <button> elements like Run already get this for free.)
function makeButtonLike(elem, action)
{
elem.setAttribute('tabindex', '0');
elem.setAttribute('role', 'button');
elem.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
action();
}
});
}
makeButtonLike(document.getElementById('copy-div'), () => document.getElementById('copy-div').click());
makeButtonLike(document.getElementById('download-div'), () => document.getElementById('download-div').click());
makeButtonLike(document.getElementById('theme'), () => setColorTheme(theme === 'dark' ? 'light' : 'dark', true));
// Copy raw result to clipboard
document.getElementById('copy-div').addEventListener('click', function() {
let text = resultEl.rawText;
if (!text) return;
let button = this;
let showBalloon = function(message) {
let balloon = document.createElement('span');
balloon.className = 'copy-balloon';
balloon.textContent = message;
button.appendChild(balloon);
balloon.addEventListener('animationend', () => balloon.remove());
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(
() => showBalloon('Result copied'),
() => showBalloon('Copy failed'));
} else {
showBalloon('Copy not supported');
}
});
// Download functionality
let last_query_for_download = '';
let last_params_for_download = {};
// Toggle download dropdown
document.getElementById('download-div').addEventListener('click', function(e) {
e.stopPropagation();
const dropdown = document.getElementById('download-dropdown');
const button = document.getElementById('download-div');
const context = document.getElementById('main');
if (dropdown.classList.contains('show')) {
dropdown.classList.remove('show');
} else {
// Position the dropdown relative to the button
dropdown.classList.add('show');
const button_rect = button.getBoundingClientRect();
const context_rect = context.getBoundingClientRect();
const dropdown_rect = dropdown.getBoundingClientRect();
dropdown.style.top = (button_rect.top - context_rect.top) + 'px';
dropdown.style.left = (button_rect.left - context_rect.left + button_rect.width - dropdown_rect.width) + 'px';
// Move focus into the menu so keyboard users land on the format selection.
document.getElementById('download-format').focus();
}
});
// Prevent dropdown from closing when clicking inside it
document.getElementById('download-dropdown').addEventListener('click', function(e) {
e.stopPropagation();
});
// Close dropdown when clicking outside
document.addEventListener('click', function() {
const dropdown = document.getElementById('download-dropdown');
dropdown.classList.remove('show');
});
/// Keep the open download menu contiguous with the download button in the tab
/// order. The dropdown lives at the end of the DOM (so it is positioned without
/// being clipped), so without this its controls would otherwise land at the very
/// end of the tab sequence. Tab/Shift+Tab flow:
/// download button -> format select -> [other format] -> Download button
/// and leaving either end closes the menu. Escape also closes it.
function downloadTabSequence() {
const seq = [document.getElementById('download-div'), document.getElementById('download-format')];
const other = document.getElementById('download-format-other');
if (other.classList.contains('show')) seq.push(other);
seq.push(document.getElementById('download-button'));
return seq;
}
document.getElementById('main').addEventListener('keydown', e => {
const dropdown = document.getElementById('download-dropdown');
if (!dropdown.classList.contains('show')) return;
if (e.key === 'Escape') {
e.preventDefault();
dropdown.classList.remove('show');
document.getElementById('download-div').focus();
return;
}
if (e.key !== 'Tab') return;
const seq = downloadTabSequence();
const idx = seq.indexOf(document.activeElement);
if (idx < 0) return;
if (!e.shiftKey) {
if (idx < seq.length - 1) {
e.preventDefault();
seq[idx + 1].focus();
} else {
/// Past the last item: close the menu and continue to the next toolbar control.
dropdown.classList.remove('show');
e.preventDefault();
document.getElementById('theme').focus();
}
} else {
if (idx > 0) {
e.preventDefault();
seq[idx - 1].focus();
} else {
/// Before the download button: close the menu and let focus move naturally.
dropdown.classList.remove('show');
}
}
});
// Handle "Other" format selection
document.getElementById('download-format').addEventListener('change', function() {
const otherInput = document.getElementById('download-format-other');
if (this.value === 'other') {
otherInput.classList.add('show');
otherInput.focus();
} else {
otherInput.classList.remove('show');
}
});
const formatExtensions = {
CSVWithNames: "csv",
NDJSON: "jsonl",
JSONEachRow: "jsonl",
Markdown: "md"
};
// Handle download button click
document.getElementById('download-button').addEventListener('click', async function() {
const formatSelect = document.getElementById('download-format');
const formatOther = document.getElementById('download-format-other');
let format = formatSelect.value;
if (format === 'other') {
format = formatOther.value.trim();
if (!format) {
alert('Please enter a format name');
return;
}
}
const extension = formatExtensions[format] || format.toLowerCase();
// Get current values
const user = user_elem.value;
const password = password_elem.value;
const server_address = url_elem.value;
const query = last_query_for_download;
if (!query) {
alert('No query to download results for');
return;
}
// Build download URL
let url = server_address +
(server_address.indexOf('?') >= 0 ? '&' : '?') +
'add_http_cors_header=1' +
'&enable_http_compression=1' +
'&use_query_cache=1&enable_reads_from_query_cache=1&enable_writes_to_query_cache=0&query_cache_ttl=600&query_cache_nondeterministic_function_handling=save&query_cache_system_table_handling=ignore' +
'&default_format=' + encodeURIComponent(format) +
'&http_response_headers=' + encodeURIComponent(`{'Content-Disposition':'attachment; filename="result.${encodeURIComponent(extension)}"'}`);
if (user) url += '&user=' + encodeURIComponent(user);
if (password) url += '&password=' + encodeURIComponent(password);
for (const [name, value] of Object.entries(last_params_for_download))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
let downloadQuery = query.replaceAll(/\bFORMAT\s+\w+/ig, '');
// Create a form and submit it to trigger download
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.enctype = 'multipart/form-data';
form.style.display = 'none';
const queryInput = document.createElement('input');
queryInput.type = 'hidden';
queryInput.name = 'query';
queryInput.value = downloadQuery;
form.appendChild(queryInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
// Close dropdown
document.getElementById('download-dropdown').classList.remove('show');
});
// Web Terminal integration
{
const terminalIcon = document.getElementById('terminal-icon');
const terminalPanel = document.getElementById('terminal-panel');
const terminalResizer = document.getElementById('terminal-resizer');
const mainEl = document.getElementById('main');
let terminalIframe = null;
let terminalActive = false;
let terminalHiddenByResize = false;
/// Origin the iframe was loaded from, used as the target origin for `postMessage`.
/// Captured once when the iframe is created so it stays in sync with `iframe.src`
/// even if the user later edits the server URL field.
let terminalOrigin = null;
const MIN_TERMINAL_HEIGHT = 20;
/// The `/webterminal` endpoint lives on the ClickHouse server addressed by `url_elem.value`,
/// which may differ from the page's own origin when the page is opened locally with `?url=...`.
/// Resolve both the availability probe and the iframe `src` against the same configured server URL.
/// Returns `null` if the configured URL cannot be parsed, so the caller can treat the terminal
/// as unavailable instead of letting the exception abort `play.html` initialization.
function getTerminalURL() {
try {
return new URL('/webterminal', url_elem.value).href;
} catch (e) {
return null;
}
}
/// Keep the icon's `href` pointing at the current `/webterminal` URL, so that a native
/// middle-click or Ctrl/Shift+click opens the right address even after the user edits the
/// server URL. The `href` is removed when the URL is currently unparsable: an empty `href`
/// would resolve to the current page, so a modifier/middle-click would open Play itself
/// instead of doing nothing.
///
/// The non-empty Play username is carried over as a `user` query parameter so a new-tab
/// terminal connects as the same user instead of falling back to the server default user.
/// The password is intentionally omitted (it would leak in the URL); `webterminal.html`
/// prompts for it on the first authentication failure. This `user` parameter is added only
/// to the anchor `href` used for new-tab navigation, never to `getTerminalURL`: that one
/// also feeds the inline iframe `src`, where a `?user=` would set `userFromURL` and stop the
/// iframe from waiting for the credentials `play.html` sends via `postMessage`.
function updateTerminalHref() {
const base = getTerminalURL();
if (!base) { terminalIcon.removeAttribute('href'); return; }
try {
const url = new URL(base);
if (user_elem.value) url.searchParams.set('user', user_elem.value);
terminalIcon.setAttribute('href', url.href);
} catch (e) {
terminalIcon.setAttribute('href', base);
}
}
/// Hide the terminal icon if /webterminal is not available (gated by allow_experimental_webterminal).
terminalIcon.style.display = 'none';
const terminalProbeURL = getTerminalURL();
if (terminalProbeURL) {
updateTerminalHref();
fetch(terminalProbeURL, {method: 'HEAD'}).then(function(resp) {
if (resp.ok) terminalIcon.style.display = '';
}).catch(function() {});
}
function sendCredentialsToTerminal() {
if (terminalIframe && terminalIframe.contentWindow && terminalOrigin) {
terminalIframe.contentWindow.postMessage({
type: 'webterminal-credentials',
user: user_elem.value,
password: password_elem.value || ''
}, terminalOrigin);
}
}
/// Keep the `--terminal-height` CSS variable in sync so the side menu can
/// size itself as `100vh - terminal_height` and its bottom icons stay visible.
function updateTerminalHeightVar() {
const h = terminalActive ? terminalPanel.offsetHeight : 0;
document.documentElement.style.setProperty('--terminal-height', h + 'px');
}
function activateTerminal() {
if (terminalActive) return;
/// Bail out before mutating any state if the configured server URL is currently invalid.
/// The icon is only shown when the initial probe succeeded, so this can happen only if the
/// user edited `url_elem.value` to something unparsable between page load and this click.
const terminalSrc = terminalIframe ? null : getTerminalURL();
if (!terminalIframe && !terminalSrc) return;
terminalActive = true;
terminalIcon.classList.add('active');
terminalPanel.classList.add('active');
if (!terminalIframe) {
terminalPanel.style.height = Math.round(window.innerHeight * 0.4) + 'px';
terminalIframe = document.createElement('iframe');
terminalIframe.id = 'terminal-iframe';
terminalOrigin = new URL(terminalSrc).origin;
terminalIframe.src = terminalSrc;
terminalIframe.addEventListener('load', sendCredentialsToTerminal);
terminalPanel.insertBefore(terminalIframe, terminalResizer);
} else if (terminalHiddenByResize) {
terminalPanel.style.height = Math.round(window.innerHeight * 0.4) + 'px';
}
terminalHiddenByResize = false;
updateTerminalHeightVar();
// Tell the iframe to refit the terminal after becoming visible again, and focus it
if (terminalIframe && terminalIframe.contentWindow && terminalOrigin) {
terminalIframe.contentWindow.postMessage({type: 'webterminal-refit'}, terminalOrigin);
terminalIframe.focus();
}
}
/// Hide the terminal panel but keep the iframe alive so the session persists.
function hideTerminal() {
if (!terminalActive) return;
terminalActive = false;
terminalIcon.classList.remove('active');
terminalPanel.classList.remove('active');
updateTerminalHeightVar();
}
/// Destroy the terminal: remove the iframe and hide the panel.
function closeTerminal() {
hideTerminal();
if (terminalIframe) {
terminalIframe.remove();
terminalIframe = null;
terminalOrigin = null;
}
}
function toggleTerminal() {
if (terminalActive) hideTerminal();
else activateTerminal();
}
/// Ctrl/Cmd/Shift+click opens the terminal in a new browser tab; let the anchor's native
/// behaviour handle it (middle-click does not fire a `click` event and is handled natively too).
function wantsNewTab(e) {
return e.ctrlKey || e.metaKey || e.shiftKey;
}
/// Refresh the `href` before navigation so a new-tab open uses the current server URL.
/// `pointerdown` fires before both `click` and the middle-button `auxclick` navigation.
terminalIcon.addEventListener('pointerdown', updateTerminalHref);
terminalIcon.addEventListener('click', function(e) {
/// For a modifier-click, fall through to the anchor's native "open in new tab".
if (wantsNewTab(e)) return;
/// A plain left click toggles the inline terminal panel instead of navigating.
e.preventDefault();
e.stopPropagation();
toggleTerminal();
});
// ~ key toggles terminal (Quake-style), but not when typing in inputs
document.addEventListener('keydown', function(e) {
if ((e.key === '`' || e.key === '~') && !e.ctrlKey && !e.altKey && !e.metaKey) {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || e.target.isContentEditable) return;
// In the query textarea: activate terminal if ~ is typed into an empty field
if (tag === 'TEXTAREA' && e.target.id === 'query') {
if (!terminalActive && e.target.value === '') {
e.preventDefault();
activateTerminal();
}
return;
}
e.preventDefault();
toggleTerminal();
}
});
// Drag-to-resize terminal panel
let termDrag = { active: false, startY: 0, startHeight: 0 };
terminalResizer.addEventListener('pointerdown', function(e) {
if (e.button !== 0) return;
termDrag.active = true;
termDrag.startY = e.clientY;
termDrag.startHeight = terminalPanel.offsetHeight;
terminalResizer.classList.add('terminal-resizer-dragging');
// Overlay to prevent iframe from stealing pointer events
const overlay = document.createElement('div');
overlay.id = 'terminal-drag-overlay';
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;cursor:row-resize;';
document.body.appendChild(overlay);
document.addEventListener('pointermove', termMove);
document.addEventListener('pointerup', termStop);
document.addEventListener('pointercancel', termStop);
});
function termMove(e) {
if (!termDrag.active) return;
const newHeight = termDrag.startHeight + (e.clientY - termDrag.startY);
if (newHeight < MIN_TERMINAL_HEIGHT) {
terminalHiddenByResize = true;
hideTerminal();
termStop(e);
return;
}
terminalPanel.style.height = newHeight + 'px';
updateTerminalHeightVar();
}
function termStop() {
termDrag.active = false;
terminalResizer.classList.remove('terminal-resizer-dragging');
const overlay = document.getElementById('terminal-drag-overlay');
if (overlay) overlay.remove();
document.removeEventListener('pointermove', termMove);
document.removeEventListener('pointerup', termStop);
document.removeEventListener('pointercancel', termStop);
}
terminalResizer.addEventListener('touchstart', function(e) { e.preventDefault(); });
// Listen for messages from the webterminal iframe
window.addEventListener('message', function(e) {
if (!e.data) return;
if (e.data.type === 'webterminal-closed') {
closeTerminal();
} else if (e.data.type === 'webterminal-escape') {
hideTerminal();
}
});
}
</script>
</html>