mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
8566 lines
373 KiB
HTML
8566 lines
373 KiB
HTML
<!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"> (<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/">☁</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">⧗</span>
|
||
<span id="check-mark">✔</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`,
|
||
'¶m_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 += '¶m_' + 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/// 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 += '¶m_' + 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>
|