Files
zeekayandHanzo Dev 64c8e40f76 the face is Zen, and it ships from @hanzo/font
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.

Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.

Comments naming a face we no longer use are deleted, not reworded.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 10:25:22 -07:00

12654 lines
644 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="icon" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1NCIgaGVpZ2h0PSI0OCIgdmlld0JveD0iMCAwIDExIDEwIj48c3R5bGU+LmJne2ZpbGw6I2ZmMH0ub3tmaWxsOiMwMDB9PC9zdHlsZT48cmVjdCBjbGFzcz0iYmciIHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48cGF0aCBkPSJNMSwxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik0zLDEgaDEgdjggaC0xIHoiIGNsYXNzPSJvIi8+PHBhdGggZD0iTTUsMSBoMSB2OCBoLTEgeiIgY2xhc3M9Im8iLz48cGF0aCBkPSJNNywxIGgxIHY4IGgtMSB6IiBjbGFzcz0ibyIvPjxwYXRoIGQ9Ik05LDQuMjUgaDEgdjEuNSBoLTEgeiIgY2xhc3M9Im8iLz48L3N2Zz4K">
<title>Datastore 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 `datastore-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
`datastore-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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
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;
/* Anchor for the absolutely-positioned progress fill and the active tab's
bottom-border eraser (::after). */
position: relative;
/* 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;
}
/* Per-tab activity indicators (rendered even for inactive/background tabs):
a translucent progress fill behind the title, a spinning hourglass while the
tab's query runs, and a completion check-mark shown until the tab is opened.
The title/close/status sit above the fill via z-index. */
.tab-title, .tab-close, .tab-status
{
position: relative;
z-index: 1;
}
.tab-progress-fill
{
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: var(--tab-progress, 0%);
background: var(--progress-color);
opacity: 0.35;
z-index: 0;
pointer-events: none;
transition: width 0.15s linear;
}
.tab-status
{
flex-shrink: 0;
line-height: 1;
font-size: 1em;
}
.tab-spinner
{
display: inline-block;
color: var(--misc-text-color);
animation: tab-hourglass-animation 2s linear infinite;
}
.tab-check
{
color: #080;
}
.tab-cross
{
color: #c00;
}
@keyframes tab-hourglass-animation
{
0% { transform: none; }
25% { transform: rotate(180deg); }
50% { transform: rotate(180deg); }
75% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
.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);
}
/* Each tab owns one persistent result panel; only the active one is shown, the
rest are kept alive but hidden so their rendered result (and any running query)
survives switching away and back. */
.tab-panel[hidden]
{
display: none;
}
/* 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, Shift+click, and the platform's new-tab
modifier click (Cmd+click on macOS, Ctrl+click elsewhere) open the terminal in
a new browser tab or window 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;
/* The icon toggles the list of columns, so it reads as an interactive
disclosure control: brighten it on hover and while expanded. */
cursor: pointer;
transition: opacity 0.1s;
}
.table-icon:hover,
.table-icon.expanded
{
opacity: 1;
}
/* The list of columns shown under an expanded table. Its own opaque background
covers the table's size bar (drawn on the whole `.table` box) so the bar does
not bleed vertically behind the columns; each column then draws its own bar. */
.table-columns
{
background-color: var(--table-header-color);
}
.table-column
{
background-repeat: no-repeat;
}
.table-column button
{
display: flex;
align-items: baseline;
width: 100%;
/* Align columns with the table rows: the type icon sits at the same left edge as
the table icon, so the two lists share a left margin. */
padding: 0 0.25em 0 0;
text-align: left;
cursor: pointer;
background: transparent;
border: none;
font-size: 100%;
user-select: none;
color: var(--text-color);
overflow: hidden;
}
.table-column button:hover,
.table-column button:focus
{
color: var(--button-text-color);
background-color: var(--button-color);
}
.column-name
{
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1 1 auto;
min-width: 0;
}
/* The data type is shown to the right, dimmed; it may be truncated when long,
the full type (with sizes) is available in the hover balloon. */
.column-type
{
flex: 0 1 auto;
min-width: 0;
max-width: 55%;
margin-left: 0.5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--misc-text-color);
font-size: 90%;
}
/* Type icon to the left of the column name. Every icon is an SVG in the same 24x24
viewBox and the same box here, so they all render at identical dimensions (both the
line drawings for Date/DateTime and the text labels 09, Aa, [], {}, (,), T), which
also keeps the column names aligned. */
.column-type-icon
{
width: 1.4em;
height: 1em;
vertical-align: -0.15em;
/* Same icon-to-name gap as the table rows (`.table-icon`). */
margin-right: 0.25em;
flex-shrink: 0;
opacity: 0.5;
}
/* The text-label icons are SVG <text>; size them here (font-size is in viewBox units). */
.column-type-badge text
{
font-family: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-weight: bold;
font-size: 15px;
}
.columns-loading
{
padding: 0 0.25em 0 0;
color: var(--misc-text-color);
animation: hourglass-animation 2s linear infinite;
}
.table-addendum,
.column-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,
.column-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 > button .table-addendum:hover,
.column-name:hover ~ .column-addendum,
.column-type:hover ~ .column-addendum,
.table-column .column-addendum:hover
{
opacity: 1;
pointer-events: auto;
transition: opacity 0s 0.1s;
}
/* Show the balloon when the row (or one of its balloon links) is focused via the
keyboard, mirroring the hover behaviour above. Uses `:focus-visible` (not `:focus`
/ `:focus-within`) so a mouse click - which focuses the button but not "visibly" -
does not leave the balloon stuck open after the pointer moves away; only keyboard
focus reveals it. `:has(:focus-visible)` covers a focused balloon link inside the
button; the bare `:focus-visible` covers the button itself. Scoped to the row's own
button so a focused nested column does not also reveal the table balloon. */
.table > button:is(:focus-visible, :has(:focus-visible)) .table-addendum,
.table-column > button:is(:focus-visible, :has(:focus-visible)) .column-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 > button .table-addendum:hover,
body.suppress-balloons .column-name:hover ~ .column-addendum,
body.suppress-balloons .column-type:hover ~ .column-addendum,
body.suppress-balloons .table-column .column-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 > button:is(:focus-visible, :has(:focus-visible)) .table-addendum,
body.suppress-balloons .table-column > button:is(:focus-visible, :has(:focus-visible)) .column-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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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; }
/* The token where the server reported a syntax error is painted with a red
background. It is cleared as soon as the user returns focus to the editor. */
#query-backdrop .q-err-pos { background-color: color-mix(in srgb, var(--syntax-error) 40%, transparent); border-radius: 2px; }
/* An "(end of query)" error has no token to paint (the parser reached the end of the
statement or a `;` separator). Mark the statement boundary with a thin vertical bar
instead of filling the following separator/whitespace token. The bar is zero content
width (drawn by the border, offset by negative margins) so the backdrop still aligns
character-for-character with the textarea underneath it. */
#query-backdrop .q-err-pos-end { display: inline-block; width: 0; border-left: 2px solid var(--syntax-error); margin: 0 -1px; }
/* 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 `datastore-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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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, Shift+click, or the platform's new-tab modifier click (Cmd+click on macOS, Ctrl+click elsewhere) to open in a new tab or window">
<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/hanzoai/datastore/">
<svg id="github-icon-light" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
<svg id="github-icon-dark" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
</a>
</div>
</div>
<div id="terminal-panel">
<div id="terminal-resizer"></div>
</div>
<div id="main">
<div id="tab-bar" hidden>
<div id="tabs"></div>
<button id="tab-add" type="button" title="New query (open a new tab)">+</button>
<div id="connection-menu">
<button id="connection-key" type="button" title="Connection settings" aria-expanded="false" hidden>🔑</button>
<div id="connection-dropdown" hidden></div>
</div>
</div>
<form id="controls">
<div id="inputs">
<input name="url" form="controls" spellcheck="false" class="monospace shadow" id="url" type="url" value="" placeholder="url" /><span id="url_status"><span id="server_info"></span>●</span><input name="user" form="controls" spellcheck="false" class="monospace shadow" id="user" type="text" value="" placeholder="user" /><input name="password" form="controls" class="monospace shadow" id="password" type="password" placeholder="password" />
</div>
<div id="query_div"><div id="query-backdrop"></div><div id="completion-mirror"></div><textarea autofocus spellcheck="false" autocorrect="false" autocomplete="false" data-gramm="false" class="monospace shadow" id="query"></textarea><div id="completion-overlay"></div></div>
<div id="query-params"></div>
<div id="run_div">
<button type="submit" class="shadow" id="run">Run</button>
<button type="button" class="shadow" id="run-all">Run all</button>
<div id="hint">&nbsp;(<span id="hint-modifier">Ctrl/Cmd</span>+Enter<span id="hint-run-all" style="display: none">, +Shift to run all</span>)</div>
<query-progress id="query-progress"></query-progress>
<div id="copy-div" class="shadow" title="Copy raw result to clipboard">
<svg id="copy-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</div>
<div id="download-div" class="shadow">
<svg id="download" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div id="theme"><span id="toggle-dark">🌘</span><span id="toggle-light">☀️</span></div>
</div>
</form>
<div id="result-panels"></div>
<p id="logo-container">
<a href="https://hanzo.ai/">
<svg id="logo" width="50%" viewBox="0 0 180 28" version="1.1" xmlns="http://www.w3.org/2000/svg"><title>Powered by Datastore</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://datastore.cloud/">&#9729;</a>
</p>
<div id="download-dropdown">
<div>
<div class="download-option">
<label for="download-format">Format:</label>
<select id="download-format">
<option value="CSV">CSV</option>
<option value="CSVWithNames">CSVWithNames</option>
<option value="TSV">TSV</option>
<option value="JSON">JSON</option>
<option value="NDJSON" selected>JSONLines</option>
<option value="Parquet">Parquet</option>
<option value="Markdown">Markdown</option>
<option value="other">Other...</option>
</select>
</div>
<input type="text" id="download-format-other" placeholder="Enter format name (e.g., Arrow, Avro)" />
</div>
<button id="download-button">Download</button>
</div>
</div>
</body>
<script type="text/javascript">
class QueryProgressElement extends HTMLElement
{
constructor()
{
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host
{
display: grid;
/* Progress and stats share the available width equally, and both can
shrink below their content size (the zero minimum), so they resize
uniformly when the row is narrow instead of one hogging the space. */
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
gap: 1rem;
align-items: center;
}
#hourglass, #check-mark
{
grid-column: 1;
grid-row: 1;
}
#hourglass
{
display: none;
font-size: 110%;
color: #888;
animation: hourglass-animation 2s linear infinite;
}
#check-mark
{
display: none;
font-size: 110%;
color: #080;
}
#progress
{
display: none;
width: 100%;
color: var(--misc-text-color);
background: linear-gradient(to right,
transparent 0%,
var(--progress-color) var(--progress, 0%),
transparent var(--progress, 0%));
}
#stats
{
color: var(--misc-text-color);
}
#stats b
{
color: var(--progress-color);
}
@keyframes hourglass-animation
{
0% { transform: none; }
25% { transform: rotate(180deg); }
50% { transform: rotate(180deg); }
75% { transform: rotate(360deg); }
100% { transform: rotate(360deg); }
}
</style>
<span id="hourglass">&#10711;</span>
<span id="check-mark">&#10004;</span>
<span id="progress"></span>
<span id="stats"></span>
`;
this._hourglass = this.shadowRoot.getElementById('hourglass');
this._checkMark = this.shadowRoot.getElementById('check-mark');
this._progress = this.shadowRoot.getElementById('progress');
this._stats = this.shadowRoot.getElementById('stats');
}
clear()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'none';
this._stats.innerText = '';
this._progress.style.display = 'none';
this._progress.innerText = '';
this.style.setProperty('--progress', '0%');
}
start()
{
this._checkMark.style.display = 'none';
this._hourglass.style.display = 'inline-block';
}
finish()
{
this._hourglass.style.display = 'none';
this._checkMark.style.display = 'inline-block';
}
updateProgress(progress)
{
const rows = +progress.read_rows;
const bytes = +progress.read_bytes;
const total_rows = +progress.total_rows_to_read;
const elapsed_ns = +progress.elapsed_ns;
let formatted_rows = formatReadableRows(rows);
let formatted_bytes = formatReadableBytes(bytes);
const rps = rows * 1e9 / elapsed_ns;
const bps = bytes * 1e9 / elapsed_ns;
let formatted_rps = formatReadableRows(rps) + '/sec';
let formatted_bps = formatReadableBytes(bps) + '/sec';
if (rows >= 1e11) { formatted_rows = `<b>${formatted_rows}<\/b>`; }
if (bytes >= 1e12) { formatted_bytes = `<b>${formatted_bytes}<\/b>`; }
if (rps >= 1e10) { formatted_rps = `<b>${formatted_rps}<\/b>`; }
if (bps >= 1e10) { formatted_bps = `<b>${formatted_bps}<\/b>`; }
let text = '';
if (total_rows) { text += (100 * Math.min(1.0, rows / total_rows)).toFixed(1) + '%, '; }
text += `Read ${formatted_rows} rows, ${formatted_bytes}`;
if (rps > 1e6) { text += ` (${formatted_rps}, ${formatted_bps})`; }
this._stats.innerHTML = text;
this.style.setProperty('--progress',
rows && total_rows ? (100 * rows / total_rows) + '%' : '0');
}
updateText(rowCount, elapsedNs, incomplete)
{
let progress_text = '';
if (rowCount)
{
progress_text += `${rowCount}${incomplete ? '+' : ''} rows in result`;
if (elapsedNs) progress_text += ', ';
}
if (elapsedNs) { progress_text += `${(elapsedNs / 1e9).toFixed(2)} sec.`; }
this._progress.innerText = progress_text;
this._progress.style.display = 'block';
}
}
customElements.define('query-progress', QueryProgressElement);
/// Per-column color coding modes chosen by the user, keyed by column name. Only columns with
/// an explicit (non-default) choice are stored. These modes are PER TAB: each tab keeps its own
/// `colorModes` object (see `makeTab`), and its result element(s) hold the same object via
/// `_colorModes`, so a toggle in one tab never repaints another tab's columns. They are persisted
/// with the tab's result snapshot and mirrored into the URL / history for the active tab.
function activeColorModes() {
const tab = getActiveTab();
return tab ? tab.colorModes : {};
}
/// Names of the columns the user pinned, kept as a set (`{name: true}`). A pinned column sticks
/// to the left/right edge of the result while it is scrolled horizontally instead of scrolling
/// away; several pinned columns stack against the edge. Like the color modes, the pinned set is
/// PER TAB: each tab keeps its own `pinnedColumns` object (see `makeTab`), and its result
/// element(s) hold the same object via `_pinnedColumns`, so pinning a column in one tab never
/// re-lays-out another tab's result. They are persisted with the tab's result snapshot and
/// mirrored into the URL / history for the active tab, exactly like the color modes.
function activePinnedColumns() {
const tab = getActiveTab();
return tab ? tab.pinnedColumns : {};
}
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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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;
}
/* A single-value result (one row, one column, e.g. SHOW CREATE TABLE) is shown
with its full height, so the whole value is visible without selecting the cell.
The rule is scoped to unselected cells: once the cell is selected it falls back
to the .td-selected behavior below, which keeps it bounded to 50vh and scrolling
internally instead of expanding into an unbounded absolutely-positioned overlay.
Only the vertical clamp is dropped (max-height: none): the base "overflow: hidden"
is kept so that with "white-space: pre" a long single line (e.g. SELECT repeat('x', 100000))
is still clipped horizontally at the 40vw cell cap instead of painting across the page.
With no max-height the box grows to the full content height, so nothing is clipped vertically.
No backticks in this comment: this style block lives inside a JS template literal. */
#data-table.single-value td:not(.td-selected) .cell-content
{
max-height: none;
}
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;
}
/* Icon at the left side of a column header to pin/unpin the column. Pinning is only
meaningful when the table is wider than the viewport (i.e. it can be scrolled
horizontally), so the toggle is disabled (in JS, via _updatePinAvailability) and
thus excluded from the tab order whenever the table does not scroll. Within a
scrollable table it stays out of the way like the colorize toggle - invisible
until the header is hovered - but, unlike the colorize toggle, it is a real
<button> that must stay in the tab order even while invisible so keyboard users
can reach it, so hiding it here uses opacity/pointer-events rather than display,
and it reappears on keyboard focus (:focus-visible) as well as on hover; an
already-pinned column keeps it visible as the pinned indicator. Sits at the left
so it does not crowd the colorize icon. */
.pin-toggle
{
display: inline-block;
position: absolute;
top: 50%;
left: 0.35rem;
transform: translateY(-50%);
cursor: pointer;
font-size: 0.9em;
line-height: 1;
user-select: none;
opacity: 0;
pointer-events: none;
/* Reset the <button> chrome so it reads as a plain icon, like the emoji it once
was as a <span>; the focus outline itself is left to the browser default. */
background: none;
border: none;
padding: 0;
margin: 0;
font-family: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: inherit;
}
/* Reveal the icon only when the table can scroll horizontally: on header hover or
keyboard focus of the toggle itself. */
#data-table.has-hscroll th:hover .pin-toggle,
#data-table.has-hscroll th .pin-toggle:focus-visible
{
opacity: 0.55;
pointer-events: auto;
}
/* A faint extra emphasis while the icon itself (not just its header) is hovered;
placed after the rule above so it wins the tie in specificity. */
#data-table.has-hscroll th .pin-toggle:hover
{
opacity: 0.85;
pointer-events: auto;
}
/* A pinned column shows its icon at full strength, so it is clear which columns
are pinned; placed last so it wins over the hover rules above regardless of
whether the pinned icon is also being hovered or focused. */
#data-table.has-hscroll th.pinned-column .pin-toggle
{
opacity: 1;
pointer-events: auto;
}
/* Touch / coarse-pointer devices have no hover, so keep the pin icon tappable -
still only when the table actually scrolls horizontally. */
@media (hover: none), (pointer: coarse)
{
#data-table.has-hscroll .pin-toggle
{
opacity: 0.55;
pointer-events: auto;
}
}
/* A disabled toggle (table not scrollable) is excluded from the tab order by the
browser; also make it fully transparent and non-interactive defensively. */
.pin-toggle:disabled
{
opacity: 0;
pointer-events: none;
}
/* Per-column color coding compares values across rows; with no more than a single
row there is nothing to compare, so hide the toggles. A column already colorized
with a non-default mode is exempted (its header carries the keep-colorize-toggle
class), so that mode stays clearable. The exemption is per column: one colorized
column must not reveal every other column's toggle. The extra selector specificity
overrides the hover and coarse-pointer rules above. */
.hide-colorize-toggle th:not(.keep-colorize-toggle) .colorize-toggle,
.hide-colorize-toggle th:not(.keep-colorize-toggle):hover .colorize-toggle
{
display: none;
}
/* 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 the ordinary cells around it. A pinned column stacks
higher still (see below), so a pinned column stays visible even when a selected
cell in a scrolling column slides underneath it. */
.td-selected
{
position: relative;
z-index: 10;
}
/* A pinned column sticks to the left/right edge of the scroll area during
horizontal scrolling instead of scrolling away. The per-cell left/right
offsets are assigned inline (they depend on the widths of the other pinned
columns, so several pinned columns stack against the edge). Both offsets are
set, so a pinned column stays within view: it slides with the content until it
would leave the viewport, then sticks to the near edge.
Body cells already carry an opaque background (the td rule) and header cells
the header color, which is what lets a pinned cell paint over the cells
scrolling underneath it. Header cells are already position: sticky (for the
vertical header), so the class only needs to raise the body cells; the z-index
here keeps a pinned body cell above the cells it covers, including a selected
cell (z-index 10) that scrolls underneath it - the pinned column must stay
visible even when the ordinary selected cell would otherwise paint over it. */
td.pinned-column
{
position: sticky;
z-index: 11;
}
/* Delimit a pinned column from the content scrolling underneath it with a border
on its left and right edges. A collapsed-border table paints cell borders at the
table level, which a stuck (sticky) cell does not carry along, so the edges are
drawn with an inset box-shadow that paints on the cell itself and stays visible
while the column is stuck. */
td.pinned-column, th.pinned-column
{
box-shadow: inset 1px 0 0 0 var(--border-color),
inset -1px 0 0 0 var(--border-color);
}
/* A selected cell in a pinned column must keep sticking (position: sticky) while
stacking one step above the pinned layer (z-index: 12), so its expanded content
paints over the neighbouring pinned cells. Without this more specific rule, the
.td-selected position: relative would win and the selected cell would scroll away
with the rest of the row. */
td.pinned-column.td-selected
{
position: sticky;
z-index: 12;
}
/* 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: Zen, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, 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: Zen Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", 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;
/// Per-column color coding modes for THIS result, keyed by column name. Each tab's
/// result element holds its own object (shared by all statements of a multi-query run
/// within the same tab), so toggling a color in one tab never repaints another tab's
/// identically-named columns. Points at the owning tab's `colorModes` object.
this._colorModes = {};
/// Pinned columns for THIS result, keyed by column name (`{name: true}`). Like `_colorModes`,
/// each tab's result element holds its own object (shared by all statements of a multi-query
/// run within the same tab, and pointing at the owning tab's `pinnedColumns`), so pinning a
/// column in one tab never re-lays-out another tab's identically-named columns.
this._pinnedColumns = {};
/// Set when a result finished rendering into this element while its tab was hidden: the
/// width/height-measuring layout passes (`transposeIfNeeded` / `_changeTableLayout`) read
/// `offsetWidth`/`offsetHeight`, which are 0 for a `display:none` panel and would freeze
/// every column to minimal width. They are deferred and run by `applyDeferredLayout` once
/// the tab is shown. The streamed rows are already in the DOM; only measurement waits.
this._pendingTranspose = false;
this._pendingChangeLayout = false;
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._dataTable.classList.remove('single-value');
this._dataDiv.classList.remove('fixed');
/// Drop the stale single-row marker: it is recomputed in `applyColumnColors` once the new
/// result finishes rendering. Clearing it here means that while rows are still streaming in,
/// the toggles follow the default hover behavior instead of staying hidden because the
/// previous result had at most a single row.
this._dataTable.classList.remove('hide-colorize-toggle');
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);
}
/// Peel transparent scalar wrappers down to the underlying value type, so that columns such as
/// `LowCardinality(Nullable(BFloat16))` or `SimpleAggregateFunction(sum, Float64)` are still
/// recognized as numeric/date. `AggregateFunction(...)` is intentionally left alone: its values
/// are opaque serialized states, not scalars, so they should stay left-aligned and uncolorized.
_unwrapScalarType(type)
{
let prev;
do
{
prev = type;
type = type
.replace(/^(?:Nullable|LowCardinality)\(([\s\S]*)\)$/, '$1')
.replace(/^SimpleAggregateFunction\([^,]+,\s*([\s\S]*)\)$/, '$1');
}
while (type !== prev);
return type;
}
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');
/// Full name and type in the tooltip, so a truncated header can be read in full on hover.
th.title = `${elem.name}\n${elem.type}`;
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);
const unwrapped_type = this._unwrapScalarType(elem.type);
this._column_is_number[elem.name] = !!unwrapped_type.match(/^(U?Int|Decimal|Float|BFloat)/);
this._column_is_date[elem.name] = !!unwrapped_type.match(/^Date/);
this._appendPinToggle(th, elem.name);
this._appendColorizeToggle(th, elem.name);
this._appendHeaderResizer(th);
/// Base header z-index: left columns are highest so a column's type-hint tooltip
/// overlaps its right neighbour. The `100 +` base keeps every header above the body
/// cells (including a pinned body cell, z-index 11, that scrolls up under the header);
/// pinned headers are raised further, into a separate band, by `applyPinnedColumns`.
th.style.zIndex = 100 + 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 left of a column header that pins/unpins the column. A real <button>,
/// so it is keyboard-reachable (Tab) and keyboard-activatable (Enter/Space) for free, unlike
/// the plain <span> icons used elsewhere on the header; see the .pin-toggle CSS for why. It
/// starts disabled - and so out of the tab order - until _updatePinAvailability finds the
/// table actually scrolls horizontally, since pinning is meaningless otherwise.
_appendPinToggle(th, name)
{
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'pin-toggle';
toggle.disabled = true;
/// A pushpin emoji, conveying "pin this column".
toggle.textContent = '\u{1F4CC}';
this._updatePinToggle(toggle, name);
toggle.addEventListener('click', (e) => {
e.stopPropagation();
this._togglePin(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);
}
/// Toggle whether a column is pinned. The pinned set is shared by column name WITHIN a tab
/// (mirroring `_cycleColorMode`), so re-lay-out every result table of the same tab that has
/// this column - scoped to this tab's panel, so other tabs keep their own independent pins -
/// then persist the change onto the active tab.
_togglePin(name)
{
if (this._pinnedColumns[name])
{
delete this._pinnedColumns[name];
}
else
{
this._pinnedColumns[name] = true;
}
const scope = this.closest('.tab-panel');
for (const el of (scope ? scope.querySelectorAll('query-result') : [this]))
{
el.applyPinnedColumns();
}
persistPinnedColumns();
}
_updatePinToggle(toggle, name)
{
const label = this._pinnedColumns[name]
? 'Unpin column (currently kept visible during horizontal scroll)'
: 'Pin column (keep it visible during horizontal scroll)';
toggle.title = label;
/// aria-label instead of relying on the title attribute, which screen readers announce
/// inconsistently; aria-pressed exposes the toggle's own state, since a screen reader
/// cannot see the pinned-column styling that conveys it visually.
toggle.setAttribute('aria-label', label);
toggle.setAttribute('aria-pressed', this._pinnedColumns[name] ? 'true' : 'false');
}
/// The rows whose data cells participate in pinning: the body rows and the totals row, but
/// not the header (its cells are handled separately, keeping their own sticky top position).
_pinnableRows()
{
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;
}
/// Lay out the pinned columns of this table for the current (shared) `pinned_columns` set:
/// mark each column's header and body/totals cells as pinned (or clear them), and assign the
/// per-cell sticky offsets so that several pinned columns stack against the edge instead of
/// overlapping. A no-op for non-tabular or vertical (transposed) results, which have no
/// horizontally scrolling column layout.
///
/// The `left` offset of a pinned column is the total width of the pinned columns before it,
/// and the `right` offset is the total width of the pinned columns after it; both are set so
/// the column sticks to whichever edge it reaches. Widths are read from the header cells,
/// which match the column widths in this collapsed-border table.
applyPinnedColumns()
{
if (!Array.isArray(this._header) || !this._dataTable.tHead) { return; }
if (this._willTranspose()) { return; }
/// Fast path for the common case where nothing is pinned in this table and there is no
/// stale pinned layout left over from a previous pass to clear: the full sweep below would
/// be a no-op except for horizontal-scroll availability, so just refresh that and return.
/// Both `applyColumnColors` and `_changeTableLayout` call this on every render, so without
/// this guard every ordinary result would pay two O(rows x columns) DOM passes before the
/// user has pinned anything - a real regression on large results. `_hasPinnedColumns` is
/// O(columns); the `querySelector` is a native read-only lookup that also catches cells
/// left `pinned-column` by a just-unpinned column (a pinned column always marks its header
/// cell too, so no stale layout escapes), for which the full clearing pass must still run.
if (!this._hasPinnedColumns() && !this._dataTable.querySelector('.pinned-column'))
{
this._updatePinAvailability();
return;
}
this._layoutPinnedColumns(this._pinnableRows());
}
/// Compute the per-column sticky offsets for the currently pinned columns from the current
/// header widths. `left_offset[j]` is the total width of the pinned columns before column `j`
/// and `right_offset[j]` the total width of those after it (both zero for unpinned columns), so
/// each pinned column sticks to whichever edge it reaches while the rest stack against it. Reads
/// the header cell widths, which match the column widths in this collapsed-border table.
/// O(columns) - no per-row work.
_computePinnedOffsets()
{
const thead = this._dataTable.tHead;
const header_len = this._header.length;
/// Cell index in a row for data column `j` (the leading row-number cell is at index 0).
const cellIndex = (j) => j + 1;
const pinned = this._header.map(col => !!this._pinnedColumns[col.name]);
/// Cumulative widths give the stacking offsets: left grows left-to-right, right grows
/// right-to-left, over the pinned columns only.
const left_offset = new Array(header_len).fill(0);
const right_offset = new Array(header_len).fill(0);
let acc = 0;
for (let j = 0; j < header_len; ++j)
{
if (!pinned[j]) { continue; }
left_offset[j] = acc;
acc += thead.children[cellIndex(j)].offsetWidth;
}
acc = 0;
for (let j = header_len - 1; j >= 0; --j)
{
if (!pinned[j]) { continue; }
right_offset[j] = acc;
acc += thead.children[cellIndex(j)].offsetWidth;
}
return { pinned, left_offset, right_offset };
}
/// A compact signature of a pinned-offset vector, used to detect when the offsets last applied
/// to the rendered rows have shifted (a pinned column changed width) and the earlier rows must
/// be re-laid-out. See `_layoutPinnedColumns` (which stores it) and `_pinStreamedRows`.
_pinnedOffsetsKey(left_offset, right_offset)
{
return left_offset.join(',') + '|' + right_offset.join(',');
}
/// Shared core of the pinned-column layout: recompute the per-column sticky offsets from the
/// current header widths, then mark (or clear) the pinned state on the header cells and on the
/// given data rows, and refresh horizontal-scroll availability. `applyPinnedColumns` passes
/// every body/totals row for a full (re)layout; `_pinStreamedRows` passes only the rows a
/// streaming chunk just appended, so the per-chunk work stays proportional to the new rows.
_layoutPinnedColumns(rows)
{
const thead = this._dataTable.tHead;
const header_len = this._header.length;
/// Cell index in a row for data column `j` (the leading row-number cell is at index 0).
const cellIndex = (j) => j + 1;
const { pinned, left_offset, right_offset } = this._computePinnedOffsets();
/// Remember the offsets just applied so `_pinStreamedRows` can tell when a later chunk shifts
/// them (a pinned column widened) and the rows already rendered with the old offsets need a
/// full re-layout rather than only the new rows.
this._pinnedLayoutKey = this._pinnedOffsetsKey(left_offset, right_offset);
for (let j = 0; j < header_len; ++j)
{
const idx = cellIndex(j);
const th = thead.children[idx];
const cells = [th];
for (const row of rows)
{
if (row.cells[idx]) { cells.push(row.cells[idx]); }
}
for (const cell of cells)
{
if (pinned[j])
{
cell.classList.add('pinned-column');
cell.style.left = `${left_offset[j]}px`;
cell.style.right = `${right_offset[j]}px`;
}
else
{
cell.classList.remove('pinned-column');
cell.style.left = '';
cell.style.right = '';
}
}
/// Raise pinned headers into a band above every other header, so a pinned header cell
/// paints over the ordinary headers scrolling behind it; unpinned headers keep the
/// base band assigned in `appendHeader`.
th.style.zIndex = (pinned[j] ? 2000 : 100) + (header_len - j);
const toggle = th.querySelector('.pin-toggle');
if (toggle) { this._updatePinToggle(toggle, this._header[j].name); }
}
this._updatePinAvailability();
}
/// Keep the pinned-column feature responsive while a table result is still streaming, called
/// after each `flushFragment`. Two cases, both cheap in the common path:
/// - Some column is pinned (e.g. a wide `?pinned_columns=...&run=1` link was opened): keep the
/// pinned columns sticky throughout streaming instead of snapping into the pinned state only
/// when `applyColumnColors` runs at EOF. If the pinned-column offsets are unchanged since the
/// last layout, only the rows this chunk appended need them (plus the header, which is cheap),
/// so the per-chunk cost stays proportional to the new rows. If a pinned column widened in
/// this chunk, the offsets of the pinned columns after it shift, so every row already rendered
/// with the old offsets must be re-laid-out too, or those earlier rows would drift out of
/// alignment (overlap or leave gaps) until EOF; that full pass runs only on the chunks that
/// actually change a width. (`_layoutPinnedColumns` refreshes availability itself at the end.)
/// - Nothing pinned yet: there are no offsets to lay out, but the table may have just grown
/// wide enough to scroll horizontally, which is what reveals and enables the pin toggles.
/// Refresh only that availability (O(columns), no per-row reflow) so a user can pin a column
/// on a long-running wide query without waiting for it to finish - the earlier behaviour
/// kept the toggles disabled/hidden until EOF.
_pinStreamedRows(newRows)
{
if (!Array.isArray(this._header) || !this._dataTable.tHead) { return; }
if (this._willTranspose()) { return; }
if (this._hasPinnedColumns())
{
const { left_offset, right_offset } = this._computePinnedOffsets();
const changed = this._pinnedOffsetsKey(left_offset, right_offset) !== this._pinnedLayoutKey;
this._layoutPinnedColumns(changed ? this._pinnableRows() : newRows);
}
else
{
this._updatePinAvailability();
}
}
/// Reflect whether this table can be scrolled horizontally onto the `has-hscroll` class, which
/// gates the visibility of the pin toggles: pinning only makes sense when the table is wider
/// than the space available to it. Also enables/disables the toggle buttons themselves to
/// match: a disabled <button> is excluded from the tab order, so pinning stays unreachable by
/// keyboard too while it is meaningless. Called from `applyPinnedColumns` (every layout pass)
/// and on window resize. The table overflows when its rendered width exceeds the host
/// element's width (the host is a block box constrained to the scroll container, so a wider
/// table overflows it and produces the horizontal scrollbar rather than stretching the host).
_updatePinAvailability()
{
if (!this._dataTable) { return; }
const overflows = this._dataTable.offsetWidth > this.clientWidth + 1;
this._dataTable.classList.toggle('has-hscroll', overflows);
if (this._dataTable.tHead)
{
for (const toggle of this._dataTable.tHead.querySelectorAll('.pin-toggle'))
{
toggle.disabled = !overflows;
}
}
}
/// Whether any column of this table is currently pinned. On resize this decides whether the
/// full pinned layout must be recomputed - the sticky `left`/`right` offsets are baked from
/// the header widths, which are viewport-dependent - or the cheaper availability refresh is
/// enough (nothing pinned means no offsets to update).
_hasPinnedColumns()
{
return Array.isArray(this._header) && this._header.some(col => this._pinnedColumns[col.name]);
}
/// 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 this._colorModes[name];
}
else
{
this._colorModes[name] = next;
}
/// The modes are shared by column name WITHIN a tab, so every result table of the same
/// tab that has this column must be repainted (not just the clicked one) to stay in sync.
/// Scope to this tab's panel: other tabs keep their own independent modes.
const scope = this.closest('.tab-panel');
for (const el of (scope ? scope.querySelectorAll('query-result') : [this]))
{
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);
/// The new mode can colorize (or de-colorize) a single-row table whose toggles were hidden,
/// so re-evaluate their visibility to keep an active mode reachable.
this._updateColorizeToggleVisibility();
/// 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(this._colorModes, name))
{
const stored = this._colorModes[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 = '';
}
}
}
/// Whether a column is currently painted with color: it has an effective mode that both differs
/// from the per-type default and actually colorizes (anything other than `none`). Such a column
/// is "already colorized", so it keeps its toggle even on a single-row result — the modes are
/// shared by column name across tables, so a mode persisted from an earlier multi-row query
/// would otherwise have no visible control to switch it back off. A column explicitly set to
/// `none` (e.g. a numeric column whose default is `bar`) paints nothing, so it does not qualify.
_columnIsColorized(name)
{
const mode = this._effectiveColorMode(name);
return mode !== 'none' && mode !== this._defaultColorMode(name);
}
/// Show or hide the per-column colorize toggles for this result. Per-column color coding
/// compares values across rows; with no more than a single row there is nothing to compare, so
/// the toggles are hidden. A totals row (from `WITH TOTALS`) is a second comparable value per
/// column, so coloring stays meaningful there. The hiding is per column: a column already
/// colorized with a non-default mode is exempted (its header keeps `keep-colorize-toggle`), so
/// that mode stays clearable, while unrelated single-value columns still hide their toggle.
/// Recomputed on the final repaint and whenever a mode is cycled (a shared mode can colorize a
/// single-row table) — never per row.
_updateColorizeToggleVisibility()
{
const tbody = this._dataTable.tBodies[0];
this._dataTable.classList.toggle('hide-colorize-toggle',
!this._totals && (!tbody || tbody.rows.length <= 1));
/// Mark the header of every already-colorized column, so its toggle survives the table-wide
/// hiding above. Iterating the header (rather than one class for the whole table) keeps the
/// exemption per column: one colorized column must not reveal every other column's toggle.
const thead = this._dataTable.tHead;
if (!thead || !Array.isArray(this._header)) { return; }
for (const column of this._header)
{
const th = thead.children[this._columnIndex(column.name)];
if (th) { th.classList.toggle('keep-colorize-toggle', this._columnIsColorized(column.name)); }
}
}
/// 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; }
this._updateColorizeToggleVisibility();
/// 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();
/// Apply the pinned-column layout here too: `applyColumnColors` runs in every table-render
/// path (live run and both history-restore paths) once all rows are present, so this is the
/// single common point at which the pinned columns get their sticky offsets on first paint.
this.applyPinnedColumns();
}
/// 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); }
/// No horizontal column layout in vertical mode, so drop the pin toggle too.
const pin_toggle = th.querySelector('.pin-toggle');
if (pin_toggle) { th.removeChild(pin_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);
}
/// A result that is exactly one row and one column (for example, `SHOW CREATE TABLE`) is shown
/// with its full height instead of the default 3-line clamp, so the whole value is visible
/// without having to select the cell.
expandSingleValueIfNeeded()
{
const table = this._dataTable;
const is_single_value = !this._totals
&& Array.isArray(this._header) && this._header.length == 1
&& table.rows.length == 1;
table.classList.toggle('single-value', is_single_value);
}
/// Run the measurement-based layout passes that were deferred because this element's tab was
/// hidden when its query finished (see `_pendingTranspose`/`_pendingChangeLayout`). Called on
/// activation, once the panel is visible so `offsetWidth`/`offsetHeight` are meaningful.
applyDeferredLayout()
{
if (this._pendingTranspose) { this._pendingTranspose = false; this.transposeIfNeeded(); }
if (this._pendingChangeLayout) { this._pendingChangeLayout = false; this._changeTableLayout(); }
}
_changeTableLayout()
{
const table = this._dataTable;
const headers = table.querySelectorAll('th');
table.style.setProperty('--table-width', `${table.getBoundingClientRect().width}px`);
/// Use the exact fractional border-box width. `offsetWidth` rounds the real (fractional)
/// layout width to the nearest integer, so it under-reports by up to ~0.5px about half the
/// time. With `box-sizing: border-box`, writing that smaller value back shrinks the content
/// box by the lost fraction, and the `.name` div (overflow: hidden; text-overflow: ellipsis)
/// then clips the title even though it just fit before freezing. CSS `width` accepts
/// fractional `px`, so freezing the raw widths preserves the auto-layout geometry exactly.
let columnWidths = [];
headers.forEach(header => {
columnWidths.push(header.getBoundingClientRect().width);
});
/* Freeze row heights so that absolutely-positioned cell content
does not cause reflow of the following rows. Read all heights first,
then write, to avoid layout thrashing.
A single-value result is deliberately shown unclamped at its full height
(see `expandSingleValueIfNeeded`) and has a single row with nothing below it
to reflow. Freezing that full height would keep the row full-height even after
the cell is selected and its content collapses to a bounded 50vh overlay,
leaving a large blank row; so skip the freeze for single-value tables and let
the row follow its content (it then shrinks to the row-number cell's height
when the value cell is selected). */
const freeze_row_heights = !table.classList.contains('single-value');
let rowHeights = [];
if (freeze_row_heights)
{
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`;
});
if (freeze_row_heights)
{
let i = 0;
for (let row of table.rows)
{
row.style.height = `${rowHeights[i++]}px`;
}
}
/// Recompute the pinned-column offsets against the now-frozen column widths, so they match
/// exactly whatever this layout pass measured (this also covers any render path that reaches
/// `_changeTableLayout` without going through `applyColumnColors`).
this.applyPinnedColumns();
}
_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);
/// A resize changes column widths, so the pinned columns' stacking offsets must be
/// recomputed to stay aligned with the new widths.
this.applyPinnedColumns();
};
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);
/// Two supersede tokens guard against races. They used to be a single `request_num`, but
/// persistent per-tab queries need them separated:
/// - `activation_num` (this global): bumped on every tab activation, Back/Forward navigation,
/// and run launch. It guards the SHARED editor + parameter-input restore (`restoreEditor`),
/// so a superseded switch stops writing the editor. It does NOT abort queries.
/// - `tab.reqNum` (per tab, see `makeTab`): bumped on each run / (re)materialization / cancel /
/// close WITHIN a tab. It guards everything that writes into that tab's own result element
/// (a live `postImpl` stream and a snapshot materialization). A plain switch does not bump it,
/// so a query keeps streaming into its background tab and a late response still lands there.
let activation_num = 0;
/// Run identity, minted once per run launch (`postOne` / `postAll`) into the launching tab's
/// `launchRunId`. Unlike the launch query text, it is UNIQUE per execution: a rerun of the
/// identical query gets a fresh id, and a partial `Run selected` shares it with the full-editor
/// history entry it wrote even though their query texts differ. `window.onpopstate` uses it to tell
/// whether a tab's live state (still-running launch, or completed snapshot) truly belongs to the
/// run a mid-run (`result: null`) history entry was written for, instead of inferring "same run"
/// from matching query/params (which both a rerun and a selection defeat). The completed snapshot
/// carries it as `tab.result.run_id`, and every history entry as `state.run_id`. Those persisted
/// ids outlive the page (browser history, `IndexedDB` tab snapshots), and the set of surviving
/// entries is NOT enumerable at startup — a deeper Back/Forward entry can outlive a reload without
/// ever reaching `IndexedDB` (the deferred `persist`, private mode) — so no re-seeded counter can
/// promise uniqueness against them. Instead, every id is qualified by `run_epoch`, a random token
/// drawn once per page instance: ids minted by different page instances can never be equal, so a
/// post-reload launch can never alias an entry written before the reload, while ids persisted
/// TOGETHER by a previous instance (a tab snapshot and the history entries of the same run) still
/// match each other after a reload.
const run_epoch = Array.from(crypto.getRandomValues(new Uint32Array(2)), n => n.toString(36)).join('');
let run_seq = 0;
function nextRunId()
{
return run_epoch + '-' + (++run_seq);
}
/// While a tab activation is rebuilding the active tab's parameter inputs, this holds the
/// activation's request token. `restoreEditor` 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` treats ANY non-null
/// token as "the inputs are not committed yet". A run that supersedes the restore (`postOne`/
/// `postAll` bump `activation_num` mid-await) leaves the inputs uncommitted during the await, but it
/// does NOT start a new restore, so it keeps this token: the superseded `restoreEditor` still runs
/// its tail when the await resolves, commits this tab's parameters into the rebuilt inputs and
/// clears the token. Only a newer ACTIVATION overwrites this token with its own; that restore then
/// owns the guard and the superseded one bails without touching it. Keying the bail on token
/// ownership (not `activation_num`) keeps the guard from staying latched until the next tab switch,
/// which would decouple the visible inputs from the values a later run actually sends.
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 (its content restored from the active tab on switch), but each tab
/// owns a PERSISTENT result panel (`tab.panel` / `tab.resultEl`) that stays alive in the DOM
/// while inactive (just hidden). So switching away and back neither re-renders the result (an
/// image, chart or oversized table survives intact) nor aborts a running query — a query keeps
/// streaming into its own tab's element in the background. Panels are materialized lazily: a
/// tab loaded from IndexedDB builds its DOM only when first activated. Per-tab runtime state
/// (`resultEl`, `controller`, `reqNum`, `inFlight`, `progress`, `colorModes`, …) is transient
/// and never persisted; only `{id,title,query,params,result,lastSavedQuery}` are saved.
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 activeTitleCommit = null; /// commit fn of the open inline title editor, or null; lets other paths flush a pending rename
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 only once reconciliation
/// has FULLY finished (not merely started), after which these signals are irrelevant. The whole
/// reconciliation still contains awaits (`activateTab`, `updateQueryParams`, the deferred
/// auto-run), so keeping the flag live until the very end is what lets a real edit made during
/// those awaits gate the URL-driven parameter restore and the auto-run. Reconciliation's OWN
/// programmatic edits do not set the flag: the synthetic input events it dispatches carry
/// `isTrusted === false` and are filtered below.
let bootstrap_dirty = false;
let bootstrap_settled = false;
function markBootstrapDirty(e) {
/// Only genuine user actions count. The page programmatically dispatches input events during
/// bootstrap/reconciliation; those carry `isTrusted === false` and must be ignored. Once
/// reconciliation has fully finished, `bootstrap_dirty` is no longer read, so short-circuit.
if (bootstrap_settled) return;
if (e && e.isTrusted === false) return;
bootstrap_dirty = true;
}
/// Set by `writeHistoryEntry` the moment it writes the current history entry WITHOUT `run=1`,
/// cancelling the URL's deferred `run=1` auto-run if it is still pending: the live URL no longer
/// requests an auto-run, so executing the deferred `postAll` anyway would run the shared query
/// against a database, server/user or edited query/params the link never specified. Centralizing
/// the cancellation in the history-write funnel (rather than at each trusted action that can end
/// up clearing `run=1` — a database-panel click, a connection-field edit, a query/param edit, a
/// tab switch) means no future caller needs to remember to set it. The funnel has two mouths:
/// `writeHistoryEntry` for an entry that can be written, and `refreshCurrentHistoryEntry`'s bail
/// for a current entry that CANNOT (during startup it can still belong to a tab of the previous
/// page instance — `history.state` survives a reload — until `reconcileStartup` rebinds it); the
/// bail applies the same `carries_run` rule, so the cancellation is not lost with the write.
/// `bootstrap_dirty` cannot carry this
/// signal: none of those actions is a workspace edit (the saved tabs must still be restored and
/// reconciled as usual), and the pending window extends past `bootstrap_settled` (`reconcileStartup`
/// keeps awaiting — tab activation, parameter reconciliation — after settling, and
/// `markBootstrapDirty` ignores events from then on). The query itself is untouched: it stays in
/// the editor, targeting the fresh selection/connection/edit, and the user can still run it
/// explicitly.
let deferred_run_cancelled = false;
/// Set by `window.onpopstate` while it replays synthetic connection `input` events to reproduce a
/// navigated-to entry's server/user. Those events fire the normal connection listener, whose
/// `resetConnectionScopedState` restamps the CURRENT history entry from the active tab when the
/// connection or selection changed. During a Back/Forward restore that is wrong twice over:
/// `history.state` is already the popped entry the restore is rebuilding, and `activeTabId` still
/// points at the tab being left (it is rebound only later in `onpopstate`), so the restamp would
/// overwrite the time-travel target with the leaving tab's newer query/cleared `database=`. While
/// this flag is set, `resetConnectionScopedState` still resets its connection-scoped caches but
/// skips the history restamp; `onpopstate` performs the authoritative entry/URL restore itself.
let restoring_connection_from_history = false;
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.
/// Seeded into the bootstrap tab's `colorModes` once it is created (see the bootstrap below).
let url_color_modes = {};
const color_modes_from_url = current_url.searchParams.get('color_modes');
if (color_modes_from_url) {
try { url_color_modes = JSON.parse(color_modes_from_url) || {}; } catch (e) { /* ignore malformed */ }
}
/// Restore the pinned columns from the URL, so a shared/bookmarked link keeps them. Seeded into the
/// bootstrap tab's `pinnedColumns` (see the bootstrap below), exactly like the color modes above.
let url_pinned_columns = {};
const pinned_columns_from_url = current_url.searchParams.get('pinned_columns');
if (pinned_columns_from_url) {
try { url_pinned_columns = JSON.parse(pinned_columns_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 logoEl = document.getElementById('logo-container');
/// Container for the per-tab result panels. Each tab owns one `.tab-panel` here (created
/// lazily); only the active tab's panel is shown. `resultEl`/`multiQueryContainer` are dynamic
/// aliases that always point at the ACTIVE tab's elements, repointed by `activateTab`, so the
/// many "operate on the current result" call sites keep working. A running query, however,
/// targets the tab it was launched in via `tab.resultEl`, never these aliases.
let resultPanelsEl = document.getElementById('result-panels');
let resultEl = null;
let multiQueryContainer = null;
/// Route a result element's live progress to the shared progress bar (under Run) and to the
/// owning tab's title indicators. For a background tab only the tab-title effects update; the
/// shared bar reflects the active tab only. Attached per tab in `getOrCreatePanel`.
function handleResultProgress(tab, detail) {
tab.progress = {
read_rows: +detail.read_rows,
read_bytes: +detail.read_bytes,
total_rows_to_read: +detail.total_rows_to_read,
elapsed_ns: +detail.elapsed_ns,
rowCount: tab.resultEl ? tab.resultEl.rowCount : 0,
incomplete: tab.resultEl ? tab.resultEl.incompleteResult : false,
};
updateTabIndicator(tab);
if (tab.id === activeTabId) {
elapsed_ns = +detail.elapsed_ns;
progressEl.updateProgress(detail);
progressEl.updateText(tab.resultEl.rowCount, elapsed_ns, tab.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 Datastore 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.
/// The non-empty Play username is carried over as a `user` query parameter so the docs page 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).
{
const docs_icon = document.getElementById('docs-icon');
function updateDocsURL() {
try {
const url = new URL('/docs', url_elem.value);
if (user_elem.value) url.searchParams.set('user', user_elem.value);
docs_icon.href = url.href;
} catch (e) {
/// Leave the previous href in place while the configured URL is not parsable.
}
}
url_elem.addEventListener('input', updateDocsURL);
user_elem.addEventListener('input', updateDocsURL);
/// Refresh the `href` before navigation so an open uses the current server URL and user. `#user` is a
/// browser-autofillable credential field, and autofilling a saved login does not always fire `input`,
/// which would otherwise leave `docs_icon.href` stale. `pointerdown` fires before the middle-button
/// `auxclick` navigation (which produces no `click`), while `click` also covers keyboard activation
/// (tab to the link, then Enter) where no pointer event fires at all. Together they refresh the `href`
/// on every navigation path, matching how the Web Terminal link refreshes its `href`.
docs_icon.addEventListener('pointerdown', updateDocsURL);
docs_icon.addEventListener('click', 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.
/// `quiet` suppresses touching the shared result pane (used by background panel-maintenance
/// queries). `on_error`, when provided, still receives the real server error text in that case,
/// so a quiet caller can surface actionable diagnostics in its own inline UI instead of dropping
/// them - a quiet query hides the error from the result pane, not from the user.
async function auxiliaryQuery(server_address, user, password, query, url_params = '', quiet = false, generation = null, on_error = 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';
}
if (on_error) on_error("Network error");
return false;
}
if (!response.ok) {
let error_text = await response.text();
if (!quiet && belongs_to_active_connection()) {
clear();
resultEl.renderError(error_text);
logoEl.style.display = 'none';
}
if (on_error) on_error(error_text);
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();
/// The database the user selected in the database panel. When set, it is passed as the
/// default database (`&database=`) for queries run from the editor, so unqualified table
/// names resolve against it. It is cleared when the connection identity changes (see
/// `resetConnectionScopedState`), since it refers to a database on the previous connection; a
/// connection-field edit that does not change the semantic identity — a cosmetic URL normalization
/// or a password-only change — keeps it.
/// The selection is committed synchronously on the panel click by `commitSelectedDatabase` (not
/// after the table list finishes loading), so a `Run` issued before the load returns already
/// targets it. It is a connection-scoped setting (like the server address and user), so it is
/// seeded from the URL and serialized back into every history URL by `buildHistoryParams`: a
/// shared or reloaded link — including an auto-run `run=1` link — then resolves unqualified names
/// against the same database, instead of silently falling back to the server default. On a
/// Back/Forward step, `window.onpopstate` restores it from the navigated URL — but only when that
/// entry belongs to the current connection (server/user), since the connection itself is global
/// and is not restored by history navigation; a cross-connection entry's database is left alone
/// rather than imported into the live connection.
let selected_database = current_url.searchParams.get('database');
/// The (server, user) connection the page loaded with, captured after the startup block above
/// seeded the connection fields from the URL and before any user edit. While no history entry
/// with an authoritative connection stamp exists — the bootstrap window before the first history
/// write, or a reload whose surviving `history.state` was written by a play.html predating the
/// stamp — `resetConnectionScopedState` compares against it to decide whether a trusted connection
/// edit must cancel a deferred startup auto-run (`defer_run_for_reconcile`), which is armed against
/// this loaded connection. Once a stamped entry exists, that entry's own
/// `connectionServer`/`connectionUser` stamp is authoritative.
const initial_connection = {server: url_elem.value, user: user_elem.value};
/// The (server, user) connection the current `selected_database` belongs to.
/// `resetConnectionScopedState` compares the live connection against this — via the same
/// `sameServerAddress` / `effectiveConnectionUser` identity the `window.onpopstate` restore gate
/// uses — so an edit to a connection field that does not change the semantic identity keeps the
/// selection instead of silently dropping it (and retargeting the next unqualified `Run` to the
/// server default). It is refreshed wherever `selected_database` is set: here from the URL at load,
/// on a panel click (`commitSelectedDatabase`), and on a Back/Forward restore (`window.onpopstate`).
/// Null when nothing is selected.
let selected_database_connection = null;
/// Record (or clear, when nothing is selected) the connection `selected_database` belongs to. The
/// connection fields are read live: `url_elem`/`user_elem` are already seeded from the URL by the
/// startup block above, so the initial call below captures the connection the URL's `database=`
/// was chosen for.
function stampSelectedDatabaseConnection() {
selected_database_connection = selected_database === null
? null
: {server: url_elem.value, user: user_elem.value};
}
stampSelectedDatabaseConnection();
/// The server's current database (`currentDatabase()`), recorded while building the database
/// panel. The highlight falls back to it when nothing is selected (e.g. after a history
/// navigation to an entry that carried no `database=`), without re-querying `system.databases`.
let server_current_database = null;
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 });
});
/// Record the server's current database so the highlight can fall back to it (see
/// `applySelectedDatabaseHighlight`) without re-querying `system.databases`.
server_current_database = (json.data.find(e => e.current) || {}).database ?? null;
for (let elem of json.data) {
let database = document.createElement('div');
database.className = 'database';
let database_link = document.createElement('button');
/// The database name is stamped as a data attribute (not read back from the button text,
/// which also carries the table-count badge) so `applySelectedDatabaseHighlight` can move
/// the `.current` class between rows without rebuilding the list.
database_link.dataset.database = elem.database;
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` commits this database as the editor's selected
/// database (a manual open of a collapsed row); a refresh or a restore of the previously
/// expanded state passes false, so merely reloading a row's table list never silently
/// retargets editor queries to it. `quiet` (defaulting to a background restore, i.e.
/// `!set_current`) suppresses surfacing the load's error in the main result pane; an
/// explicit refresh overrides it to keep surfacing errors as before.
const refreshTables = (set_current = true, quiet = !set_current) => {
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, quiet).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();
/// A refresh reloads this row's table list only. It must not commit the selection
/// (`set_current = false`), so refreshing an already-open database never silently
/// retargets the editor's default database to it; but it still surfaces its errors
/// (`quiet = false`), since it is an explicit user action, not a background restore.
if (database.dataset['opened']) refreshTables(false, false);
});
/// 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);
}
}
/// The selected database survives closing and reopening the panel (it is only cleared on a
/// connection change) and can be seeded from the URL, but the reopen path never re-runs a
/// `set_current` load, so the highlight would otherwise snap back to `currentDatabase()` and
/// the panel would show a different database than the one unqualified names actually resolve
/// against. Highlight the selected database (or fall back to the server's current one).
applySelectedDatabaseHighlight();
}
/// Move the `.current` highlight of the database panel to `selected_database` (or, when nothing
/// is selected or the selection no longer exists, to the server's current database), without
/// reloading the list. Used both while building the list in `loadDatabases` and after a history
/// navigation changes `selected_database`, so the open panel always shows the database that the
/// next run and unqualified names actually resolve against. A no-op when the panel is empty.
function applySelectedDatabaseHighlight() {
const databases_elem = document.getElementById('databases');
if (!databases_elem) return;
const buttons = databases_elem.querySelectorAll('button[data-database]');
const has_selected = selected_database
&& Array.from(buttons).some(b => b.dataset.database === selected_database);
/// When a database is explicitly selected, highlight it only while it is actually present in
/// the list, and otherwise highlight nothing: falling back to the server's current database
/// would make the panel claim one database while `postSingle`/`postMulti` still run against
/// the (still-set) selection — a stale panel relative to a navigated `database=`, or a
/// selection that has since disappeared from `system.databases`, would then lie about the next
/// run. The `null` target matches no row, so nothing is highlighted until an authoritative
/// reload brings the row back or the selection is cleared (on a connection change). Only fall
/// back to the server default when nothing is selected at all.
const target = selected_database ? (has_selected ? selected_database : null) : server_current_database;
for (const button of buttons) {
button.classList.toggle('current', button.dataset.database === target);
}
}
/// Canonicalize a database designation to the effective target it denotes on the live connection:
/// an absent/empty value means "the server's default database" and is represented as `null`, and an
/// explicit name equal to the server's current database denotes that same target — both run the
/// query against the same database — so it collapses to `null` too. A database value can carry the
/// explicit spelling of the current database even though `commitSelectedDatabase` only ever assigns
/// the canonical form: the live selection is seeded verbatim from a shared/restored `?database=` URL
/// (at load and in `window.onpopstate`), `commitSelectedDatabase` preserves that raw spelling on a
/// no-op re-click (it returns early without canonicalizing), and a run snapshot (`tab.result.database`)
/// keeps whatever the selection was at run time — so the `run=1` divergence checks
/// (`writeHistoryEntry`, `tabReflectsRun`) must
/// compare BOTH sides through this canonical form, or a semantically no-op interaction (clicking the
/// already-highlighted current row) would make `null` look different from the snapshot's explicit
/// spelling and spuriously drop `run=1` from a still-reproducible entry.
/// `server_current_database` is `null` until the database list has loaded for the live connection;
/// while it is unknown an explicit value is kept rather than risking a false collapse (fail safe —
/// two spellings then compare raw, never two different databases comparing equal).
function effectiveDatabase(database) {
const value = database || null;
return (server_current_database !== null && value === server_current_database) ? null : value;
}
/// Commit a database selection made in the panel: record it as the default database for editor
/// queries (`selected_database`), move the panel highlight to it, and fold it into the current
/// browser-history entry so a reload or shared link carries the new `database=` (and drops `run=1`
/// once the selection no longer matches the database that produced the shown result — see
/// `tabReflectsRun`). Called synchronously on the trusted panel click — before the table list is
/// even requested — so a `Run` issued before the load returns already targets the just-clicked
/// database, and so switching databases restamps the entry the same way a query or parameter edit
/// does (`refreshCurrentHistoryEntry`) instead of leaving a stale `database=`/`run=1` in the URL.
///
/// A semantic no-op — clicking a database that resolves to the same effective target already
/// selected, whether that target is held as `null` (nothing selected) or as an explicit
/// `?database=<current>` spelling seeded verbatim from the URL — returns early WITHOUT touching
/// `selected_database`: it must neither rewrite the current history entry nor canonicalize the live
/// selection.
/// - Not rewriting the entry: on a fresh `?run=1&database=<selected>#...` bootstrap load the tab
/// still has no result, so the rewrite below would drop `run=1` and — via `writeHistoryEntry` —
/// set `deferred_run_cancelled`, cancelling the deferred startup auto-run even though the target
/// database is unchanged, so the shared query would never run.
/// - Keeping the raw `selected_database`: collapsing an explicit `?database=<current>` spelling to
/// `null` here would later, once a same-identity connection edit (a password change, or
/// `http://h:8123` -> `http://h:8123/`) clears `server_current_database` in
/// `resetConnectionScopedState`, make `effectiveDatabase(selected_database)` (now `null`) differ
/// from the run snapshot's still-explicit `effectiveDatabase(tab.result.database)` — so
/// `tabReflectsRun`/`writeHistoryEntry` would spuriously drop `run=1` and `database=` from a
/// still-reproducible entry on the next history sync (e.g. a tab switch).
///
/// On a genuine change the selection is stored in canonical effective form (`effectiveDatabase`):
/// selecting the server's already-current database while a different one was selected is identical
/// to leaving the selection unset, so it collapses back to `null`.
function commitSelectedDatabase(database) {
const new_selection = effectiveDatabase(database);
/// Whether the click actually changes the effective target database. Both sides are compared in
/// canonical form (`effectiveDatabase`) so that re-selecting the already-current database —
/// whether it is held as `null` (nothing selected) or as its explicit spelling seeded verbatim
/// from a `?database=` URL — is recognized as a no-op rather than a change. On a no-op nothing
/// below runs, so the live `selected_database` keeps its raw spelling (see the header comment).
const selection_changed = new_selection !== effectiveDatabase(selected_database);
if (!selection_changed) return;
selected_database = new_selection;
stampSelectedDatabaseConnection();
applySelectedDatabaseHighlight();
captureActiveTab();
/// The entry rewrite below drops `run=1` while the bootstrap tab has no result yet, which
/// itself cancels a still-deferred startup auto-run (see `deferred_run_cancelled` and
/// `writeHistoryEntry`) — firing it anyway would execute the shared query against a database
/// the link never asked for.
refreshCurrentHistoryEntry(getActiveTab());
}
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;
}
/// Set the query editor to `value` (or run it, if it already holds exactly that query - i.e. on
/// a second click, or open it in a new Web UI tab on a new-tab gesture), then scroll the editor
/// into view. Shared by the table rows and the column rows. Stops the click so it does not
/// bubble to an enclosing row's handler.
function setQueryOrRun(e, value) {
if (isMac && e.button !== 1 && e.ctrlKey) {
/// On macOS `Ctrl+Click` is the secondary (context-menu) click, not a query
/// activation or new-tab gesture. Leave it entirely to the browser (so the native
/// context menu can appear) and never activate, replace, or run the query - not
/// even the second-click `postOne` path below. The macOS new-tab modifier is Cmd
/// (`metaKey`), handled in the new-tab branch. A middle click (button 1) is a
/// genuine new-tab gesture even with Ctrl held, so it is excluded here.
return;
}
if (query_area.value == value) {
/// A second click on an already-loaded query runs it - regardless of whether it
/// was a plain, middle, or modifier click (opening yet another identical tab
/// would be pointless).
postOne();
} else if (e.button === 1 || e.shiftKey || (isMac ? e.metaKey : e.ctrlKey)) {
/// Middle click, Shift+click, or the platform's new-tab modifier click (Cmd on
/// macOS, Ctrl elsewhere) - the gestures that open a link in a new tab - open the
/// query in a new Web UI tab instead of replacing the current one. Only a genuine
/// middle click (button 1) needs the X11 PRIMARY paste guard.
openQueryInNewTab(value, e.button === 1);
} 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();
}
/// Every data-type icon is an SVG in the same 24x24 viewBox, so they all render at exactly the
/// same dimensions - whether the glyph is a line drawing (Date/DateTime) or a short text label
/// (`09`, `Aa`, `[]`, `{}`, `(,)`, `T`).
function makeColumnSVG() {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.classList.add('column-type-icon');
return svg;
}
/// A line-drawing data-type icon (no fill, stroked with the current text color).
function makeColumnIcon(...paths) {
const svg = makeColumnSVG();
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '2');
svg.setAttribute('stroke-linecap', 'round');
svg.setAttribute('stroke-linejoin', 'round');
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;
}
/// A short text label rendered as an SVG glyph, centered in the same box as the line icons.
/// `textLength` pins every label to the same width so 1-, 2- and 3-character labels line up.
function makeColumnBadge(text) {
const svg = makeColumnSVG();
svg.classList.add('column-type-badge');
const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
t.setAttribute('x', '12');
t.setAttribute('y', '12.5');
t.setAttribute('text-anchor', 'middle');
t.setAttribute('dominant-baseline', 'central');
t.setAttribute('fill', 'currentColor');
t.setAttribute('textLength', text.length > 1 ? '17' : '9');
t.setAttribute('lengthAdjust', 'spacingAndGlyphs');
t.textContent = text;
svg.appendChild(t);
return svg;
}
/// Pick an icon for a column from its data type. Modifier wrappers (`Nullable`, `LowCardinality`)
/// are peeled off first so that e.g. `Nullable(Int64)` still reads as numeric, while container
/// types (`Array`, `Map`, `Tuple`) are recognised at the outer level.
function getColumnTypeIcon(type) {
let t = String(type).trim();
for (let m; (m = /^(?:Nullable|LowCardinality)\((.*)\)$/.exec(t)); )
t = m[1].trim();
if (/^Array\s*\(/.test(t)) return makeColumnBadge('[]');
if (/^(?:Map\s*\(|JSON\b|Object\b)/.test(t)) return makeColumnBadge('{}');
if (/^(?:Tuple|Nested)\s*\(/.test(t)) return makeColumnBadge('(,)');
/// Date - calendar. Drawn within a central region so its visual size matches the text glyphs.
if (/^Date(?:32)?$/.test(t))
return makeColumnIcon(
'M5 7 h14 v12 h-14 z', // body
'M5 10.5 h14', // header separator
'M9 5 v3', 'M15 5 v3'); // binding rings
/// DateTime - clock.
if (/^DateTime/.test(t))
return makeColumnIcon(
'M12 6 a6 6 0 1 0 0 12 a6 6 0 1 0 0 -12', // dial
'M12 9 v3.5 l2.5 1.5'); // hands
if (/^(?:U?Int|Float|BFloat|Decimal|Bool)/.test(t)) return makeColumnBadge('09');
if (/^(?:String|FixedString|Enum|UUID|IPv[46])/.test(t)) return makeColumnBadge('Aa');
/// Fallback for everything else (e.g. `AggregateFunction`, geo types, `Nothing`).
return makeColumnBadge('T');
}
/// Anchor a column's balloon to the end of its name, mirroring `positionTableBalloon`, and
/// remember it as the currently visible balloon so `hideTableBalloons` can dismiss it on scroll.
function positionColumnBalloon(column) {
const name = column.querySelector('.column-name');
const button = column.querySelector('button');
const addendum = column.querySelector('.column-addendum');
if (!name || !button || !addendum) return;
const rect = name.getBoundingClientRect();
const btn = button.getBoundingClientRect();
addendum.style.left = Math.min(rect.right, btn.right) + 'px';
addendum.style.top = (rect.top + rect.height / 2) + 'px';
addendum.style.visibility = '';
visible_balloon = addendum;
}
/// Quote an identifier (database, table, or column name) for safe embedding in a query.
/// Datastore's double-quoted identifiers accept both C-style backslash escapes and SQL-style
/// doubling, so escape any backslash and double any embedded quote. A name such as `a"b` or
/// `a\b` then round-trips as a valid `SELECT` instead of producing broken SQL.
function quoteIdentifier(name) {
return '"' + String(name).replace(/\\/g, '\\\\').replace(/"/g, '""') + '"';
}
/// Quote a string literal for the same reason (used where a name is matched as a value, e.g.
/// `WHERE database = '...'`): escape any backslash and double any embedded single quote.
function quoteString(value) {
return "'" + String(value).replace(/\\/g, '\\\\').replace(/'/g, "''") + "'";
}
/// Build the column rows (name + type + size bar + hover balloon) into `columns_elem`.
function renderColumns(columns_elem, data, database, table_name_str) {
if (data.length == 0) {
let none = document.createElement('div');
none.className = 'columns-loading';
none.style.animation = 'none';
none.textContent = 'no columns';
columns_elem.appendChild(none);
return;
}
/// The size bar of each column is drawn proportionally to the largest compressed column.
let max_compressed = Math.max(0, ...data.map(c => +c.compressed || 0));
for (let col of data) {
let column = document.createElement('div');
column.className = 'table-column';
const compressed = +col.compressed || 0;
const uncompressed = +col.uncompressed || 0;
if (max_compressed && compressed) {
const percent = Number(100 * compressed / max_compressed).toFixed(2);
column.style.backgroundImage = `linear-gradient(to right,
var(--table-size-bar-color) 0%,
var(--table-size-bar-color) ${percent}%,
transparent ${percent}%)`;
}
let button = document.createElement('button');
let name_elem = document.createElement('span');
name_elem.className = 'column-name';
name_elem.append(getColumnTypeIcon(col.type), col.name);
let type_elem = document.createElement('span');
type_elem.className = 'column-type';
type_elem.textContent = col.type;
button.append(name_elem, type_elem);
/// Hover balloon. Always shows the full (untruncated) data type, and adds the compressed
/// and uncompressed size and the compression ratio when they are known. Per-column sizes
/// are unavailable for some tables (e.g. small tables stored in compact parts, such as
/// `default.actors`, report zero), so the size lines are simply omitted there rather
/// than showing a misleading `0 B`.
let addendum = document.createElement('span');
addendum.className = 'column-addendum';
let lines = [col.type];
if (compressed || uncompressed) {
lines.push(`Compressed: ${formatReadableBytes(compressed)}`);
lines.push(`Uncompressed: ${formatReadableBytes(uncompressed)}`);
if (compressed) lines.push(`Ratio: ${Number(uncompressed / compressed).toFixed(2)}×`);
}
addendum.textContent = lines.join('\n');
button.appendChild(addendum);
/// Position the balloon whenever the pointer enters the row button, so it opens from both
/// the name and the (often truncated) type text - which is exactly the element a user hovers
/// to read a long type - not only from the name span.
button.addEventListener('mouseenter', () => positionColumnBalloon(column));
/// Show the balloon when the row is focused via keyboard navigation, too.
button.addEventListener('focus', () => positionColumnBalloon(column));
column.appendChild(button);
/// Activate the column row through the same helper as table rows and their addenda, so a
/// column shares the full click-like contract: a middle click (`auxclick`) opens the
/// generated `SELECT` in a new Web UI tab (with autoscroll and the X11 PRIMARY paste
/// suppressed), and Shift/new-tab-modifier clicks open a new tab too, instead of the row
/// silently losing those gestures because it only listened for a plain `click`.
onTableActivate(column, e => setQueryOrRun(e, `SELECT ${quoteIdentifier(col.name)} FROM ${quoteIdentifier(database)}.${quoteIdentifier(table_name_str)} LIMIT 100`));
columns_elem.appendChild(column);
}
}
/// Per-table staleness token for column loads, guarding against a stale in-flight load
/// repopulating a list that was collapsed and reopened while the query was in flight.
let toggle_columns_generation = new WeakMap();
/// Toggle the list of columns shown under a table row. Columns are loaded lazily from
/// `system.columns` on first expand and kept in the DOM; collapsing removes them. A late load is
/// dropped if the row was collapsed/reopened, detached, or the connection changed meanwhile.
async function toggleColumns(table, table_icon, database, table_name_str, server_address, user, password) {
/// The column list is a secondary control, reached by clicking the icon (or pressing Left on
/// the focused row). The icon carries the visual caret (`expanded` class). The row `<button>`
/// deliberately does not advertise `aria-expanded`: its normal activation runs `SELECT *`
/// rather than toggling the list, so announcing it as a disclosure widget would be a false
/// promise to assistive technology.
let columns_elem = table.querySelector(':scope > .table-columns');
if (columns_elem) {
/// Collapse.
columns_elem.remove();
delete table.dataset['columnsOpen'];
table_icon.classList.remove('expanded');
return;
}
table.dataset['columnsOpen'] = 1;
table_icon.classList.add('expanded');
columns_elem = document.createElement('div');
columns_elem.className = 'table-columns';
let loading = document.createElement('div');
loading.className = 'columns-loading';
loading.textContent = '⧗';
columns_elem.appendChild(loading);
table.appendChild(columns_elem);
const generation = (toggle_columns_generation.get(table) || 0) + 1;
toggle_columns_generation.set(table, generation);
const connection_generation = connectionGeneration;
/// Expanding columns is an explicit user action, so its failures must surface the real
/// server error (a missing privilege on `system.columns`, a version mismatch, ...) the way
/// the other user-triggered panel queries do, rather than being swallowed behind a generic
/// placeholder. The query still runs quietly so it does not clobber the main result pane;
/// `on_error` captures the error text and we render it here, inline in the side panel.
let error_text = null;
let json = await auxiliaryQuery(server_address, user, password,
`SELECT name, type, data_compressed_bytes AS compressed, data_uncompressed_bytes AS uncompressed
FROM system.columns WHERE database = {database:String} AND table = {table:String} ORDER BY position`,
'&param_database=' + encodeURIComponent(database) + '&param_table=' + encodeURIComponent(table_name_str),
true, connection_generation, (e) => { error_text = e; });
/// Drop the result if this load is no longer the relevant one for the row.
if (generation != toggle_columns_generation.get(table)) return;
if (!columns_elem.isConnected || connection_generation !== connectionGeneration) return;
loading.remove();
if (!json) {
let error_elem = document.createElement('div');
error_elem.className = 'columns-loading';
error_elem.style.animation = 'none';
error_elem.textContent = error_text ? ('unable to load columns: ' + error_text.trim()) : 'unable to load columns';
columns_elem.appendChild(error_elem);
return;
}
renderColumns(columns_elem, json.data, database, table_name_str);
}
/// 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();
/// Wire up a click-like activation on a table row or its addendum. Besides an ordinary left
/// click, we also honor the gestures that conventionally open a link in a new tab: a middle
/// click (which fires `auxclick`, not `click`), a Shift+click, or the platform's new-tab
/// modifier click - Cmd on macOS, Ctrl elsewhere (on macOS `Ctrl+Click` is the secondary
/// context-menu click, so it is not a new-tab gesture). The handler inspects the event to
/// decide between replacing the current query and opening a new Web UI tab.
///
/// There is no browser-level test harness for `play.html`, so this gesture-routing and its
/// paste guard are verified by hand. The committed manual matrix below is the behavior
/// contract; re-run it when touching `set_query_or_run`, `openQueryInNewTab`/`activateTab`,
/// or the `suppress_middleclick_paste` guard. Every case applies to a databases-panel table
/// row, its bytes/rows/engine addenda, and the column rows of an expanded table alike, since
/// they all route through the same `set_query_or_run` (below):
///
/// 1. Plain left click on a not-yet-loaded query -> replaces the current tab's query
/// (`query_area.value = value`); no new tab is created.
/// 2. Click the same row again while its query is already in the editor -> runs it
/// (`postOne`), no new tab - and likewise for a second middle/modifier click, since
/// opening an identical tab would be pointless.
/// 3. Middle click (button 1) -> opens the query in a new Web UI tab; on Linux/X11 the
/// synthetic PRIMARY paste into the freshly focused editor is swallowed (the guard is
/// armed around `query_area.focus()` in `activateTab`).
/// 4. Shift+click -> opens a new tab on every platform.
/// 5. Ctrl+click on Linux/Windows, or Cmd+click on macOS -> opens a new tab.
/// 6. Ctrl+click on macOS (the secondary/context-menu click) -> a no-op for query activation:
/// it neither opens a new tab, replaces the editor, nor runs the query (not even the
/// second-click `postOne`). The gesture is left entirely to the browser so the native
/// context menu can appear. (Cmd+click is the macOS new-tab gesture; see case 5.)
/// 7. A real keyboard paste (Ctrl+V, Cmd+V, or Shift+Insert) into the editor immediately
/// after a middle-click new-tab open -> the pasted text is kept: the paste-shortcut
/// `keydown` disarms the guard before its `paste` fires, so only the mouse-driven
/// PRIMARY paste (which carries no keystroke) is ever swallowed.
/// 8. Two middle clicks in quick succession (open table A, then table B within the guard's
/// fallback window) -> B's PRIMARY paste is still swallowed: re-arming cancels A's pending
/// fallback timer (`middleclick_paste_timeout`), so A's stale timeout cannot clear B's guard.
function onTableActivate(elem, handler) {
elem.addEventListener('click', handler);
elem.addEventListener('auxclick', e => {
if (e.button !== 1) return;
/// Cancel the middle click's native paste at the click site. The X11 PRIMARY paste that
/// would otherwise land in the query editor is guarded separately, around the actual
/// `query_area.focus()` in the new-tab path (see `activateTab`), so the guard cannot
/// expire before a slow activation focuses the editor.
e.preventDefault();
handler(e);
});
/// Suppress the browser's middle-click autoscroll so the gesture only opens a new tab.
elem.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); });
}
async function loadTables(server_address, user, password, database, container, set_current = true, is_current = null, quiet = !set_current) {
const request_num = (load_tables_request_nums.get(container) || 0) + 1;
load_tables_request_nums.set(container, request_num);
/// Commit the database selection synchronously, on the trusted click — before the table list
/// query is even issued — so a `Run` fired before the load returns already targets the
/// just-clicked database. The async load below only repaints the list; the latest click always
/// wins because click handlers run to completion (nothing interleaves before the first await),
/// so no per-selection token is needed to arbitrate concurrent loads. A restore load
/// (set_current = false) is background panel maintenance and must not move the selection.
if (set_current) commitSelectedDatabase(database);
/// 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 is a background panel-maintenance query triggered by merely reopening the
/// panel, so it runs quietly (`quiet = true`): it must not overwrite the main result pane with
/// its own success or error. An explicit open or refresh keeps surfacing its error as before
/// (`quiet = false`). `quiet` is decoupled from `set_current` so a refresh can reload the list
/// without committing the selection yet still report failures.
let json = await auxiliaryQuery(server_address, user, password,
`SELECT table, engine, total_rows, total_bytes FROM system.tables WHERE database = {database:String}
ORDER BY table LIKE '.inner%', table`,
'&param_database=' + encodeURIComponent(database),
quiet, 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;
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';
let table_icon = getTableIcon(elem.engine);
table_icon.setAttribute('title', 'Show columns');
/// The row `<button>`'s primary action is `SELECT *`, so it must not advertise
/// `aria-expanded`: that would promise a disclosure widget whose normal activation
/// (click / Enter / Space) never toggles the column list. Toggling is a secondary
/// action, reached by clicking the icon or pressing Left on the focused row; the icon
/// carries the visual caret (its `expanded` class).
table_name.append(table_icon, elem.table);
table_link.appendChild(table_name);
/// Clicking the icon (to the left of the name) toggles the list of columns of this
/// table, instead of running the default `SELECT *`. Stop the event so it neither
/// bubbles to the row's click handler nor triggers a hover balloon reposition.
const toggle_columns = () => toggleColumns(table, table_icon, database, elem.table, server_address, user, password);
table_icon.addEventListener('click', e => {
if (isMac && e.ctrlKey && e.button !== 1) {
/// On macOS `Ctrl+Click` is the secondary (context-menu) click, same as the
/// guard in `setQueryOrRun`; leave it entirely to the browser instead of
/// toggling the column list, so the native context menu still appears.
return;
}
e.stopPropagation();
toggle_columns();
});
/// A middle click fires `auxclick`, not `click`, so the handler above never sees it.
/// Without consuming it here, the middle-click `auxclick` would bubble to the row's
/// `onTableActivate` and open `SELECT *` in a new tab - a completely different action
/// from this icon's `Show columns` purpose. Toggle the column list for a middle click
/// too (a middle click is a genuine gesture even with Ctrl held, so there is no macOS
/// guard here), and cancel the middle-button defaults at the icon - the X11 PRIMARY
/// paste on `auxclick` and the autoscroll on `mousedown` - so the disclosure control
/// behaves consistently for every click type.
table_icon.addEventListener('auxclick', e => {
if (e.button !== 1) return;
e.preventDefault();
e.stopPropagation();
toggle_columns();
});
table_icon.addEventListener('mousedown', e => { if (e.button === 1) e.preventDefault(); });
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 = setQueryOrRun;
const show_table_properties = e => set_query_or_run(e, `SELECT * FROM system.tables WHERE database = ${quoteString(database)} AND table = ${quoteString(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';
onTableActivate(bytes_elem, 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';
onTableActivate(rows_elem, 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';
onTableActivate(engine_elem, e => set_query_or_run(e, `SHOW TABLE ${quoteIdentifier(database)}.${quoteIdentifier(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);
onTableActivate(table, e => set_query_or_run(e, `SELECT * FROM ${quoteIdentifier(database)}.${quoteIdentifier(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.
/// A column row is nested inside its `.table`, so match it first: `closest` returns the
/// nearest ancestor, i.e. the `.table-column` when a column is focused, the `.table` otherwise.
const focused_row = document.activeElement?.closest?.('.table-column, .table');
if (focused_row && document.getElementById('databases').contains(focused_row)) {
/// 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');
if (focused_row.classList.contains('table-column')) positionColumnBalloon(focused_row);
else positionTableBalloon(focused_row);
} 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 middle-click, Shift+click, and the platform's
/// new-tab modifier click (Cmd+click on macOS, Ctrl+click elsewhere) natively open the
/// current page (with the query) in a new tab or window.
/// 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();
/// Defer any modified click to the browser's native link handling instead of toggling:
/// a new tab or window for the new-tab gestures, or the context menu for macOS Ctrl+click.
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.
/// - Left on a table row toggles its column list (the disclosure the table icon
/// opens on click), so the columns are reachable from the keyboard.
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;
}
/// Left arrow on a table row toggles its list of columns, so keyboard users can expand it
/// (`Enter` already runs the default `SELECT *`, so it can't double as the toggle). It routes
/// through the icon's own click handler to reuse its wiring; the guard restricts this to the
/// table's own row button (not a database row or a nested column button).
if (e.key === 'ArrowLeft') {
const table = active.closest && active.closest('.table');
if (table && active === table.querySelector(':scope > button')) {
const table_icon = table.querySelector(':scope > button .table-icon');
if (table_icon) {
e.preventDefault();
table_icon.dispatchEvent(new MouseEvent('click', { bubbles: 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);
/// A resize can make a table start or stop overflowing horizontally, which changes whether the pin
/// option should be offered; it also changes the header widths that the pinned columns' sticky
/// `left`/`right` offsets are baked from. So for a table with pinned columns recompute the whole
/// layout (`applyPinnedColumns` refreshes `has-hscroll` itself), and for the rest just refresh the
/// pin availability.
function refreshPinnedLayout() {
for (const el of document.querySelectorAll('query-result')) {
if (el._hasPinnedColumns()) {
el.applyPinnedColumns();
} else {
el._updatePinAvailability();
}
}
}
window.addEventListener('resize', refreshPinnedLayout);
/// A viewport `resize` is not the only thing that changes a result table's available width: opening
/// or closing the database panel widens the `#menu` grid column and so narrows the `#main` results
/// column WITHOUT any `resize` event. A table can therefore gain horizontal overflow while its pin
/// controls stay hidden, or keep stale sticky offsets. Observe the results container so both a
/// viewport resize and such internal layout changes keep the pin availability and offsets correct.
const main_container = document.getElementById('main');
if (main_container && typeof ResizeObserver !== 'undefined') {
new ResizeObserver(refreshPinnedLayout).observe(main_container);
}
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';
/// Low-level marker for an exception raised after the HTTP response body has already started.
/// Exceptions are normally reported by the output format itself — `JSONEachRowWithProgress` writes
/// a `{"exception":...}` line when it finalizes, and that still happens. But there is also a newer,
/// format-agnostic transport-level mechanism: when the HTTP write buffer
/// (`WriteBufferFromHTTPServerResponse::cancelWithException`) is cancelled with an error after the
/// body has already been sent, it appends this marker block and breaks the HTTP framing. The Web UI
/// has to handle it in addition to the `{"exception":...}` line. The block looks like:
///
/// \r\n
/// __exception__\r\n
/// <TAG>\r\n
/// <error message, may span several lines>\n
/// <message_length> <TAG>\r\n
/// __exception__\r\n
///
/// where <TAG> is a random 16-character string repeated to delimit the message unambiguously even
/// if the message itself contains the marker text.
const EXCEPTION_MARKER = '__exception__';
/// Extract a `__exception__` marker block embedded anywhere in `text` (typically at the very end of a
/// streamed response). Returns `{ message, offset }` — the error message and the character offset in
/// `text` of the opening `__exception__` line of the matched block — or null if no complete block is
/// present. A complete block is a tag line, the message, a `<length> <TAG>` trailer line, and a closing
/// `__exception__` line; the message is everything between the tag and the trailer, so it is recovered
/// verbatim regardless of its own content. The `offset` lets a caller keep the raw output that arrived
/// *before* the block while hiding the block itself: an earlier line may merely look like the marker (a
/// raw format can emit `__exception__`, or the whole block shape, as data and then fail later), so the
/// split point must be the validated block, not the first candidate line. A candidate line that does not
/// lead to a complete block is skipped and scanning continues, so such lookalikes never abort the search.
///
/// `expected_tag`, when supplied, is the value of the `X-Datastore-Exception-Tag` response header. The
/// server picks a fresh random tag per response and repeats it inside the marker block (see
/// `WriteBufferFromHTTPServerResponse::cancelWithException`), so a genuine transport marker always
/// carries exactly that tag. Requiring the match rejects a *successful* query whose own output merely
/// mimics the marker shape — the client cannot know the random per-response tag. For example
/// `SELECT arrayJoin(['__exception__','0123456789abcdef','hello','5 0123456789abcdef','__exception__']) FORMAT TSV`
/// is a fully-framed lookalike, but its tag will not match the header, so it is not treated as a failure.
/// Callers that already know the query failed and have no header at hand (history/snapshot restore) omit
/// the argument to extract the message without verifying; a `null` value — the header was absent, e.g. a
/// proxy or an older server sent the block but did not expose `X-Datastore-Exception-Tag`, so
/// `Headers.get` returned `null` — is treated the same as omitting it.
function parseExceptionMarker(text, expected_tag)
{
const lines = text.split('\n');
for (let i = 0; i < lines.length; ++i)
{
if (lines[i].replace(/\r$/, '') !== EXCEPTION_MARKER) continue;
const tag = (lines[i + 1] ?? '').replace(/\r$/, '');
if (!tag) continue;
/// A genuine marker reuses the per-response header tag; a mismatch means this is ordinary output
/// that merely looks like a marker, so keep scanning rather than reporting a false failure. Both
/// `undefined` (restore callers omit the argument) and `null` (`Headers.get` when the header is
/// absent — a proxy or older server that wrote the block without exposing the tag) mean "no tag to
/// match": accept the block on its framing alone, otherwise a valid marker would be rejected here.
if (expected_tag !== undefined && expected_tag !== null && tag !== expected_tag) continue;
/// The message ends at the trailer line `<length> <TAG>`; the length lets the server bound
/// the message, but here the tag alone is enough to locate the boundary unambiguously.
const trailer = ' ' + tag;
const message_lines = [];
for (let j = i + 2; j < lines.length; ++j)
{
const stripped = lines[j].replace(/\r$/, '');
/// Commit to a failure only for a fully-framed block: the `<length> <TAG>` trailer must be
/// immediately followed by the closing `__exception__` line (see the server's
/// `WriteBufferFromHTTPServerResponse::cancelWithException`). A lone `<digits> <TAG>` line is
/// not proof of a marker — successful output can legitimately contain one, e.g.
/// `SELECT arrayJoin(['__exception__', 'x', 'hello', '6 x'])` (tag `x`, trailer `6 x`), which
/// without the closing check would be misread as the error "hello".
if (/^\d+$/.test(stripped.slice(0, stripped.length - trailer.length)) && stripped.endsWith(trailer)
&& (lines[j + 1] ?? '').replace(/\r$/, '') === EXCEPTION_MARKER)
{
/// Char offset of the opening `__exception__` line (`lines[i]`) within `text`, so callers
/// can split the raw output on the *validated* block rather than the first candidate line.
const offset = i === 0 ? 0 : lines.slice(0, i).join('\n').length + 1;
return { message: message_lines.join('\n'), offset };
}
message_lines.push(lines[j]);
}
/// This candidate's block never completed (a lookalike `__exception__` line, or a genuine block
/// truncated by an incomplete stream): keep scanning for a later, complete block rather than
/// giving up, otherwise a raw prefix that contains a lookalike line would mask the real marker.
}
return null;
}
/// Char offset within `text` of the first line where a transport marker for THIS response *starts* —
/// a `__exception__` line whose immediately following line equals `expected_tag` (the server repeats
/// the per-response `X-Datastore-Exception-Tag` right after the opening marker line, see
/// `WriteBufferFromHTTPServerResponse::cancelWithException`) — or -1 if there is none. The live raw
/// branch uses this to tell a genuine marker whose block the server truncated by breaking HTTP
/// framing — the trailing `reader.read()` `TypeError` is then *expected* — apart from ordinary raw
/// output that merely contains a `__exception__` lookalike line and then hit a real transport failure
/// (that `TypeError` is a genuine error and must surface, not be reported as an unparsable marker).
/// The offset (not just a boolean) matters for the truncated-marker case: echoing stopped at the
/// first *candidate* line, which may be an earlier lookalike in ordinary output, so the withheld
/// output before the tag-compatible start streamed before the failure and must still be shown — this
/// is the split point for it, in both the live path and `renderFailedSnapshot`. With no tag to check
/// against (`expected_tag` null/undefined) a start cannot be ruled out, so the first candidate line
/// counts (best effort, same permissive stance `parseExceptionMarker` takes when it has no tag).
function exceptionMarkerStart(text, expected_tag)
{
const match_any_tag = expected_tag === undefined || expected_tag === null;
const lines = text.split('\n');
let offset = 0;
for (let i = 0; i < lines.length; ++i)
{
if (lines[i].replace(/\r$/, '') === EXCEPTION_MARKER
&& (match_any_tag || (lines[i + 1] ?? '').replace(/\r$/, '') === expected_tag))
return offset;
offset += lines[i].length + 1; /// + 1 for the '\n' that `split` removed
}
return -1;
}
/// Reverse the escaping `writeXMLStringForTextElement` applies to an `XML`-format text node: it only
/// ever produces `&lt;` (for `<`) and `&amp;` (for `&`). Unescape `&amp;` last so a literal `&lt;`
/// in the message (written as `&amp;lt;`) is not turned into `<`.
function unescapeXMLText(s)
{
return s.replace(/&lt;/g, '<').replace(/&amp;/g, '&');
}
/// Formats whose entire response body is a single JSON document, so an in-band failure surfaces as a
/// top-level `"exception"` string (or, for `GeoJSON`, a trailing `{"exception":...}` element of the
/// `features` array) that can be read out unambiguously — a successful document of these formats
/// never carries one. Each-row formats are intentionally absent (see `parseInbandException`).
const SINGLE_DOCUMENT_JSON_FORMATS = new Set([
'JSON', 'JSONStrings', 'JSONCompact', 'JSONCompactStrings',
'JSONColumns', 'JSONColumnsWithMetadata', 'JSONObjectEachRow', 'GeoJSON']);
/// Detect a format-level exception that an output format wrote in-band after the response body had
/// already started (`http_write_exception_in_output_format`, on by default), so the raw branch can
/// flag the query as failed instead of finishing green. Returns the error message, or null.
///
/// Datastore writes this exception differently per format, and only some encodings can be told apart
/// from successful output. This probes exactly the unambiguous ones:
/// - single-document JSON (`JSON`, `JSONCompact`, `JSONColumns`, `JSONObjectEachRow`, `GeoJSON`, ...):
/// a top-level `"exception"` string, or a trailing `{"exception":...}` feature for `GeoJSON`;
/// - `*WithProgress` row streams: a terminal top-level `{"exception":...}` line (every row is
/// wrapped as `{"row":...}` / `{"progress":...}`, so a bare `{"exception":...}` line is the failure);
/// - `XML`: the top-level `<exception>` trailer (a one-tab direct child of `<result>`; a row field
/// named `exception` is far more deeply indented, so it cannot be mistaken for the trailer).
///
/// It deliberately does NOT probe the each-row JSON family, because their in-band failure line is
/// byte-for-byte a valid row, so flagging it could mark a successful query as failed:
/// - the bare formats `JSONEachRow` / `JSONStringsEachRow` / `JSONLines` write the failure as a
/// `{"exception":...}` object line, identical to a successful row with an `exception` column;
/// - `JSONCompactEachRow` / `JSONCompactStringsEachRow` write it as `["..."]`, identical to a
/// single-column row;
/// - the pretty-printed aliases `PrettyJSONEachRow` / `PrettyJSONLines` / `PrettyNDJSON` are backed by
/// the same `JSONEachRowRowOutputFormat` (its `writeSuffix` appends the very same in-band
/// `{"exception":...}` object, only indented), so they are equally ambiguous.
/// Non-JSON row formats (`TSV`, `CSV`, `Pretty`, ...) do not write exceptions in-band at all — they
/// signal a mid-stream failure with the transport-level `__exception__` marker instead. So the one case
/// the raw branch cannot flag is a mid-stream failure of an each-row JSON format with
/// `http_write_exception_in_output_format=1` (the default): the server writes an in-band line that is
/// indistinguishable from a valid row and no marker, so such a query can still finish in the success
/// state. This is UNSUPPORTED by design — the "all streamed formats" recovery does not cover the
/// ambiguous each-row JSON encodings — not an oversight; do not widen it to guess on those.
function parseInbandException(text, format)
{
/// The format may be missing: an error snapshot saved when `fetch` failed before the response
/// headers arrived (`Network error`, an early `AbortError`, the direct-browser
/// `ERR_INCOMPLETE_CHUNKED_ENCODING`) carries no `X-Datastore-Format`, so the restore paths
/// call this with `format === undefined`. Bail out before touching string methods on it —
/// otherwise the restore would throw a `TypeError` instead of showing the saved error text.
if (typeof format !== 'string') return null;
if (format == 'XML')
{
/// `XMLRowOutputFormat` writes the in-band failure as a top-level `<exception>` element, indented
/// with a single tab as a direct child of `<result>` (see `writeException`). A row field that
/// happens to be named `exception` is written far more deeply (`\t\t\t<exception>` inside a
/// `\t\t<row>`), so anchor to the one-tab top-level element — otherwise a successful
/// `SELECT 'ok' AS exception FORMAT XML` would be misread as a failure. The message text is
/// XML-escaped (`<` → `&lt;`), so it can never contain a literal `<exception>` / `</exception>`.
const m = text.match(/^\t<exception>([\s\S]*?)<\/exception>/m);
return m ? unescapeXMLText(m[1]) : null;
}
/// Only the two unambiguous JSON encodings are probed: a `*WithProgress` row stream (a terminal
/// top-level `{"exception":...}` line) or a single-document body (`SINGLE_DOCUMENT_JSON_FORMATS`).
/// Everything else — the whole ambiguous each-row JSON family, both the bare formats and the
/// pretty-printed `Pretty*` aliases noted above — is deliberately unsupported, so bail out. This is
/// an explicit whitelist rather than a `startsWith('JSON')` test, which would falsely imply the
/// each-row family is handled (and would not even match the `Pretty*` aliases).
if (!format.includes('WithProgress') && !SINGLE_DOCUMENT_JSON_FORMATS.has(format)) return null;
if (!text.includes('"exception"')) return null; /// cheap guard before parsing/scanning the body
/// `*WithProgress` formats wrap every row, so a standalone top-level `{"exception":...}` line —
/// always the terminal content line — is unambiguously the failure.
if (format.includes('WithProgress'))
{
const trimmed = text.replace(/\s+$/, '');
const last_line = trimmed.slice(trimmed.lastIndexOf('\n') + 1);
if (last_line.startsWith('{"exception"'))
{
try {
const data = JSON.parse(last_line);
if (typeof data.exception === 'string') return data.exception;
} catch (e) { /* not a standalone exception line */ }
}
return null;
}
/// Single-document formats: parse the whole body and read the failure out of the one object.
if (SINGLE_DOCUMENT_JSON_FORMATS.has(format))
{
try {
const data = JSON.parse(text);
if (data && typeof data === 'object')
{
if (typeof data.exception === 'string') return data.exception;
/// `GeoJSON`: `{"type":"FeatureCollection","features":[..., {"exception":...}]}`. The
/// appended element has no `type`, so it cannot be mistaken for a real `Feature`.
if (Array.isArray(data.features) && data.features.length)
{
const last = data.features[data.features.length - 1];
if (last && typeof last.exception === 'string' && last.type === undefined)
return last.exception;
}
}
} catch (e) { /* not a single JSON document */ }
}
return 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 };
/// failed runs also carry `exception_tag` and `display_error` — the rendered error text, persisted
/// with the snapshot so the failure still restores when the body is dropped by the history size cap
/// (see `renderFailedSnapshot`).
/// The caller is responsible for top-level UI (download buttons, history, favicon).
/// `tab` owns the run; `posted_request_num` is a snapshot of `tab.reqNum` taken at launch, so a
/// late response bails once the tab is re-run/cancelled/closed. `abortController` cancels the fetch.
/// `queryStart` is the absolute character offset of `query` in the editor, threaded to
/// `highlightQueryError` so a server-reported error position maps back to the statement that ran.
/// `query_database` and `connection` (the { url, user, password } tuple) are snapshotted by
/// `postOne`/`postAll` BEFORE the lexer preflight await: the shared database/connection inputs must
/// not be re-read here, or a tab switch + database/connection edit during that await (or between the
/// sequential statement groups of a "Run all") would send this run's already-snapshotted query to a
/// different database, server or identity.
async function postImpl(tab, posted_request_num, query, targetResultEl, abortController, paramValuesSnapshot, query_database, connection, queryStart)
{
/// The connection tuple (server address, user, password) is snapshotted once per run by the
/// caller and passed in, rather than read live from the inputs here. Otherwise, in a
/// multi-query run (where later statement groups start only after earlier ones finish), editing
/// the connection mid-run would send later statements to a different server/user while the saved
/// provenance still claims a single producing connection (see `postMulti`).
const user = connection.user;
const password = connection.password;
const server_address = connection.url;
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';
/// If the user selected a database in the panel, use it as the default database.
if (query_database) url += '&database=' + encodeURIComponent(query_database);
for (const [name, value] of Object.entries(paramValuesSnapshot))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
/// Extremes only if the format is likely to be unchanged. This is imprecise.
if (!query.match(/\bFORMAT\s+\w+/i)) url += '&extremes=1';
targetResultEl.queryText = query;
let is_table = false;
let is_raw = false;
let is_chart = false;
let is_image = false;
let is_error = false;
/// The rendered error text of a failed run. EVERY failure path sets it (the HTTP-error branch,
/// the mid-stream marker / in-band failure returns, and the outer transport catch), so the
/// history snapshot always carries the message even when the body itself is dropped by the
/// 100 KB size cap — otherwise a large failed result would restore as a blank tab (see
/// `renderFailedSnapshot`).
let response, reply, format, exception_tag, display_error;
reply = '';
try {
response = await fetch(url, { method: "POST", body: query, signal: abortController.signal,
headers: { 'Authorization': 'never' } });
format = response.headers.get('X-Datastore-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') ?? '';
/// The random per-response tag the server repeats inside a transport `__exception__` marker
/// block; passing it to `parseExceptionMarker` tells a genuine marker apart from successful
/// output that merely mimics the marker shape (the client cannot know this random tag).
exception_tag = response.headers.get('X-Datastore-Exception-Tag');
if (!response.ok) {
is_error = true;
reply = await response.text();
if (posted_request_num != tab.reqNum) { return { cancelled: true }; }
let has_exception = false;
for (const line of reply.split('\n')) {
if (line.startsWith(`{"exception":`)) {
const parsed = JSON.parse(line);
targetResultEl.update(parsed);
display_error = typeof parsed.exception === 'string' ? parsed.exception : line;
has_exception = true;
break;
}
}
if (!has_exception) {
targetResultEl.renderError(reply);
/// Equal to the saved `data` on purpose: when the body IS persisted, restore renders
/// it from `data` alone and `renderFailedSnapshot` steps aside on the equality, so the
/// error text is not echoed as output first.
display_error = reply;
}
/// If the error message points at a position in the query (e.g. a syntax error),
/// highlight that position in the editor -- only while the launching tab is still the
/// active one (the shared editor shows ITS text; a background run must never paint the
/// foreground editor) and the user has not returned to the editor since this run
/// started (any interaction, including a tab switch's synthetic editor restore, bumps
/// the generation past this run's snapshot and makes the response stale).
if (tab.id === activeTabId && editorInteractionGen === tab.runEditorInteraction) {
highlightQueryError(reply, query, queryStart, tab);
}
return { format, exception_tag, reply, display_error, 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 != tab.reqNum) { 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 = '';
let marker_seen = false; /// once the `__exception__` block starts, stop parsing rows
let stream_exception = null; /// the error message, from either signalling method
while (true) {
let done, value;
try {
({ done, value } = await reader.read());
} catch (e) {
/// After writing the `__exception__` block the server intentionally breaks the
/// HTTP framing (it omits the terminating chunk to signal the error), so the
/// reader may reject with a network `TypeError`. If the marker was already seen
/// that is expected — parse the buffered block below. Otherwise it is a genuine
/// transport failure, so let the outer handler report it.
if (marker_seen && e instanceof TypeError) break;
throw e;
}
if (posted_request_num != tab.reqNum) { return { cancelled: true }; }
if (done) break;
let new_content = decoder.decode(value, { stream: true });
reply += new_content;
/// Once the marker block has started, the remainder of the stream is that block
/// (message + trailer), not JSON rows: keep accumulating `reply` for the parser
/// (invoked after the loop) and stop feeding lines to `JSON.parse`.
if (marker_seen) continue;
buffer += new_content;
let lines = buffer.split('\n');
let fragment = document.createDocumentFragment();
let cont = true;
for (let idx = 0; idx < lines.length; ++idx) {
const line = lines[idx];
/// The `__exception__` marker signals an exception raised after the body already
/// started (see `EXCEPTION_MARKER`). It always occupies whole lines, so match the
/// line exactly (ignoring the trailing `\r`) — this is robust to chunk-boundary
/// splits, and a row value that merely contains the text is `{"row":...}` and
/// never equals the marker. Everything from here on is the block, not rows: stop
/// parsing and leave it in `reply` for `parseExceptionMarker` after the loop.
if (line.replace(/\r$/, '') === EXCEPTION_MARKER) { marker_seen = true; break; }
/// The last line is a partial row (or a partial marker); keep it in `buffer`.
if (idx == lines.length - 1) break;
/// Skip blank/whitespace-only lines: `JSON.parse` on them throws
/// ("Unexpected end of JSON input"). The marker block is framed by `\r\n`, so
/// its leading bare-`\r` line is skipped here too.
if (line.trim().length == 0) continue;
const data = JSON.parse(line);
/// The other way an exception is signalled: with `http_write_exception_in_output_format`
/// the format writes a `{"exception":...}` line (this is the pre-marker mechanism, and
/// it can still arrive mid-stream after a 200). Treat it as a failure — like the marker
/// — rather than letting the query fall through to the success path below.
if (data.exception) { stream_exception = data.exception; cont = false; break; }
cont = targetResultEl.update(data, fragment);
if (!cont) { break; }
}
/// The rows this chunk appended: captured before `flushFragment` empties the
/// fragment, but the node references stay valid after the move into the table body.
const streamed_rows = fragment.querySelectorAll('tr');
targetResultEl.flushFragment(fragment);
/// Keep the pinned-column feature live as the table streams: lay out the new rows
/// for any already-pinned columns (e.g. a wide `?pinned_columns=...&run=1` link),
/// and otherwise just refresh whether the table now scrolls horizontally so the pin
/// toggles become usable mid-stream rather than only at EOF.
targetResultEl._pinStreamedRows(streamed_rows);
/// The marker block keeps arriving after this: do not abort (that could truncate it)
/// and do not carry the marker text as an unfinished row — just keep draining into
/// `reply`. The marker line and the rest stay in `buffer` (unused once `marker_seen`).
if (marker_seen) continue;
if (!cont)
{
abortController.abort();
break;
}
buffer = lines[lines.length - 1];
}
/// The `__exception__` marker (the newer, format-agnostic mechanism) is parsed out of the
/// full reply after the stream ended; the format-level `{"exception":...}` line is already
/// captured in `stream_exception` above.
if (marker_seen && stream_exception === null) {
const marker = parseExceptionMarker(reply, exception_tag);
stream_exception = (marker !== null ? marker.message : null)
?? "The query failed after the response body had already started, but the error message could not be parsed.";
}
/// Either way, the query failed after its response body had already started streaming. Keep
/// any partial rows that arrived (they are already flushed into the table): finish their
/// totals/coloring so they look consistent, then render the error beneath them. If nothing
/// but a header arrived, hide the empty table so only the error shows. The partial table
/// still needs its layout pass (deferred while the tab is hidden).
if (stream_exception !== null) {
if (targetResultEl.rowCount > 0) {
targetResultEl.renderTotals();
targetResultEl.applyColumnColors();
if (tab.id === activeTabId) { targetResultEl.transposeIfNeeded(); targetResultEl._changeTableLayout(); }
else { targetResultEl._pendingTranspose = true; targetResultEl._pendingChangeLayout = true; }
} else {
targetResultEl.clear();
}
targetResultEl.renderError(stream_exception);
display_error = stream_exception;
return { format, exception_tag, reply, display_error, response_ok: false, is_table: false, is_raw: false, is_chart: false, is_image: false, is_error: true };
}
} else if (format == 'JSONCompactColumns') {
is_chart = true;
reply = await response.text();
if (posted_request_num != tab.reqNum) { 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 === tab.reqNum);
} else {
is_raw = true;
const reader = response.body.getReader();
const decoder = new TextDecoder();
/// A query can fail after its HTTP response body has already started streaming (the status
/// is already committed to 200). This branch renders an explicitly-requested streamed
/// format (e.g. `FORMAT JSONEachRowWithProgress`, `TSV`, ...) verbatim, so — like the table
/// branch above — it must recognize the same two mid-stream failure signals, otherwise a
/// failed query would finish in the success state:
/// 1. the format-agnostic transport-level `__exception__` marker (see `EXCEPTION_MARKER`),
/// used by formats that do not write exceptions in-band (`TSV`, `CSV`, `Pretty`, ...);
/// 2. the in-band `{"exception":...}` a JSON output format writes when
/// `http_write_exception_in_output_format` is set (the default) — see `parseInbandException`.
/// The raw output that arrived before the failure is still shown; the error is rendered beneath.
let buffer = '';
let marker_seen = false; /// a candidate `__exception__` line was seen; stop echoing from it
let marker_framing_broken = false; /// the server broke HTTP framing after the marker (see below)
let held_back = ''; /// output withheld from the candidate marker line onward (see below)
let stream_exception = null;
while (true) {
let done, value;
try {
({ done, value } = await reader.read());
} catch (e) {
/// After a genuine `__exception__` block the server intentionally breaks the HTTP
/// framing, so the reader may reject with a `TypeError`; that is expected and the block
/// is parsed below. But `marker_seen` only means echoing stopped on a line that *looked*
/// like the marker — ordinary raw output can contain such a line (`ok1\n__exception__\n
/// ok2\n`) and then hit a real transport failure. Treat the error as the expected framing
/// break only when the withheld tail actually began a marker for this response (its
/// opening line carried the response tag); otherwise it is a genuine transport error, so
/// flush the withheld output and let the outer handler report it, exactly as a mid-stream
/// network drop with no marker candidate at all would be handled.
if (marker_seen && e instanceof TypeError && exceptionMarkerStart(held_back, exception_tag) >= 0)
{
marker_framing_broken = true;
break;
}
if (marker_seen && held_back.length) targetResultEl.updateRaw(held_back);
throw e;
}
if (done) break;
let new_content = decoder.decode(value, { stream: true });
reply += new_content;
if (posted_request_num != tab.reqNum) { return { cancelled: true }; }
/// After a candidate marker line, withhold the rest of the stream instead of echoing it:
/// it is either the marker block (kept hidden) or — if the framing turns out incomplete —
/// ordinary output that is re-echoed after the loop. Keep accumulating `reply` regardless.
if (marker_seen) { held_back += new_content; continue; }
/// The marker occupies whole lines, so echo only complete lines and hold the last
/// (possibly partial) line in `buffer` — this matches the marker robustly across
/// chunk-boundary splits and never echoes the marker block itself into the raw view.
buffer += new_content;
let lines = buffer.split('\n');
let output = '';
for (let idx = 0; idx < lines.length; ++idx) {
/// A complete line equal to the marker is only a *candidate*: a successful query can
/// legitimately emit one (e.g. `SELECT '__exception__' FORMAT TSV`). Stop echoing from
/// here and decide after the loop, once the full marker framing is known.
if (lines[idx].replace(/\r$/, '') === EXCEPTION_MARKER) {
marker_seen = true;
held_back = lines.slice(idx).join('\n');
break;
}
/// The last line may be a partial row or a partial marker; keep it in `buffer`.
if (idx == lines.length - 1) break;
output += lines[idx] + '\n';
}
if (output) targetResultEl.updateRaw(output);
if (!marker_seen) buffer = lines[lines.length - 1];
}
if (marker_seen) {
/// A genuine transport marker is a fully-framed `__exception__ <TAG> ... <len> <TAG>
/// __exception__` block that `parseExceptionMarker` can extract from the withheld tail;
/// echoing stopped at the *first* candidate line, but that line may merely look like the
/// marker — a raw format can emit `__exception__` (or the whole block shape) as data and
/// only then fail. So split on the validated block: flush the output that precedes it
/// (withheld from the live view until now) and hide only the block itself. If nothing
/// validates: `marker_framing_broken` is set only when the tail actually began a marker for
/// this response (the catch verified the tag before swallowing the framing-break
/// `TypeError`), so it means a genuine but unparsable/truncated block — flush the withheld
/// output up to where that marker starts (an earlier lookalike line withheld with it is
/// ordinary output that streamed before the failure), hide the truncated block, and report
/// a generic failure; otherwise every candidate was ordinary output that finished cleanly,
/// so re-echo the whole withheld tail — and still probe for an in-band failure, exactly
/// like the no-marker branch below.
const marker = parseExceptionMarker(held_back, exception_tag);
if (marker !== null) {
if (marker.offset > 0) targetResultEl.updateRaw(held_back.slice(0, marker.offset));
stream_exception = marker.message;
} else if (marker_framing_broken) {
const start = exceptionMarkerStart(held_back, exception_tag);
if (start > 0) targetResultEl.updateRaw(held_back.slice(0, start));
stream_exception = "The query failed after the response body had already started, but the error message could not be parsed.";
} else {
/// `marker_seen` may have been tripped by a bare `__exception__` DATA line — `XML`
/// writes field text verbatim, so a row value can put the marker text on a line of
/// its own — while the real failure is the format's top-level in-band trailer
/// further down the withheld tail. Without this probe such a failed `FORMAT XML`
/// query would finish in the success state.
if (held_back.length) targetResultEl.updateRaw(held_back);
stream_exception = parseInbandException(reply, format);
}
} else {
/// Flush the final line (a stream need not end with a newline).
if (buffer.length) targetResultEl.updateRaw(buffer);
/// No transport marker: a JSON output format may still have reported the failure in-band.
stream_exception = parseInbandException(reply, format);
}
if (stream_exception !== null) {
targetResultEl.renderError(stream_exception);
display_error = stream_exception;
return { format, exception_tag, reply, display_error, response_ok: false, is_table: false, is_raw: false, is_chart: false, is_image: false, is_error: true };
}
}
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 === tab.reqNum);
} else {
/// Render totals before coloring, so the totals row (which `_colorTargetRows`
/// includes) is colored on the first paint too. Totals and coloring are content-only
/// (no measurement), so they run even for a hidden tab; the transpose pass measures
/// widths, so it is deferred when the tab is hidden (see `applyDeferredLayout`).
if (is_table) targetResultEl.renderTotals();
targetResultEl.applyColumnColors();
if (is_table) {
if (tab.id === activeTabId) targetResultEl.transposeIfNeeded();
else targetResultEl._pendingTranspose = true;
/// Content-only (toggles a class based on row/column counts, no measurement),
/// so it runs even for a hidden tab, like the totals/coloring passes above.
targetResultEl.expandSingleValueIfNeeded();
}
}
/// 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 != tab.reqNum) { 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 != tab.reqNum) { return { cancelled: true }; }
console.log(e);
/// A transport failure (`Network error` / `Query was cancelled` / any other thrown error) can
/// strike mid-stream, after the table/raw branch already flushed partial output into
/// `targetResultEl` — and into `reply`, which still holds the streamed body so far. Keep that
/// body as the snapshot payload and carry the synthetic display message in a SEPARATE
/// `display_error` field, so a reload / tab switch replays `partial result + error` exactly as
/// shown live instead of collapsing to `error only` (see `renderFailedSnapshot`). Overwriting
/// `reply` here (as before) dropped the already-rendered prefix from the persisted snapshot.
if (e instanceof TypeError) {
display_error = "Network error";
} else if (e.name === 'AbortError') {
display_error = "Query was cancelled";
} else {
display_error = e.toString();
}
targetResultEl.renderError(display_error);
return { format, exception_tag, reply, display_error, response_ok: false, is_table: false, is_raw: false, is_chart: false, is_image: false, is_error: true };
}
}
/// Build a compact stand-in for a failed reply too large to persist in full. The success path
/// drops an oversized result to `null` (it stays reproducible via `run=1`), but doing that for a
/// failure would lose the error entirely and make the tab reopen blank. Keep the essential error
/// text within the same size budget: the `{"exception":...}` line if the stream carried one — the
/// rows that streamed before it are what made the reply large and cannot be kept — otherwise a
/// bounded prefix of the error body. `renderFailedSnapshot` renders either form on restore (the
/// bounded-prefix form replays as the streamed prefix with the persisted `display_error` beneath).
function compactFailureSnapshot(reply)
{
/// Locate the `{"exception":...}` line without splitting the whole (potentially huge) reply.
let start = reply.startsWith(`{"exception":`) ? 0 : reply.indexOf(`\n{"exception":`);
if (start !== -1)
{
if (start > 0) ++start; /// skip the leading newline
let end = reply.indexOf('\n', start);
if (end === -1) end = reply.length;
const line = reply.slice(start, end);
/// Keep the whole exception line when it fits, so the restore replays it as the exception.
if (line.length <= 100000) return line;
/// A single exception line over the budget: store just its message text (which the restore
/// renders as a plain error) rather than a truncated, unparseable JSON line.
try { return JSON.parse(line).exception.slice(0, 100000); }
catch (e) { return line.slice(0, 100000); }
}
/// A plain (non-row) error body with no exception line: keep a bounded prefix.
return reply.slice(0, 100000);
}
/// Execute a single query (legacy path, used for single-query mode) in the given `tab`, rendering
/// into that tab's own persistent result element. All shared-chrome updates (progress bar under
/// Run, download/copy buttons, page hue) are applied only when `tab` is the active one; otherwise
/// they are recorded on the tab and replayed by `syncActiveTabChrome` when it is next opened, so a
/// background run never disturbs the foreground.
/// 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.
/// `paramValuesSnapshot`, `connection` and `query_database` are captured by the caller
/// (`postOne`/`postAll`) BEFORE the lexer preflight await, so a tab switch during that await cannot
/// rebind this run to another tab's parameters, database, or freshly edited connection fields.
async function postSingle(tab, posted_request_num, query, history_query_text, paramValuesSnapshot, queryStart, connection, query_database)
{
/// Per-run start time: tabs execute concurrently, so a shared global would be overwritten
/// by whichever tab launched last and misreport the elapsed time of the others.
const query_start = performance.now();
const el = tab.resultEl;
const active = () => tab.id === activeTabId;
/// `history_query_text` is provided exactly when the run covered only PART of the launched
/// editor (one statement of a multi-statement text, under the cursor or selected); with it
/// absent, `query` IS the whole launched text. `saveHistory` needs this to decide whether the
/// entry may carry `run=1` (see the full-editor requirement there).
const ran_full_editor = history_query_text === undefined;
/// The database, server address and user this run executes against are snapshotted by the caller
/// (`postOne`/`postAll`) BEFORE the lexer preflight await, together with the connection tuple and
/// parameter values, and threaded in — never re-read from the live inputs here. A tab switch +
/// database/connection edit during that await must not retarget this already-snapshotted run.
/// They are kept for the saved result snapshot and a later download so both reproduce the result
/// against the database and server/user that produced it (see `writeHistoryEntry`/
/// `materializeResult`). The password authenticates this run only; it is never persisted (see the
/// `last_url_for_download` docs).
const query_url = connection.url;
const query_user = connection.user;
tab.downloadVisible = false;
tab.copyVisible = false;
clearPanel(tab);
tab.progressPhase = 'running';
if (active()) { paintDownloadCopy(tab); progressEl.start(); }
tab.controller = new AbortController();
const result = await postImpl(tab, posted_request_num, query, el, tab.controller, paramValuesSnapshot, query_database, connection, queryStart);
if (result.cancelled) return;
if (result.is_error || !result.response_ok) {
tab.logoVisible = false;
tab.progressPhase = 'error';
tab.failed = true;
tab.downloadVisible = false;
tab.copyVisible = false;
if (active()) {
logoEl.style.display = 'none';
progressEl.clear();
paintDownloadCopy(tab);
}
/// 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 tab's request number (like the success path below and like
/// `postMulti`), so a superseded run (re-run / cancel) does not overwrite the snapshot.
if (posted_request_num == tab.reqNum) {
/// Mark "finished while away" only when the tab is not the active one at the moment the
/// run ends; a foreground finish must not make the tab sprout a completion check-mark
/// the instant the user later switches away from it.
tab.completed = !active();
saveHistory({
query: history_query_text || query,
resultQuery: query,
format: result.format,
/// The per-response `X-Datastore-Exception-Tag`: `renderFailedSnapshot` needs it to split
/// a transport `__exception__` marker on the same validated block the live path did (see
/// `renderFailedSnapshot`), so restore matches live instead of re-parsing the body tag-blind.
exception_tag: result.exception_tag,
/// The rendered error text — every failure path in `postImpl` sets it (HTTP error,
/// mid-stream marker / in-band failure, transport catch) — so the failure still
/// restores as this error even when `data` is dropped by the size cap below. For a
/// transport failure it is the synthetic message (`Network error` / `Query was
/// cancelled` / ...) rendered beneath the replayed prefix. See `renderFailedSnapshot`.
display_error: result.display_error,
ok: result.response_ok,
/// Unlike the success path, never drop a failure to `null`: keep at least a compact
/// summary so a reload restores the error instead of reopening the tab blank.
data: result.reply.length > 100000 ? compactFailureSnapshot(result.reply) : result.reply,
elapsed_ns: 1e6 * (performance.now() - query_start),
params: paramValuesSnapshot,
fullEditor: ran_full_editor,
database: query_database,
url: query_url,
user: query_user,
}, undefined, tab);
}
updateTabIndicator(tab);
return;
}
/// `postImpl` already performed `renderGraph`/`applyColumnColors`/`renderTotals`/`transposeIfNeeded`;
/// the only step specific to the single-query path is the final table layout pass. It measures
/// column widths, so defer it (like the transpose) when the tab is hidden — a hidden panel
/// measures 0 and would freeze every column to minimal width (`applyDeferredLayout` runs it on
/// show).
if (result.is_table) {
if (tab.id === activeTabId) el._changeTableLayout();
else el._pendingChangeLayout = true;
}
tab.progressPhase = 'done';
tab.failed = false;
tab.downloadVisible = true;
tab.copyVisible = result.is_raw;
tab.downloadQuery = query;
tab.downloadParams = paramValuesSnapshot;
/// Record the database and connection this run produced its result against, so a later download
/// (possibly after switching tabs, so `paintDownloadCopy` mirrors these into the shared
/// `last_*_for_download`) reproduces the result against the same database/server/user rather than
/// whatever connection happens to be live then. A fresh run's connection is always known, so the
/// result is reproducible (unlike a legacy restored snapshot; see `materializeResult`).
tab.downloadDatabase = query_database;
tab.downloadUrl = query_url;
tab.downloadUser = query_user;
if (result.is_raw || result.is_image) {
el.elapsedNs = 1e6 * (performance.now() - query_start);
}
tab.finalStats = { rowCount: el.rowCount, elapsedNs: el.elapsedNs, incomplete: el.incompleteResult };
if (active()) {
elapsed_ns = el.elapsedNs;
progressEl.finish();
paintDownloadCopy(tab);
progressEl.updateText(el.rowCount, el.elapsedNs, el.incompleteResult);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(query));
}
if (posted_request_num != tab.reqNum) { updateTabIndicator(tab); return; }
/// Only flag "finished while inactive" when the tab is not currently active (see the error
/// path above): otherwise switching away from a tab that just finished in the foreground would
/// wrongly show its completion check-mark.
tab.completed = !active();
updateTabIndicator(tab);
/// Persist the result snapshot on this 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 produced this result.
/// The per-column color coding modes travel with the snapshot (see `saveHistory`).
///
/// An image result (e.g. `FORMAT PNG`) streams as a blob and is rendered directly by `postImpl`,
/// so its `reply` stays empty and the image bytes are not persisted. Store `data: null` for it,
/// exactly like an oversized result and like the per-statement multi-query path (`postMulti`):
/// the restore path treats `data: null` as "no stored snapshot" and reopens the tab idle rather
/// than misclassifying the empty body as an empty raw result (with a Copy button). The run stays
/// reproducible from the `run=1` URL (see `buildHistoryParams`), which is why images are grouped
/// with oversized results as "not snapshotted" there. Storing `data: ''` here would fall through
/// to the raw branch in `materializeResult` and restore the image tab as an empty text result.
saveHistory({
query: history_query_text || query,
resultQuery: query,
format: result.format,
ok: result.response_ok,
data: (result.is_image || result.reply.length > 100000) ? null : result.reply,
elapsed_ns: el.elapsedNs,
params: paramValuesSnapshot,
fullEditor: ran_full_editor,
database: query_database,
url: query_url,
user: query_user,
}, undefined, tab);
}
/// Execute multiple queries with parallelization of consecutive SELECTs, in the given `tab`,
/// rendering into that tab's own multi-query container. Like `postSingle`, all shared-chrome
/// side effects apply only when `tab` is active; a background run only touches its own tab.
/// `parsed` is an array of { query, is_select } objects from splitAllQueries; `editorText` is the
/// full editor content captured at launch (used as the tab's saved query — must NOT be re-read
/// from the shared editor at completion, which by then may hold another tab's text).
/// `paramValuesSnapshot`, `connection` and `query_database` are captured by the caller before the
/// lexer preflight await (see `postSingle`); every statement group of this run uses the same values,
/// so a database/connection edit mid-run cannot reroute the later groups to a different database,
/// server or identity.
/// `ranFullEditor` says whether `parsed` covers EVERY statement of the launched editor ("Run all",
/// or a selection spanning all statements) — only then may the history entry carry `run=1`,
/// because its URL hash is the full editor text (see `saveHistory`).
async function postMulti(tab, posted_request_num, parsed, paramValuesSnapshot, editorText, connection, ranFullEditor, query_database)
{
/// Per-run start time, as in `postSingle`: concurrent tabs must not share it.
const query_start = performance.now();
const active = () => tab.id === activeTabId;
/// See `postSingle`: the database, server address and user are snapshotted by the caller before
/// the lexer preflight await and threaded in; every statement group of this run uses the same
/// values (groups run sequentially, so without a single snapshot editing the connection between
/// groups would split one run across different servers/users while the saved provenance still
/// claims a single producing connection). Kept for the saved result snapshot (see
/// `writeHistoryEntry`); the password authenticates this run's requests only.
const query_url = connection.url;
const query_user = connection.user;
tab.downloadVisible = false;
tab.copyVisible = false;
clearPanel(tab);
tab.progressPhase = 'running';
if (active()) { paintDownloadCopy(tab); progressEl.start(); }
/// Hide this tab's single result element and the logo; the multi container takes over.
tab.resultEl.style.display = 'none';
tab.logoVisible = false;
if (active()) logoEl.style.display = 'none';
/// Create this tab's multi-query container if needed (one per tab, kept alive with the panel).
if (!tab.multiContainer) {
tab.multiContainer = document.createElement('div');
tab.multiContainer.className = 'multi-query-results';
tab.panel.insertBefore(tab.multiContainer, tab.resultEl);
}
tab.multiContainer.innerHTML = '';
tab.multiContainer.style.display = 'block';
if (active()) multiQueryContainer = tab.multiContainer;
tab.multiControllers = [];
/// Create result elements for each query.
const resultElements = [];
for (let i = 0; i < parsed.length; ++i) {
const el = document.createElement('query-result');
el._colorModes = tab.colorModes;
el._pinnedColumns = tab.pinnedColumns;
tab.multiContainer.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 }
tab.progressState = progressState;
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);
}
const detail = {
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),
};
tab.progress = {
read_rows: total_read_rows, read_bytes: total_read_bytes,
total_rows_to_read: total_total_rows, elapsed_ns: max_elapsed_ns,
};
updateTabIndicator(tab);
if (active()) progressEl.updateProgress(detail);
}
/// 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 != tab.reqNum) return;
if (had_error) break;
const promises = group.indices.map(i => {
const ac = new AbortController();
tab.multiControllers.push(ac);
return postImpl(tab, posted_request_num, parsed[i].query, resultElements[i], ac, paramValuesSnapshot, query_database, connection, parsed[i].queryStart)
.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 != tab.reqNum) 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;
}
tab.logoVisible = !had_error;
/// A "Run all" where any statement failed marks the tab failed (red cross on its title) and is
/// encoded with the SAME `'error'` phase as a single-query failure — not `'done'`. Otherwise
/// `syncActiveTabChrome` (whose `'done'` branch calls `progressEl.finish()`) would replay the
/// green success check-mark when the failed background tab is reopened, contradicting its state.
tab.failed = had_error;
tab.progressPhase = had_error ? 'error' : 'done';
tab.finalStats = {
rowCount: total_rows,
elapsedNs: max_elapsed_ns || 1e6 * (performance.now() - query_start),
incomplete: any_incomplete,
};
if (active()) {
if (had_error) {
/// Mirror the single-query error path: clear the shared progress widget rather than
/// showing the success check-mark; each statement's error is rendered in its own panel.
progressEl.clear();
} else {
logoEl.style.display = 'block';
progressEl.finish();
elapsed_ns = tab.finalStats.elapsedNs;
progressEl.updateText(total_rows, tab.finalStats.elapsedNs, any_incomplete);
progressEl.style.setProperty('--progress', '0');
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(parsed[0].query));
}
}
if (posted_request_num != tab.reqNum) { updateTabIndicator(tab); return; }
/// As in the single-query path, only mark "finished while away" when this tab is inactive at
/// the moment the run completes; a foreground finish must not badge the tab on the next switch.
tab.completed = !active();
updateTabIndicator(tab);
/// 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,
/// drop each statement's raw body but KEEP the per-statement metadata (`display_error`
/// included), so a failed statement still restores as its error (see `renderFailedSnapshot`)
/// instead of the whole run degrading to query-only and blanking on the first tab switch.
/// 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,
/// See the single-query save: the tag lets restore reproduce the live marker split per statement.
exception_tag: r ? r.exception_tag : undefined,
/// See the single-query save: the rendered error text of a failed statement, so restore can
/// show it even when the statement's body is dropped by the size cap below.
display_error: r ? r.display_error : undefined,
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) for (const s of multi) s.data = null;
/// Save all queries to browser history, using the full editor content captured at launch.
saveHistory({
query: editorText,
format: null,
ok: !had_error,
data: null,
multi: multi,
elapsed_ns: tab.finalStats.elapsedNs,
params: paramValuesSnapshot,
fullEditor: ranFullEditor,
database: query_database,
url: query_url,
user: query_user,
}, parsed[0].query, tab);
}
/// Render a saved failed-query snapshot (`ok: false`) into `el`, mirroring the live mid-stream
/// failure rendering in `postImpl`. The saved `data` is the full raw reply that arrived before the
/// query failed: the partial output (table rows or raw bytes) followed by the failure signal —
/// either a transport-level `__exception__` marker block, or an in-band `{"exception":...}` line
/// (for a `*WithProgress` stream) / `exception` field (for a single-document JSON/XML format).
/// The live path deliberately KEEPS that partial output and renders the error beneath it (see the
/// table branch's `rowCount > 0` handling and the raw branch's line echoing in `postImpl`), so the
/// restore must replay the same prefix instead of collapsing to `error only` — otherwise a
/// mid-stream failure that showed `partial result + error` live degrades to `error only` on the
/// first tab switch or reload. `data` with no parseable failure signal and no distinct persisted
/// `display_error` is a plain error string (an HTTP-error body, or a legacy entry saved by
/// `postImpl`'s catch before `display_error` existed — then `format` may even be missing); render
/// it as the error alone, with no raw echo.
///
/// `tag` is the `X-Datastore-Exception-Tag` the producing response carried (persisted in the snapshot —
/// see `saveHistory`). The live path uses it as the discriminator that tells a genuine transport
/// `__exception__` block from successful output that merely mimics the marker shape, and it drives the
/// generic "could not be parsed" message when a marker started but no complete block arrived. Restore
/// MUST use the same discriminator rather than re-parsing the body tag-blind, otherwise a reload / tab
/// switch diverges from what the live UI showed: a marker-shaped lookalike in the output would be
/// misread as the failure (wrong split + wrong message), and an unparsable-marker failure would render
/// its raw bytes as the error text instead of the generic message. `tag` is `undefined` for legacy
/// snapshots and for failures with no transport marker (the header is only set when the marker is
/// written); then matching is skipped, exactly as `parseExceptionMarker` does live.
function renderFailedSnapshot(el, data, format, tag, display_error) {
/// The message the live path shows when a transport marker began but no complete, tag-validated
/// block could be recovered (broken framing / a truncated stream). Kept byte-identical to `postImpl`
/// so restore reproduces it instead of dumping the raw reply (which holds the truncated marker bytes).
const MARKER_UNPARSED = "The query failed after the response body had already started, but the error message could not be parsed.";
/// The body was dropped by the history size cap (`data: null`), or the failure produced no body at
/// all: there is no prefix to replay, but every failure path persists its rendered error text in
/// `display_error` (see `postImpl`), so the snapshot still restores as that error instead of a
/// blank tab. The `MARKER_UNPARSED` fallback is defensive only, for legacy entries saved before
/// `display_error` existed (the restore guards skip those without calling here).
if (!data) {
el.renderError(typeof display_error === 'string' ? display_error : MARKER_UNPARSED);
return;
}
/// When `data` IS present, the inner branches below recover the message FROM it — validating the
/// transport marker against the persisted tag, splitting the raw prefix on the validated block, and
/// finalizing the table exactly as the live path did — so the plain `display_error` replay must not
/// pre-empt them. This branch handles the failures whose `data` carries no signal:
/// - a transport failure caught in `postImpl`'s outer catch (`Network error` / cancellation / a
/// thrown error) after partial output had already streamed: `data` is that already-rendered
/// prefix (table rows or raw bytes) and the error text lives only in `display_error`. Replay
/// the prefix exactly as the live path left it and render the error beneath it. Unlike the
/// inner branches, the live outer catch does NOT finalize the table (no `renderTotals` /
/// `applyColumnColors` / `transposeIfNeeded`, and no `clear` of an empty table): it simply
/// throws after the streamed rows were flushed — match that exactly;
/// - an HTTP-error response (`!response.ok`) whose body IS the error text: there `postImpl` sets
/// `display_error` equal to the saved `data`, so this branch steps aside on the equality and
/// the fallthrough below renders `data` as the error alone, without first echoing it as output.
const has_failure_signal_in_data =
exceptionMarkerStart(data, tag) >= 0 || parseInbandException(data, format) !== null;
if (typeof display_error === 'string' && !has_failure_signal_in_data && display_error !== data) {
if (format === default_format) {
let fragment = document.createDocumentFragment();
for (const line of data.split('\n')) {
if (line.trim().length === 0) continue;
let parsed;
/// A truncated/garbled tail must not throw out of the whole restore: stop replaying.
try { parsed = JSON.parse(line); } catch (e) { break; }
/// A non-terminal in-band failure line the signal probe above cannot see (the live run
/// aborted with trailing content after it): leave it to `renderError` below — its
/// message is `display_error` — rather than replaying it as a row.
if (parsed.exception) break;
if (!el.update(parsed, fragment)) break;
}
el.flushFragment(fragment);
} else {
el.updateRaw(data);
}
el.renderError(display_error);
return;
}
if (format === default_format) {
/// Table branch — mirror `postImpl` (rows are `{"row":...}` / `{"meta":...}` / `{"totals":...}`
/// / `{"progress":...}` lines): replay them until the failure signal, keep the partial rows,
/// then render the error underneath.
let fragment = document.createDocumentFragment();
let inband_exception = null;
let marker_seen = false;
const lines = data.split('\n');
for (let idx = 0; idx < lines.length; ++idx) {
const line = lines[idx];
/// The transport marker occupies a whole line and everything from it on is the marker
/// block, not rows (see `EXCEPTION_MARKER`); stop replaying and recover the message below.
if (line.replace(/\r$/, '') === EXCEPTION_MARKER) { marker_seen = true; break; }
if (line.trim().length === 0) continue;
let parsed;
/// A truncated/garbled tail (e.g. the reply was cut off) should not throw out of the whole
/// restore: stop replaying rows and fall back to the recovered message below.
try { parsed = JSON.parse(line); } catch (e) { break; }
/// The in-band failure line (`http_write_exception_in_output_format`); like the live path,
/// treat it as the terminal failure rather than an ordinary row.
if (parsed.exception) { inband_exception = parsed.exception; break; }
if (!el.update(parsed, fragment)) break;
}
el.flushFragment(fragment);
if (el.rowCount > 0) {
el.renderTotals();
el.applyColumnColors();
el.transposeIfNeeded();
} else {
el.clear();
}
/// Validate the marker against the persisted tag, exactly as the live table branch did. When a
/// marker line was present but no complete block validates, live showed the generic message —
/// reproduce it here rather than rendering the raw reply (with the truncated marker bytes).
const marker = marker_seen ? parseExceptionMarker(data, tag) : null;
const message = inband_exception
?? (marker !== null ? marker.message : null)
?? parseInbandException(data, format)
?? (marker_seen ? MARKER_UNPARSED : data);
el.renderError(message);
return;
}
/// Raw branch. Mirror the live raw path (`postImpl`): echoing stops at the FIRST line equal to the
/// marker, but that line may merely look like the marker, so the real split is the first tag-validated
/// block at or after it. A failed raw snapshot that carries a marker line either validated a block
/// (show its message) or never completed one (broken framing / truncation ⇒ generic message live);
/// it can never fall through to the in-band / plain-string cases, which have no marker line.
const first_marker_line = exceptionMarkerStart(data, null); /// tag-blind: any candidate line
if (first_marker_line >= 0) {
const marker = parseExceptionMarker(data, tag);
if (marker !== null) {
/// A complete, tag-validated block: echo the raw output preceding it (any lookalike lines
/// included, as the live path did), keep the block hidden, then show its message.
if (marker.offset > 0) el.updateRaw(data.slice(0, marker.offset));
el.renderError(marker.message);
} else {
/// A marker began but never validated (broken framing / truncation): live echoed the output up
/// to the first *tag-compatible* marker start — an earlier lookalike line is ordinary output
/// that streamed before the failure — and showed the generic message. Split the same way; only
/// when no tag-compatible start exists (the tag line itself was truncated away, or a foreign /
/// pre-tag snapshot) fall back conservatively to the first candidate line.
const start = exceptionMarkerStart(data, tag);
const split = start >= 0 ? start : first_marker_line;
if (split > 0) el.updateRaw(data.slice(0, split));
el.renderError(MARKER_UNPARSED);
}
return;
}
/// In-band `{"exception":...}` line / `exception` field: the live raw branch echoes the whole
/// reply (the exception line included) and renders the parsed error beneath it.
const inband_message = parseInbandException(data, format);
if (inband_message !== null) {
el.updateRaw(data);
el.renderError(inband_message);
return;
}
/// No mid-stream failure signal — the body IS the error text (an HTTP-error response, whose
/// `display_error` equals `data`, or a legacy snapshot with none). Render it alone.
el.renderError(data);
}
/// 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.
/// `matToken` (a snapshot of `tab.reqNum`) guards the awaited chart/graph loaders: a superseding
/// run/re-materialization/close of the tab 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.
/// Returns false when such a restore-time failure turned a saved-successful statement into an
/// error panel, so the caller can fold it into the tab outcome (a persisted failure is already
/// accounted for by `snap.ok`); true otherwise, including when superseded (the caller re-checks
/// `matToken` and bails without using the value).
async function renderSnapshotIntoElement(el, snap, tab, matToken) {
el.queryText = snap.query;
/// `data == null` means no stored payload for this statement - an image (streamed as a blob,
/// kept as `data: null`) or a statement that never ran - so restore it as blank. A successful
/// statement with a legitimately empty body (`data === ''`, e.g. a raw-format `SELECT ... WHERE
/// 0`) is not that case and must still render, so test null here, not falsiness (mirrors the
/// single-query guard in `materializeResult`). A failed statement whose body was dropped by the
/// multi-run size cap still renders: every failure persists its rendered error text in
/// `display_error` (see `renderFailedSnapshot`), so only failures with neither survive as blank.
if (snap.data == null && typeof snap.display_error !== 'string') return true;
const format = snap.format;
if (!snap.ok) {
/// Replay any output that streamed before the mid-stream failure, exactly as the live path
/// left it, with the error rendered beneath (see `renderFailedSnapshot`).
renderFailedSnapshot(el, snap.data, format, snap.exception_tag, snap.display_error);
return true;
}
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 (matToken !== tab.reqNum) return true;
try {
await el.renderChart(JSON.parse(snap.data), () => matToken === tab.reqNum);
} catch (e) {
if (matToken !== tab.reqNum) return true;
console.log(e);
el.renderError(e.toString());
return false;
}
if (matToken !== tab.reqNum) return true;
} else {
el.updateRaw(snap.data);
}
if (el.isExplainGraph) {
try {
await el.renderGraph(() => matToken === tab.reqNum);
} catch (e) {
if (matToken !== tab.reqNum) return true;
console.log(e);
el.renderError(e.toString());
return false;
}
if (matToken !== tab.reqNum) return true;
} 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();
if (is_table) el.expandSingleValueIfNeeded();
}
return true;
}
/// 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();
const modes = tab ? tab.colorModes : {};
if (tab && tab.result) tab.result.color_modes = modes;
const url = new URL(window.location);
if (Object.keys(modes).length > 0) {
url.searchParams.set('color_modes', JSON.stringify(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 the restore path 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: modes } }
: { ...cur, color_modes: modes };
try {
history.replaceState(next, document.title, url.toString());
} catch (e) {
/// History manipulation is not allowed for the 'file:' protocol.
}
}
scheduleSave();
}
/// Persist the pinned columns the same way `persistColorModes` persists the color modes: onto the
/// active tab's result snapshot, the URL, and the current history entry, so reloads, shares, and
/// Back/Forward keep them. The pinned set is PER TAB (`getActiveTab().pinnedColumns`), so a toggle
/// updates only the active tab's persisted set.
function persistPinnedColumns() {
const tab = getActiveTab();
const pins = tab ? tab.pinnedColumns : {};
if (tab && tab.result) tab.result.pinned_columns = pins;
const url = new URL(window.location);
if (Object.keys(pins).length > 0) {
url.searchParams.set('pinned_columns', JSON.stringify(pins));
} else {
url.searchParams.delete('pinned_columns');
}
/// Keep the tabs state shape: fold the pins into the entry's nested `result` when it has one
/// (matching how the restore path 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, pinned_columns: pins } }
: { ...cur, pinned_columns: pins };
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 into `tab`'s own panel, rebuilding
/// its multi-query container the same way `postMulti` lays it out: one result element per
/// statement, this tab's single result element and the logo hidden, and aggregated progress text.
/// Called from `materializeResult` for a tab whose result carries a `multi` array (small runs;
/// large ones are query-only). `matToken` is the tab's materialization generation.
async function restoreMultiQuery(snapshot, tab, matToken) {
tab.resultEl.style.display = 'none';
if (!tab.multiContainer) {
tab.multiContainer = document.createElement('div');
tab.multiContainer.className = 'multi-query-results';
tab.panel.insertBefore(tab.multiContainer, tab.resultEl);
}
tab.multiContainer.innerHTML = '';
tab.multiContainer.style.display = 'block';
if (tab.id === activeTabId) multiQueryContainer = tab.multiContainer;
const elements = [];
for (let i = 0; i < snapshot.multi.length; ++i) {
const el = document.createElement('query-result');
el._colorModes = tab.colorModes;
el._pinnedColumns = tab.pinnedColumns;
tab.multiContainer.appendChild(el);
elements.push(el);
}
let any_restore_failed = false;
for (let i = 0; i < snapshot.multi.length; ++i) {
const rendered_ok = await renderSnapshotIntoElement(elements[i], snapshot.multi[i], tab, matToken);
if (matToken !== tab.reqNum) return;
if (!rendered_ok) any_restore_failed = true;
}
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;
}
/// The logo returns only for a fully successful run, matching the live `postMulti` path.
/// Recorded on the tab; `syncActiveTabChrome` applies it to the shared logo/progress bar.
/// A statement that failed to re-render (a chart/graph loader failure during restore) shows
/// an error panel, so it counts as failed too — matching the single-query restore path in
/// `materializeResult`, which flips the tab to `'error'` for the same loader failures.
tab.logoVisible = !snapshot.multi.some(s => !s.ok) && !any_restore_failed;
tab.failed = !tab.logoVisible;
/// Restore the same phase the live run recorded (`postMulti`): a failed "Run all" is `'error'`,
/// so reopening it does not paint the shared success check-mark over a failed tab.
tab.progressPhase = tab.failed ? 'error' : 'done';
tab.finalStats = { rowCount: total_rows, elapsedNs: snapshot.elapsed_ns || max_elapsed_ns, incomplete: any_incomplete };
}
/// Restore the SHARED editor (query text + parameter inputs) from a tab/history `state`. Guarded
/// by `token` (a snapshot of `activation_num`): a newer activation/run bumps it and this bails
/// after the tokenization await, so a slow restore never applies on top of a newer activation.
/// It does NOT touch the result DOM — the result lives in the tab's own panel and is rendered
/// separately by `materializeResult` (once, lazily) so switching keeps it intact. The producing
/// database/connection and the download-action targets are likewise restored per tab by
/// `materializeResult`, not here (this only touches the shared editor).
async function restoreEditor(state, token) {
if (token === undefined) token = activation_num;
query_area.value = state.query;
query_area.dispatchEvent(new Event('input'));
/// 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 a NEWER activation's restore has taken over the shared editor and parameter inputs, it now
/// owns `params_restore_pending_token` (every `restoreEditor` stamps it with its own token): stop,
/// so we neither overwrite that tab's freshly restored parameters nor clear its pending guard. A
/// run in the SAME tab that superseded this activation (`postOne`/`postAll` bump `activation_num`
/// without starting a new restore) still owns this token, so fall through: commit this tab's
/// parameters into the now-rebuilt inputs and release the guard. Keying the bail on the token
/// owner rather than on `activation_num` is what stops the guard from staying latched until the
/// next tab switch — which would leave the visible `param_*` inputs decoupled from the values a
/// later run actually sends (a run would keep sourcing the saved snapshot instead of the inputs).
if (params_restore_pending_token !== token) 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;
}
/// Render a tab's stored result snapshot (`tab.result`) into its OWN persistent result element,
/// the first time the tab is opened (lazy materialization) or when Back/Forward swaps its
/// snapshot. Sets per-tab state (progress phase, final stats, logo/download/copy visibility) but
/// does NOT paint the shared chrome — the caller does that via `syncActiveTabChrome`. Bumps the
/// tab's `reqNum` so a superseding run / re-materialization / close bails; guarded on `matToken`.
async function materializeResult(tab) {
tab.materialized = true;
tab.failed = false; /// set true below if the restored snapshot is an error
/// A rematerialized snapshot is a historical result, not a live run: drop any live-progress
/// numbers left on the tab (e.g. from a later run before a Back to an older entry). Otherwise
/// `syncActiveTabChrome`'s `'done'` branch would replay that stale `tab.progress` — the wrong
/// run's `Read ...` counters and progress fill — underneath the restored result. The finished
/// stats come from `tab.finalStats`, set below.
tab.progress = null;
const matToken = ++tab.reqNum;
const el = tab.resultEl;
const result = tab.result || {};
/// Restore this result's per-column color coding modes before rendering so the table is
/// colored on first paint. The element shares the tab's modes object.
tab.colorModes = result.color_modes || {};
el._colorModes = tab.colorModes;
/// Restore this result's pinned columns likewise, so the table is laid out with them on first
/// paint. The element shares the tab's pinned-columns object, mirroring the color modes above.
tab.pinnedColumns = result.pinned_columns || {};
el._pinnedColumns = tab.pinnedColumns;
clearPanel(tab);
tab.downloadVisible = false;
tab.copyVisible = false;
tab.downloadQuery = result.query || '';
tab.downloadParams = result.params || {};
/// Graph detection keys off the producing query, so a snapshot still renders as a graph
/// even if the editor query was changed without rerunning.
el.queryText = result.query ?? tab.query;
/// A snapshotted multi-query ("Run all") result is restored into this tab's multi-query
/// container, rebuilding one result element per statement, rather than into `tab.resultEl`.
if (result.multi && result.multi.length) {
await restoreMultiQuery(result, tab, matToken);
return;
}
/// A finished failed run must restore as an error even when its body was too large to store in
/// full (persisted as a compact summary) or was not stored at all (a legacy/edge snapshot with
/// `data: null`): fall through to the `!result.ok` branch below rather than reopening blank. A
/// never-run tab has no `ok` field, so it still resolves to idle here.
///
/// A successful run with no stored snapshot (`data: null`) resolves to idle here too: this
/// covers an oversized result and an image result (`FORMAT PNG` and friends), whose bytes are
/// deliberately not persisted (see `postSingle`/`postMulti`) and which stay reproducible from
/// the `run=1` URL. Reopening idle avoids rendering an empty raw result for them.
///
/// Test `data == null` ("no stored snapshot"), not falsiness: a successful run can legitimately
/// have an empty body (`data === ''`) in a raw format - e.g. `SELECT 1 WHERE 0 FORMAT CSV`
/// returns `200` with no bytes - and must still restore as `done` (with its elapsed time and
/// download action), not be demoted to idle.
if (result.data == null && result.ok !== false) { tab.progressPhase = 'idle'; return; }
let is_table = false;
let is_raw = false;
let format = result.format;
if (!result.ok) {
/// The idle logo is hidden for the error branch, matching the live single-query error path.
tab.logoVisible = false;
tab.progressPhase = 'error';
tab.failed = true;
if (result.data || typeof result.display_error === 'string') {
/// Replay any output that streamed before the mid-stream failure, exactly as the live
/// path left it, with the error rendered beneath (see `renderFailedSnapshot`); replayed
/// table rows need the same layout pass as the success restore below.
renderFailedSnapshot(el, result.data, format, result.exception_tag, result.display_error);
if (el.rowCount > 0) el._changeTableLayout();
} else {
/// The failure body was not stored (too large with no exception line to keep, or a
/// legacy snapshot): show a notice so the tab restores as failed rather than blank.
el.renderError('The query failed, but its error output was not stored.');
}
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 (!el.update(JSON.parse(line), fragment)) { break; }
}
el.flushFragment(fragment);
} else if (format == 'JSONCompactColumns') {
if (matToken !== tab.reqNum) { return; }
/// renderChart awaits the uPlot loader on first use; the predicate makes it bail
/// inside (right after the load, before mutating the DOM) if this materialization was
/// superseded, and we re-check here too.
try {
await el.renderChart(JSON.parse(result.data), () => matToken === tab.reqNum);
} catch (e) {
/// The external chart library failed to load (e.g. CDN blocked). Show the error
/// for this saved snapshot instead of throwing out half-rendered.
if (matToken !== tab.reqNum) return;
console.log(e);
el.renderError(e.toString());
tab.logoVisible = false;
tab.progressPhase = 'error';
tab.failed = true;
return;
}
if (matToken !== tab.reqNum) { return; }
} else {
is_raw = true;
el.updateRaw(result.data);
}
if (el.isExplainGraph) {
try {
await el.renderGraph(() => matToken === tab.reqNum);
} catch (e) {
/// The Viz loader failed; show the error instead of throwing out half-rendered.
if (matToken !== tab.reqNum) return;
console.log(e);
el.renderError(e.toString());
tab.logoVisible = false;
tab.progressPhase = 'error';
tab.failed = true;
return;
}
if (matToken !== tab.reqNum) return;
} else {
/// Render totals before coloring, so the totals row is colored on first paint too.
if (is_table) el.renderTotals();
el.applyColumnColors();
if (is_table) el.transposeIfNeeded();
if (is_table) el.expandSingleValueIfNeeded();
if (is_table) el._changeTableLayout();
}
/// A result is shown for this tab, so its download/copy actions apply to this query — unless the
/// snapshot predates the producing-connection record (`download_reproducible` is false): its
/// producing connection is unknown, so keep Download hidden until the query is rerun on the
/// current connection (a rerun stamps a fresh, reproducible snapshot). Copy stays available: it
/// copies the already-fetched result client-side and hits no server. The producing
/// database/server/user are recorded on the tab so `paintDownloadCopy` targets the download at
/// the connection that produced the result (see the download handler / `last_url_for_download`).
const download_reproducible = result.url !== undefined && result.user !== undefined;
tab.downloadDatabase = result.database || '';
tab.downloadUrl = result.url ?? '';
tab.downloadUser = result.user ?? '';
tab.progressPhase = 'done';
tab.downloadVisible = download_reproducible;
tab.copyVisible = is_raw;
if (is_raw) {
el.elapsedNs = result.elapsed_ns;
}
tab.finalStats = { rowCount: el.rowCount, elapsedNs: el.elapsedNs, incomplete: el.incompleteResult };
}
let query_area = document.getElementById('query');
/// A middle click that focuses the query editor - duplicating a tab, or opening a table in a new
/// tab - additionally pastes the X11 PRIMARY selection on Linux, appending clipboard junk after the
/// query we just set. The new-tab activation (`activateTab` with `guard_paste`) arms this flag
/// right around that `query_area.focus()`, so we swallow exactly that one paste.
///
/// The guard must never eat a real keyboard paste. The X11 PRIMARY paste is mouse-driven and
/// carries no keystroke, whereas a real keyboard paste - Ctrl+V / Cmd+V, or Shift+Insert on
/// Linux/Windows - is always preceded by its own paste-shortcut `keydown`; disarming on that
/// `keydown` (which fires before the resulting `paste`) keeps the discrimination independent of
/// timing. So even if the user middle-clicks and immediately presses a paste shortcut on a platform
/// where no PRIMARY paste ever arrives, their paste is preserved; the short post-focus fallback in
/// `activateTab` only bounds how long a never-consumed guard can linger.
///
/// The guard is a single shared bit, so we keep the handle of its one fallback timer and cancel
/// it whenever the guard is re-armed or consumed. Otherwise two overlapping middle-click opens
/// (open A, then open B within the fallback window) would share the bit but leave two pending
/// timeouts: A's stale timeout could clear the bit that B re-armed, before B's PRIMARY paste
/// arrives, letting that paste land in the editor. Cancelling the previous timer on re-arm means
/// an older open's timeout can never clear a newer open's guard.
let suppress_middleclick_paste = false;
let middleclick_paste_timeout = null;
function disarmMiddleclickPaste() {
suppress_middleclick_paste = false;
clearTimeout(middleclick_paste_timeout);
middleclick_paste_timeout = null;
}
query_area.addEventListener('keydown', e => {
/// Match the physical `V` key via `e.code` rather than the printed character `e.key`: on a
/// non-Latin layout (e.g. Russian) `Ctrl`/`Cmd`+paste reports a non-'v' `e.key`, so keying off
/// the character would leave the guard armed and swallow the genuine paste. `e.code === 'KeyV'`
/// is layout-independent, matching how the OS binds the paste shortcut on such layouts.
/// `Shift+Insert` already keys off the layout-independent named key `'Insert'`.
const keyboard_paste = ((e.ctrlKey || e.metaKey) && e.code === 'KeyV')
|| (e.shiftKey && e.key === 'Insert');
if (keyboard_paste) disarmMiddleclickPaste();
});
query_area.addEventListener('paste', e => {
if (suppress_middleclick_paste) {
e.preventDefault();
disarmMiddleclickPaste();
}
});
appendTextareaResizer();
/// ===== Tabs =====================================================================
/// A tab bundles a query, its title, query parameters and a result snapshot.
/// We keep one shared editor (its content restored on activation) but a persistent result panel
/// per tab, so results survive switching and queries run independently (see the notes on the
/// `tabs` model above and the `activation_num` / per-tab `reqNum` token split).
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;
}
}
/// Transient per-tab runtime state (never persisted). Kept in a factory so it can be attached both
/// to tabs created by `makeTab` and to tabs rehydrated from IndexedDB in `reconcileStartup`.
function tabRuntimeDefaults() {
return {
panel: null, /// the <div class="tab-panel"> holding this tab's result(s)
resultEl: null, /// this tab's <query-result>
multiContainer: null, /// this tab's multi-query results container (created on demand)
controller: null, /// AbortController of this tab's in-flight single query
multiControllers: [], /// AbortControllers of this tab's in-flight multi-query statements
reqNum: 0, /// per-tab run/render generation (see the token model at `activation_num`)
inFlight: false, /// a query is currently running in this tab
materialized: false, /// this tab's stored result has been rendered into its panel
completed: false, /// a query finished while this tab was inactive (check-mark / cross on its title)
failed: false, /// the finished query errored (show a red cross instead of a green check)
progress: null, /// last progress detail (numbers) for the shared bar / tab indicator
progressState: null, /// per-statement progress map for a multi-query run
progressPhase: 'idle', /// 'idle' | 'running' | 'done' | 'error' — drives the shared bar on activation
finalStats: null, /// { rowCount, elapsedNs, incomplete } shown when a run finishes
logoVisible: true, /// whether the shared logo shows for this tab (hidden by errors / multi)
colorModes: {}, /// per-column color modes for this tab's result (shared with its element)
pinnedColumns: {}, /// pinned columns for this tab's result (shared with its element)
downloadQuery: '', /// query the download button should re-run for this tab
downloadParams: {},
downloadDatabase: '', /// database the download should target (the producing connection)
downloadUrl: '', /// server address the download reproduces against (producing connection)
downloadUser: '', /// user the download reproduces against (producing connection)
downloadVisible: false, /// whether the download / copy buttons apply to this tab
copyVisible: false,
launchQuery: undefined, /// editor text snapshotted when this tab's run was launched (see `saveHistory`)
launchRunId: undefined, /// unique run identity of this tab's current launch (see `run_epoch` / `window.onpopstate`)
runEditorInteraction: 0, /// `editorInteractionGen` snapshot at this tab's run launch (see `beginFlight`)
queryErrorOwnerStart: -1, /// editor offset of the statement owning the error marker in this tab's run, or -1
barEl: null, /// cached tab-bar row + indicator nodes (see renderTabBar/updateTabIndicator)
spinnerEl: null,
checkEl: null,
crossEl: null,
fillEl: null,
};
}
/// Title for a duplicated tab: the whole source title is the family base, and the copy gets a
/// " N" suffix, N starting at 1 and incrementing until the result is free (titles must stay
/// unique — see `uniqueTitle`). Duplicating "Query A" yields "Query A 1", "Query A 2", …
///
/// The full source title is always used as the base — a trailing " N" is never stripped — so a
/// user-authored numeric ending is preserved and the duplicate lineage stays unambiguous:
/// duplicating "Incident 2025" yields "Incident 2025 1" (not "Incident 1"), and "Release 24 7"
/// yields "Release 24 7 1". A title-only heuristic cannot tell a user's trailing digits from a
/// duplicate counter — e.g. with both "Incident" and "Incident 2025" open, stripping " 2025"
/// would wrongly collapse the copy into the unrelated "Incident" family — so we do not strip.
function duplicateTitle(title) {
const taken = new Set(tabs.map(t => t.title));
for (let i = 1; ; ++i) {
const candidate = title + ' ' + 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,
...tabRuntimeDefaults(),
};
}
function getActiveTab() {
return tabs.find(t => t.id === activeTabId) || null;
}
/// Create (once) this tab's persistent result panel and <query-result>, append it (hidden), and
/// wire its live-progress routing. The panel and its rendered result survive tab switches.
function getOrCreatePanel(tab) {
if (tab.panel) return tab.panel;
const panel = document.createElement('div');
panel.className = 'tab-panel';
panel.dataset.id = tab.id;
panel.hidden = true;
const el = document.createElement('query-result');
el._colorModes = tab.colorModes;
el._pinnedColumns = tab.pinnedColumns;
panel.appendChild(el);
resultPanelsEl.appendChild(panel);
tab.panel = panel;
tab.resultEl = el;
el.addEventListener('query-progress', (e) => handleResultProgress(tab, e.detail));
return panel;
}
/// Show only the given tab's panel (creating it if needed), hide the rest, and repoint the
/// active-result aliases (`resultEl`/`multiQueryContainer`) at it. Result content is untouched.
function setActivePanel(tab) {
getOrCreatePanel(tab);
for (const t of tabs) if (t.panel) t.panel.hidden = (t !== tab);
resultEl = tab.resultEl;
multiQueryContainer = tab.multiContainer;
}
/// Clear a tab's own result element and multi container (its logo state resets to visible). Only
/// touches the shared progress bar / logo when the tab is active; a background clear stays local.
function clearPanel(tab) {
if (tab.resultEl) { tab.resultEl.clear(); tab.resultEl.style.display = ''; }
if (tab.multiContainer) { tab.multiContainer.innerHTML = ''; tab.multiContainer.style.display = 'none'; }
tab.logoVisible = true;
if (tab.id === activeTabId) { progressEl.clear(); logoEl.style.display = 'block'; elapsed_ns = 0; }
}
/// Point the shared download/copy buttons at a tab's stored state.
function paintDownloadCopy(tab) {
document.getElementById('download-div').style.display = tab.downloadVisible ? 'block' : 'none';
document.getElementById('copy-div').style.display = tab.copyVisible ? 'block' : 'none';
last_query_for_download = tab.downloadQuery || '';
last_params_for_download = tab.downloadParams || {};
/// Target the download at the database and connection that produced this tab's result (see the
/// download handler / `last_url_for_download`), so switching tabs and downloading reproduces the
/// active tab's own result rather than whatever the previous tab or the live inputs point at.
last_database_for_download = tab.downloadDatabase || '';
last_url_for_download = tab.downloadUrl || '';
last_user_for_download = tab.downloadUser || '';
}
/// Repaint the shared chrome (Run/Stop button, favicon, progress bar, logo, download/copy) from
/// the ACTIVE tab's stored state, so switching to a tab shows exactly what its own query left
/// behind — including a still-running background query's live bar. Also clears its check-mark.
function syncActiveTabChrome() {
const tab = getActiveTab();
if (!tab) return;
tab.completed = false; /// opening a tab clears its "finished while away" check-mark
if (tab.inFlight) {
document.getElementById('run').innerText = 'Stop';
document.getElementById('run-all').style.display = 'none';
} else {
updateRunButtons(isMultiQuery);
}
refreshFavicon();
/// Fully reset the shared widget, then rebuild BOTH of its halves from this tab's own state:
/// the live "Read ..." stats + bar (`updateProgress`) and the "N rows in result" line
/// (`updateText`). Resetting first is what keeps a half we do not repaint from leaking the
/// previously active tab's content (a stale "still running" bar under a done tab, or a stale
/// "Read ..." line under a running one).
progressEl.clear();
if (tab.progressPhase === 'running') {
progressEl.start();
if (tab.progress) {
progressEl.updateProgress(tab.progress);
progressEl.updateText(tab.progress.rowCount, tab.progress.elapsed_ns, tab.progress.incomplete);
}
} else if (tab.progressPhase === 'done' && tab.finalStats) {
progressEl.finish();
if (tab.progress) progressEl.updateProgress(tab.progress);
progressEl.updateText(tab.finalStats.rowCount, tab.finalStats.elapsedNs, tab.finalStats.incomplete);
progressEl.style.setProperty('--progress', '0');
}
logoEl.style.display = tab.logoVisible ? 'block' : 'none';
paintDownloadCopy(tab);
}
/// 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 `restoreEditor`, 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. Skip the capture until a restore commits (which nulls
/// the token); `tab.query` is safe (set before the await). A run that supersedes the restore
/// keeps the guard armed only until that restore's tail commits (its tail runs even when a
/// same-tab run superseded the activation, so the guard is not latched indefinitely); a switch
/// to another tab hands the guard to that tab's own restore.
if (params_restore_pending_token === null) tab.params = getParamValues();
}
/// Abort a tab's in-flight query (if any) and supersede its run generation so its late responses
/// bail. Touches only that tab. Used by cancel/close and by Back/Forward re-materialization.
function abortTabQuery(tab) {
++tab.reqNum;
if (tab.controller) tab.controller.abort();
for (const ac of tab.multiControllers) ac.abort();
tab.multiControllers = [];
tab.inFlight = false;
}
/// Activate a tab: reveal its persistent panel, restore the shared editor from it, materialize its
/// stored result the FIRST time it is opened (lazily), and repaint the shared chrome from its
/// state. It does NOT abort a query running in the tab we leave or the one we open — a background
/// query keeps streaming into its own panel, and returning to it shows its live progress.
/// `guard_paste` is set when a middle-click new-tab gesture opened this tab, so the activation
/// swallows the X11 PRIMARY paste that follows focusing the editor.
async function activateTab(id, guard_paste = false) {
const tab = tabs.find(t => t.id === id);
if (!tab) return;
activeTabId = id;
++activation_num;
const token = activation_num;
setActivePanel(tab);
/// Restore the shared editor (query text + params) from the tab; the result DOM stays put.
await restoreEditor({ query: tab.query, params: tab.params }, token);
if (token !== activation_num) return; /// a newer activation/run superseded this one
/// Lazily materialize the stored result into this tab's element the first time it is opened,
/// unless a query is running in it (the live stream owns the element) or it is already built.
if (!tab.materialized && !tab.inFlight) {
await materializeResult(tab);
if (token !== activation_num) return;
}
/// Apply any layout passes deferred while this tab's result rendered in the background (its
/// panel was hidden, so column widths measured 0). The panel is visible now, so measurements
/// are valid; covers the single result element and every multi-query statement element.
for (const el of tab.panel.querySelectorAll('query-result')) el.applyDeferredLayout();
syncActiveTabChrome();
document.documentElement.style.setProperty('--background-color-rotate', queryToColor(tab.query));
renderTabBar();
/// A middle-click duplicate or new-tab open arms the X11 PRIMARY paste guard right around this
/// focus, so the paste Linux delivers to the freshly-focused editor is swallowed - and only that
/// one. Anchoring the guard here, rather than at the original `auxclick`, means a slow activation
/// can never let it expire before focus. It is cleared by the swallowed paste, by a real
/// paste-shortcut keydown (which must never be eaten - see `suppress_middleclick_paste`), or by
/// the short fallback below in case no paste fires at all (non-Linux, empty selection), so the
/// flag can never linger. Re-arming here cancels any previous open's pending fallback timer, so
/// an overlapping middle-click open cannot have its guard cleared by an earlier open's timeout.
if (guard_paste) {
clearTimeout(middleclick_paste_timeout);
suppress_middleclick_paste = true;
}
query_area.focus();
if (guard_paste) middleclick_paste_timeout = setTimeout(disarmMiddleclickPaste, 250);
}
/// 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;
/// `reconcileStartup`'s deferred auto-run and URL-driven parameter restore act on
/// `getActiveTab()`/`activeTabId` AFTER awaiting (`updateQueryParams`, the lexer preflight in
/// `postAll`); a tab switch during one of those awaits would otherwise silently retarget them
/// at the newly active tab instead of the URL-authoritative one. Bootstrap-dirty makes
/// reconciliation yield to it, same as any other genuine interaction during the load window.
markBootstrapDirty();
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; its panel materializes empty on activation.
activateTab(tab.id).then(syncHistory);
scheduleSave();
}
/// Duplicate a tab: copy its query and parameters into a new tab inserted right after the
/// source, then switch to the copy. Triggered by a middle-click, Shift+click, or the platform's
/// new-tab modifier click (Cmd+click on macOS, Ctrl+click elsewhere) on the title — the browser
/// gesture for "open in a new tab" (macOS Ctrl+click is the secondary/context-menu click and is
/// left to the browser, not a duplicate gesture). `guard_paste` is set for a genuine middle click,
/// so the activation swallows the X11 PRIMARY paste that follows focusing the editor.
function duplicateTab(id, guard_paste = false) {
const src = tabs.find(t => t.id === id);
if (!src) return;
/// Flush any in-progress inline title rename here, before `duplicateTitle(src.title)` reads the
/// title below, so a copy of the tab being renamed reflects the new name rather than the stale
/// one. `renderTabBar` also flushes on every rebuild, but that runs later (via `activateTab`),
/// after the title has already been read. The editor is always the active tab.
if (activeTitleCommit) activeTitleCommit(true);
markBootstrapDirty(); /// user created a tab; reconciliation must preserve it
/// Fold live editor edits into the (possibly source) tab first, so the copy reflects them.
captureActiveTab();
refreshCurrentHistoryEntry(getActiveTab());
const tab = makeTab(duplicateTitle(src.title), src.query);
tab.params = { ...src.params };
const idx = tabs.findIndex(t => t.id === id);
tabs.splice(idx + 1, 0, tab);
activeTabId = tab.id;
activateTab(tab.id, guard_paste).then(syncHistory);
scheduleSave();
}
/// Like `addTab`, but the new tab starts pre-filled with `query` and becomes active.
/// Used by the databases panel when a table is opened with a new-tab gesture: middle
/// click, Shift+click, or the platform's new-tab modifier click - Cmd+click on macOS,
/// Ctrl+click elsewhere (on macOS `Ctrl+Click` is the context-menu click, not a new-tab
/// gesture; see the manual matrix above `onTableActivate`). `guard_paste` is set for a
/// genuine middle click, so the activation swallows the X11 PRIMARY paste that follows
/// focusing the editor.
function openQueryInNewTab(query, guard_paste = false) {
markBootstrapDirty(); /// user created a tab; reconciliation must preserve it
captureActiveTab();
refreshCurrentHistoryEntry(getActiveTab());
const tab = makeTab(undefined, query);
tabs.push(tab);
activeTabId = tab.id;
activateTab(tab.id, guard_paste).then(syncHistory);
scheduleSave();
}
function closeTab(id) {
const idx = tabs.findIndex(t => t.id === id);
if (idx === -1) return;
const closing = tabs[idx];
const wasActive = id === activeTabId;
/// Closing a tab is a genuine user action that can change `activeTabId` (when the active tab is
/// closed and a neighbour is activated below). During `reconcileStartup`'s await window this is
/// the same class of interaction that `switchToTab`/`addTab`/`openQueryInNewTab` already mark
/// dirty: without it, closing the URL-authoritative tab would leave `bootstrap_dirty === false`,
/// and the deferred URL param-restore / `run=1` auto-run would retarget the newly activated tab,
/// executing a different tab's query without an explicit Run.
markBootstrapDirty();
/// Stop the closed tab's query and drop its panel from the DOM (freeing its result/images).
abortTabQuery(closing);
if (closing.panel) closing.panel.remove();
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();
refreshFavicon(); /// closing a background tab may have removed the last running query
}
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');
/// Drop the hover tooltip while typing so it does not linger with the old title.
titleEl.removeAttribute('title');
/// 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 (activeTitleCommit === commit) activeTitleCommit = null;
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();
};
/// Expose the commit so paths that rerender the tab bar (e.g. duplicating another tab)
/// can flush this pending rename first — the editor otherwise only writes back on `blur`.
activeTitleCommit = commit;
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() {
/// Flush any in-progress inline title rename before we rebuild and destroy its editable span.
/// The editor writes back to `tab.title` only on `blur`, but many paths rerender the bar without
/// a blur first — a slow query completing calls `writeHistoryEntry` -> `renderTabBar`, closing a
/// tab or navigating rerenders too, and a middle click suppresses the blur — each of which would
/// otherwise silently drop the pending rename. Committing here, the single rerender chokepoint,
/// covers every path (not just `duplicateTab`). Safe against re-entry: `commit` clears
/// `activeTitleCommit` before it calls back into `renderTabBar`, so this becomes a no-op.
if (activeTitleCommit) activeTitleCommit(true);
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.
/// A running query also reveals the bar (`anyInFlight`), so even the very first query on a
/// fresh page can be backgrounded: the user needs the `[+]` button to open a second tab (or
/// switch away) while that first query is still in flight.
tab_bar_elem.hidden = (tabs.length <= 1 && !(active && active.result && active.result.ran) && !anyInFlight());
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));
/// Progress fill behind the title (width driven by `--tab-progress`).
const fillEl = document.createElement('div');
fillEl.className = 'tab-progress-fill';
el.appendChild(fillEl);
const titleEl = document.createElement('span');
titleEl.className = 'tab-title';
titleEl.textContent = tab.title;
/// Native tooltip so a title truncated by the width cap can still be read in full on hover.
titleEl.title = tab.title;
el.appendChild(titleEl);
/// Activity indicators: a spinning hourglass while the tab's query runs, and a check-mark
/// once it has finished while the tab was inactive (cleared when the tab is opened).
const statusEl = document.createElement('span');
statusEl.className = 'tab-status';
const spinnerEl = document.createElement('span');
spinnerEl.className = 'tab-spinner';
spinnerEl.textContent = '⧗';
spinnerEl.style.display = 'none';
const checkEl = document.createElement('span');
checkEl.className = 'tab-check';
checkEl.textContent = '✔';
checkEl.style.display = 'none';
const crossEl = document.createElement('span');
crossEl.className = 'tab-cross';
crossEl.textContent = '✖';
crossEl.style.display = 'none';
statusEl.appendChild(spinnerEl);
statusEl.appendChild(checkEl);
statusEl.appendChild(crossEl);
el.appendChild(statusEl);
const closeEl = document.createElement('span');
closeEl.className = 'tab-close';
closeEl.title = 'Close';
closeEl.textContent = '×';
el.appendChild(closeEl);
/// Cache the row + indicator nodes so `updateTabIndicator` can update them in place on
/// every progress tick without rebuilding the whole bar.
tab.barEl = el;
tab.fillEl = fillEl;
tab.spinnerEl = spinnerEl;
tab.checkEl = checkEl;
tab.crossEl = crossEl;
el.addEventListener('click', (e) => {
if (e.target === closeEl) { e.stopPropagation(); closeTab(tab.id); return; }
/// On macOS `Ctrl+Click` is the secondary (context-menu) click, not a new-tab
/// gesture - leave it entirely to the browser (see `set_query_or_run`). The macOS
/// duplicate modifier is Cmd (`metaKey`), checked below.
if (isMac && e.ctrlKey) return;
/// While the title is being edited inline, let clicks act on the editor (cursor placement,
/// selection) rather than duplicating from the not-yet-committed title.
if (e.target === titleEl && titleEl.isContentEditable) return;
/// Shift+click or the platform's new-tab modifier click (Cmd on macOS, Ctrl elsewhere)
/// on the title duplicates the tab (browser "open in new tab" gesture).
if (e.target === titleEl && (e.shiftKey || (isMac ? e.metaKey : e.ctrlKey))) { e.preventDefault(); duplicateTab(tab.id); return; }
if (tab.id === activeTabId) {
if (e.target === titleEl) startTitleEdit(tab, titleEl);
} else {
switchToTab(tab.id);
}
});
/// Middle-click does not raise a `click` event; it comes through as `auxclick` (button 1).
/// A genuine middle click needs the X11 PRIMARY paste guard (see `suppress_middleclick_paste`).
el.addEventListener('auxclick', (e) => {
/// Skip while editing so a middle click pastes the X11 PRIMARY selection into the title instead.
if (e.button === 1 && e.target === titleEl && !titleEl.isContentEditable) { e.preventDefault(); duplicateTab(tab.id, true); }
});
/// Suppress the middle-click autoscroll cursor over the title (but not while editing, so PRIMARY paste works).
el.addEventListener('mousedown', (e) => {
if (e.button === 1 && e.target === titleEl && !titleEl.isContentEditable) e.preventDefault();
});
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);
/// Reflect the tab's current run state on its fresh indicator nodes.
updateTabIndicator(tab);
}
}
/// Update one tab's title indicators in place (no full `renderTabBar`): the spinning hourglass
/// while its query runs, the progress fill behind its title, and the completion check-mark shown
/// once a query has finished while the tab is inactive. Cheap O(1) attribute writes, safe to call
/// on every progress event. A no-op until the tab has a row in the bar (`renderTabBar` ran).
function updateTabIndicator(tab) {
if (!tab.barEl) return;
const isActive = tab.id === activeTabId;
if (tab.spinnerEl) tab.spinnerEl.style.display = tab.inFlight ? 'inline-block' : 'none';
/// A finished-while-inactive tab shows a green check (success) or a red cross (the query
/// failed). Both clear once the tab is opened (`tab.completed` is reset in `syncActiveTabChrome`).
const done = tab.completed && !tab.inFlight && !isActive;
if (tab.checkEl) tab.checkEl.style.display = (done && !tab.failed) ? 'inline-block' : 'none';
if (tab.crossEl) tab.crossEl.style.display = (done && tab.failed) ? 'inline-block' : 'none';
let pct = 0;
if (tab.inFlight && tab.progress) {
const total = +tab.progress.total_rows_to_read || 0;
const rows = +tab.progress.read_rows || 0;
if (total > 0) pct = Math.min(100, 100 * rows / total);
}
tab.barEl.style.setProperty('--tab-progress', pct ? pct + '%' : '0%');
}
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 = 'datastore-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/database/param_*/tab) shared by history
/// entries. The active tab name is appended so a shared URL can be reconciled against saved tabs
/// on load. The connection identity (`user`, `server_address`, `database`) is passed in rather
/// than read from the live globals: a run-produced entry must carry the connection the query
/// actually ran against — captured in the result snapshot — not whatever the panel/connection has
/// since been switched to while the query was in flight (see `writeHistoryEntry`), so a shared or
/// reloaded `run=1` URL reproduces the displayed result instead of replaying against a different
/// database or server.
/// `fromRun` marks an entry that a genuine run produced (see `writeHistoryEntry`).
function buildHistoryParams(params, tabName, fromRun, colorModes, pinnedColumns, user, server_address, database) {
const out = [];
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));
/// The selected database is a connection-scoped setting (like `user`/`url`): carry it so a
/// shared or reloaded link resolves unqualified table names against the same database, and an
/// auto-run `run=1` link reproduces the run instead of hitting the server default database.
if (database) out.push('database=' + encodeURIComponent(database));
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)));
}
/// Likewise carry the result's pinned columns, so a shared/reloaded URL restores them.
if (pinnedColumns && Object.keys(pinnedColumns).length > 0) {
out.push('pinned_columns=' + encodeURIComponent(JSON.stringify(pinnedColumns)));
}
if (tabName) out.push('tab=' + encodeURIComponent(tabName));
return out;
}
/// Compare two server addresses for connection identity, tolerating cosmetic differences that
/// address the same endpoint (a root-path trailing slash, an explicit default port) while still
/// separating endpoints that differ in the request target. Used by the history restore gate
/// (`window.onpopstate`) so re-typing e.g. `http://h:8123` as `http://h:8123/` does not stop
/// restoring an entry produced on the same server. The query string IS significant: the request URL
/// is built by concatenating `url_elem.value` and preserving any existing `?...` (see `postImpl`),
/// so `http://h:8123/?cluster=a` and `http://h:8123/?cluster=b` are different endpoints (a different
/// cluster/namespace/settings) and must not share a restored `database=`. The trailing slash is
/// collapsed for the ROOT path only: `postImpl` sends the path verbatim, so a non-root
/// `/datastore` and `/datastore/` can be different routes/namespaces on a proxied deployment and
/// are compared exactly (only the root path, where the URL parser already yields `/` for both `origin`
/// and `origin/`, is treated as equivalent). Relative addresses (e.g. `/datastore`) are resolved
/// against the current page so they parse; both operands come from the same `url_elem.value` field,
/// so this is symmetric. Falls back to an exact string compare when a value does not parse as a URL.
function sameServerAddress(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
try {
const ua = new URL(a, location.href);
const ub = new URL(b, location.href);
/// Root path only: '' and '/' are the same root; every other path is compared verbatim.
const rootNormalized = (p) => (p === '/' || p === '') ? '/' : p;
return ua.protocol === ub.protocol
&& ua.host === ub.host
&& rootNormalized(ua.pathname) === rootNormalized(ub.pathname)
&& ua.search === ub.search;
} catch (e) {
return false;
}
}
/// The effective user of a connection. The user can be given either in the explicit `user` field
/// or embedded in the server URL's userinfo (`http://alice@host:8123/`), and the request sends the
/// field value only when it is non-empty (see `postImpl`), otherwise the URL userinfo authenticates
/// the request. The selected database is a namespace on (server, user), so the identity must fold in
/// the userinfo user: `sameServerAddress` compares the endpoint (`host`, not the authority), so
/// `http://alice@host:8123/` and `http://bob@host:8123/` look like the same server there and must be
/// separated by their user. The field takes precedence over the userinfo, mirroring how the request
/// is built. The userinfo may be percent-encoded, so decode it (falling back to the raw value on a
/// malformed escape) before comparing.
function effectiveConnectionUser(serverAddress, fieldUser) {
if (fieldUser) return fieldUser;
try {
const raw = new URL(serverAddress, location.href).username;
try { return decodeURIComponent(raw); } catch (e) { return raw; }
} catch (e) {
return '';
}
}
/// Has the live target moved away from the connection/selection the query that produced this run
/// snapshot (`tab.result`) actually ran against? Compared with the SAME semantic identity as the
/// restore gate (`window.onpopstate`) and the connection-change invalidation
/// (`resetConnectionScopedState`): `sameServerAddress` + `effectiveConnectionUser`, with the
/// selected database a namespace on that pair. So a cosmetic-only URL difference (a root-path
/// trailing slash, a default port) or a password-only change is not a divergence, matching those
/// gates, and the databases are compared in canonical effective form (`effectiveDatabase`) so a
/// snapshot carrying the explicit spelling of the server's current database (a shared/restored
/// `?database=` URL) is not a divergence from the collapsed `null` live selection — both denote
/// the same target. (When the snapshot ran against a DIFFERENT server whose database name happens
/// to match the live server's current one, the collapse cannot mask it: the connection comparisons
/// below still flag the divergence.) Used by `writeHistoryEntry` to decide whether a run entry may
/// keep describing the producing snapshot (and carry `run=1`), and by `refreshCurrentHistoryEntry`
/// to make the same call when the entry cannot be written at all.
function liveDivergedFromRun(run_snapshot) {
/// A snapshot saved before the producing connection was recorded (a legacy tab restored from
/// persistence) carries no `url`/`user`, so the connection it ran against is UNKNOWN. It cannot
/// be proven reproducible on the live connection, so it always counts as diverged (fail
/// closed): `run=1` is dropped and the live connection is stamped, never claiming the legacy
/// result is reproducible on the current server — which would otherwise auto-run the old query
/// on the wrong one on reload/share. A fresh run always records `url`/`user` (empty strings for
/// the default same-origin connection), so only pre-upgrade snapshots take this branch.
const snapshot_connection_known = !!run_snapshot
&& run_snapshot.url !== undefined && run_snapshot.user !== undefined;
return !!run_snapshot && (
!snapshot_connection_known
|| effectiveDatabase(selected_database) !== effectiveDatabase(run_snapshot.database)
|| !sameServerAddress(run_snapshot.url, url_elem.value)
|| effectiveConnectionUser(run_snapshot.url, run_snapshot.user)
!== effectiveConnectionUser(url_elem.value, user_elem.value));
}
/// 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;
/// The connection-scoped identity carried by this entry — the selected database, server address
/// and user, serialized into the URL and stamped into the state — must describe the connection
/// the entry is reproducible against. Three cases:
/// - A run-produced entry (`fromRun`, i.e. carrying `run=1`) whose live connection STILL
/// matches the connection the query ran against: use the producing snapshot verbatim so the
/// `run=1` URL reloads/shares the exact producing query. The run resolves asynchronously, so
/// the live values, though semantically equal here, may differ cosmetically.
/// - A run-produced entry whose live connection or `selected_database` has since DIVERGED (the
/// panel selection or the connection inputs were switched while the query was still in
/// flight): describe the LIVE page instead, and drop `run=1` (below). Stamping the producing
/// connection here would rewrite the current URL/history back to the old target, so reloading
/// or sharing the finished link would silently discard the user's newer live edits and reopen
/// the page on the wrong server/database. The producing connection is still kept in the result
/// snapshot (`tab.result.{url,user,database}`) for rendering, download and Back/Forward
/// restore, so nothing about the displayed result is lost.
/// - An editor-only entry (`fromRun` false): no reproducible run, so use the live connection.
/// - A legacy snapshot predating the url/user snapshot (unknown producing connection): treated
/// as diverged, so `run=1` is dropped and the live connection is stamped (fail closed) rather
/// than claiming the legacy result is reproducible on the live server (see below).
const run_snapshot = fromRun ? tab.result : null;
/// See `liveDivergedFromRun` for the identity semantics (an unknown producing connection —
/// a legacy snapshot without `url`/`user` — always counts as diverged, fail closed).
const live_diverged = liveDivergedFromRun(run_snapshot);
const use_snapshot = !!run_snapshot && !live_diverged;
const entry_database = use_snapshot ? run_snapshot.database : selected_database;
const entry_server = use_snapshot ? run_snapshot.url : url_elem.value;
const entry_user = use_snapshot ? run_snapshot.user : user_elem.value;
const carries_run = fromRun && !live_diverged;
/// This write just made the current entry stop requesting the deferred startup auto-run (see
/// `deferred_run_cancelled` above `reconcileStartup`): every editor-only writer that can drop
/// `run=1` — a query/param edit, a tab switch, a database-panel click, a connection-field edit,
/// and any future one — funnels through `writeHistoryEntry`, so no caller needs to remember to
/// cancel it individually. Setting it once the gate has already fired (e.g. from `saveHistory`,
/// when the deferred run itself completes) is harmless: the flag is never consulted again after
/// `reconcileStartup` has made its decision.
if (!carries_run) deferred_run_cancelled = true;
const state = {
query: tab.query,
params: tab.params,
result: tab.result || null,
tabId: tab.id,
tabName: tab.title,
/// Stamp the connection identity (server address and user) this entry is reproducible against
/// (the producing connection for a matching run entry, the live one otherwise; see above) so
/// `window.onpopstate` can restore this entry's `database=` selection only while the live
/// connection still matches (see the restore gate there). These values survive a page
/// reload, unlike the earlier `connectionGeneration` counter, which reset to `0` on reload
/// and could then both restore a different connection's database and stop restoring the live
/// connection's. The password is deliberately never serialized (here or in the URL), so it
/// is not part of the identity: the selected database is a namespace on (server, user) and
/// survives re-authentication with a different password on the same server/user.
connectionServer: entry_server,
connectionUser: entry_user,
/// The run identity this entry currently reflects (see `run_epoch`): the still-running launch
/// while the tab is in flight (so a mid-run `result: null` entry is stamped with the run that
/// wrote it), otherwise the completed snapshot's id. `window.onpopstate` matches it against
/// the tab's live state to decide whether that state stands in for a null entry.
run_id: tab.inFlight ? tab.launchRunId : (tab.result && tab.result.run_id),
};
const title = "Datastore Query: " + (titleForDoc || tab.query);
/// Once the tab has materialized, its live `colorModes` / `pinnedColumns` objects are the
/// authoritative display state: URL reconciliation and per-column toggles mutate them in place,
/// and a rerun nulls `tab.result` entirely, so the snapshot's `color_modes` / `pinned_columns`
/// may lag them or be missing (e.g. a snapshot saved with the default palette stores none, then
/// a shared link's URL modes are applied on top). Serialize the live state, and mirror it into
/// the snapshot so the IndexedDB persistence (`scheduleSave` below) and the history-state copy
/// carry it too. A tab whose stored result has NOT been rendered yet keeps its snapshot's saved
/// state: its `colorModes` / `pinnedColumns` are still the `makeTab` placeholders and must not
/// clobber the saved ones.
let color_modes = tab.result && tab.result.color_modes;
let pinned_columns = tab.result && tab.result.pinned_columns;
if (tab.materialized) {
color_modes = tab.colorModes;
pinned_columns = tab.pinnedColumns;
if (tab.result) { tab.result.color_modes = tab.colorModes; tab.result.pinned_columns = tab.pinnedColumns; }
}
/// `carries_run` (rather than the raw `fromRun`) drops `run=1` when the live connection/database
/// diverged from the one the displayed result was produced against: auto-running the live target
/// on reload would not reproduce it (and must not fire a write against the wrong server/database).
/// The producing user/server/database are serialized into the URL and stamped into the state so
/// Back/Forward can restore this entry's `database=` only while the live connection still matches.
const history_params = buildHistoryParams(tab.params, tab.title, carries_run, color_modes, pinned_columns, entry_user, entry_server, entry_database);
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).
/// The selected database is part of "what would re-run": a `run=1` URL also carries `database=`
/// from the live selection, so if the panel selection has moved away from the database that
/// produced the result, re-running would hit a different database. Drop the marker in that case
/// (comparing in canonical effective form — `effectiveDatabase` — so an absent/empty selection
/// means the server default and an explicit spelling of the server's current database, as carried
/// by a shared/restored `?database=` snapshot, denotes that same target), so a database-only
/// selection change never leaves a shared/reloaded `run=1` link auto-running against the wrong
/// one, while a semantically no-op click on the already-current row keeps the marker. A snapshot
/// produced on a different SERVER is out of scope here: `writeHistoryEntry`'s `live_diverged`
/// compares the connections and drops `run=1` regardless of what this database check says.
function tabReflectsRun(tab) {
return !!(tab && tab.result && tab.result.ran
&& tab.query === tab.result.query
&& JSON.stringify(tab.params || {}) === JSON.stringify(tab.result.params || {})
&& effectiveDatabase(selected_database) === effectiveDatabase(tab.result.database));
}
/// 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. The bail must still make the deferred-run decision,
/// though: `history.state` survives a reload, so during startup the current entry can still
/// belong to a tab of the PREVIOUS page instance while the active tab is only the fresh
/// bootstrap tab (`reconcileStartup` rebinds them later). A trusted mutation in that window — a
/// database-panel click, a connection edit, a tab operation — reaches this bail instead of
/// `writeHistoryEntry`, yet it moved the live target away from what the deferred `run=1`
/// auto-run was armed for, so it must cancel that run exactly as the entry write would have
/// (see `deferred_run_cancelled` above `reconcileStartup`). The same `carries_run` rule as
/// `writeHistoryEntry` is applied: cancel unless the tab still reflects a run whose producing
/// connection matches the live one. Setting the flag on a post-startup bail is harmless — it is
/// never consulted again after `reconcileStartup` has made its decision.
///
/// 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) {
if (!(tabReflectsRun(tab) && !liveDivergedFromRun(tab.result))) deferred_run_cancelled = true;
return;
}
writeHistoryEntry(tab, undefined, /*forceReplace=*/ true, /*fromRun=*/ tabReflectsRun(tab));
}
/// Called after an actual run: stash the result snapshot on the tab that ran (this is what makes
/// the tab "have a result", hence visible in the bar) and record history. `tab` defaults to the
/// active tab (color-mode / popstate callers), but the run path passes the tab it launched in,
/// which may have become inactive: for a background tab we persist the snapshot and refresh the
/// bar, but must NOT touch the browser history / URL / document title — those follow the ACTIVE
/// tab, and hijacking them to a background tab's query would break Back/Forward and reload.
function saveHistory(stateData, titleForDoc, tab) {
tab = tab || getActiveTab();
if (!tab) return;
/// Adopt the launch-time editor text only while the tab's draft still matches it. A run can
/// finish in the background after the user edited the query and switched away: by then
/// `captureActiveTab` (or the live input handler) has saved the newer draft into `tab.query`,
/// and overwriting it with the old launch text would lose that draft. The statement that
/// produced the result is still recorded in `result.query` below either way.
const query_unchanged = tab.launchQuery === undefined || tab.query === tab.launchQuery;
if (query_unchanged) tab.query = stateData.query;
/// The same "draft still matches the launched run" predicate for the PARAMETERS: editing a
/// `param_*` input while the run was in flight updates `tab.params`, and the history entry /
/// URL below is written from `tab.params` — stamping `run=1` on it would let a reload or a
/// shared link rerun different bindings than the result currently shown. `stateData.params`
/// is the launch-time snapshot the run actually executed with (same comparison as
/// `tabReflectsRun` uses against `result.params`).
const params_unchanged = JSON.stringify(tab.params || {}) === JSON.stringify(stateData.params || {});
/// The entry may be auto-runnable only when this run executed the FULL editor content its URL
/// hash will carry ("Run" on a single-statement editor, "Run all", or a selection spanning
/// every statement). A partial run — one statement of a multi-statement editor — writes the
/// whole editor into the hash, and stamping `run=1` on that would make a reload of a page
/// opened with `run=1` execute the statements the user deliberately did NOT run (e.g. running
/// just `SELECT 1` out of `SELECT 1; DROP TABLE t` must not produce a URL that replays the
/// `DROP`). The editor-only writers already enforce this via `tabReflectsRun`, whose
/// `result.query` comparison fails for a partial run; this is the same rule for the run path.
const full_editor_run = stateData.fullEditor === true;
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
/// The default database in effect when this result was produced, so navigating back to
/// this entry (or reloading its `run=1` URL) reproduces the run and download against the
/// same database rather than the current panel selection or the server default.
database: stateData.database,
/// The server address and user that produced this result, so a download after navigating
/// back to this entry reproduces it against the same connection even when the live
/// connection has since changed. The password is deliberately never stored in history.
url: stateData.url,
user: stateData.user,
format: stateData.format,
/// The `X-Datastore-Exception-Tag` of a failed run, so restoring the snapshot (tab switch,
/// reload, Back/Forward) can split a transport `__exception__` marker on the validated block —
/// see `renderFailedSnapshot`. Undefined for successful runs and legacy entries; then matching
/// is skipped, as before.
exception_tag: stateData.exception_tag,
/// The rendered error text of a failed run — every failure path in `postImpl` sets it: shown
/// by `renderFailedSnapshot` beneath the replayed prefix for a transport failure, and alone
/// when the body was dropped by the size cap. Round-trips through browser history /
/// `IndexedDB` with the snapshot. Undefined for successful runs and legacy entries.
display_error: stateData.display_error,
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: tab.colorModes,
/// Pinned columns in effect when this result was produced; travels with the snapshot so
/// re-activating the tab (or navigating / reloading) restores them, like `color_modes`.
pinned_columns: tab.pinnedColumns,
/// The run identity that produced this result (see `run_epoch`). A completing run still owns
/// its tab here (the caller bailed on a superseding `reqNum`), so `tab.launchRunId` is this
/// run's. Lets `window.onpopstate` recognize the mid-run history entry this run wrote —
/// which carries the same `run_id` — as belonging to the live snapshot.
run_id: tab.launchRunId,
elapsed_ns: stateData.elapsed_ns,
};
if (tab.id === activeTabId) {
/// 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.
/// When the draft diverged from the launch text OR its parameters diverged from the ones
/// the run executed with, the entry carries an UNRUN draft, which must never be
/// auto-runnable (see `writeHistoryEntry` on the `run=1` invariant). A partial run of a
/// multi-statement editor is equally not auto-runnable (see `full_editor_run` above).
writeHistoryEntry(tab, titleForDoc, /*forceReplace=*/ false, /*fromRun=*/ query_unchanged && params_unchanged && full_editor_run);
} else {
/// Background run finished: reflect its new result in the tab bar and persist it, without
/// disturbing the active tab's URL/history entry.
renderTabBar();
scheduleSave();
}
}
/// 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) {
/// A Back/Forward navigation supersedes the page-load URL that `reconcileStartup` is still
/// reconciling in the background. Its bootstrap state (`current_url`, `url_query`, `url_tab_name`,
/// `run_immediately`, `url_color_modes`) was snapshotted once at load, so if this `popstate`
/// lands before reconciliation finishes its `loadFromDb`/`activateTab`/`updateQueryParams`
/// awaits, that older reconcile could still switch back to the original tab, re-apply the
/// original URL params/colors and fire the original deferred `run=1` on top of the entry the
/// user just navigated to. Mark the workspace dirty — exactly as tab switches, closes and
/// connection edits do — so every remaining `!bootstrap_dirty`-gated branch of reconciliation
/// (the dirty-startup merge, the URL parameter restore and the auto-run) yields to this
/// navigation instead of overwriting it. A genuine Back/Forward is a trusted event, so it is
/// not filtered as synthetic; once reconciliation has settled this is a no-op (`bootstrap_settled`).
markBootstrapDirty(event);
if (!event.state) return;
captureActiveTab();
/// `buildHistoryParams` serializes the `url`/`user` in effect when this entry was written
/// (omitted when they equal the defaults below) into the query string, not into `event.state`
/// itself. The browser has already updated `location.href` to this entry's URL by the time
/// `popstate` fires, so read the connection back from there and restore it — otherwise the
/// live connection inputs stay on whatever server/user was active before the Back/Forward,
/// and a Run launched from this "restored" entry would execute against the wrong target.
const popped_url = new URL(location.href);
const target_server = popped_url.searchParams.get('url')
|| (opened_locally ? 'http://localhost:8123/' : location.origin);
const target_user = popped_url.searchParams.get('user') || '';
if (url_elem.value !== target_server || user_elem.value !== target_user) {
url_elem.value = target_server;
user_elem.value = target_user;
/// The password that authenticated this entry's `target_server`/`target_user` is deliberately
/// never persisted to history, so it cannot be recovered here. Leaving the previous
/// connection's password attached to a different restored server/user would submit `B`'s
/// password against `A` on the next run; clear it and let the user re-enter it (the credential
/// refresh below then reports the real state for the restored connection).
password_elem.value = '';
/// A Back/Forward that lands on a different connection must replay the same side effects a
/// real connection edit performs, not merely reset the connection-scoped caches: re-ping the
/// restored host and refresh its `ok`/`fail` badge, recompute the Documentation link for the
/// new server, and clear the stale credential badge. Assigning `.value` above fires none of
/// the input listeners, so dispatch synthetic `input` events on the connection inputs — every
/// registered listener (`checkURL`, `updateDocsURL`, `resetCredentialsStatus` and
/// `resetConnectionScopedState`) then runs exactly as it would for a typed edit. These carry
/// `isTrusted === false`, so `markBootstrapDirty` ignores them; this navigation already marked
/// the workspace dirty above.
///
/// Guard the replay so `resetConnectionScopedState` skips its history restamp: the target tab
/// state is not installed yet (`activeTabId`/`tab.query` are rebound below) and `history.state`
/// is already the popped entry, so a restamp would corrupt this time-travel target with the
/// leaving tab's newer state. `onpopstate` restores the entry/URL authoritatively itself.
restoring_connection_from_history = true;
try {
[url_elem, user_elem, password_elem].forEach(el => el.dispatchEvent(new Event('input')));
} finally {
restoring_connection_from_history = false;
}
}
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;
/// Restore the connection-scoped selected database from the navigated URL — but ONLY when the
/// entry still belongs to the live connection. The connection (server address and user) was just
/// restored from this entry above, so a stamped entry normally passes this gate; the check still
/// matters as a correctness guard — it fails closed for an unstamped legacy entry (see below) and
/// for the edge where the entry's connection could not be reproduced — so we never import an
/// entry's `database=` into a connection it does not belong to (e.g. replaying a server-B entry's
/// `database=` against server A). Match on the entry's stamped server address and user
/// (`writeHistoryEntry`) — a durable identity that survives a reload and never collides across
/// connections, unlike the earlier process-local `connectionGeneration` counter (it reset to `0`
/// on reload, so afterwards it could both restore a different connection's database into the live
/// connection and stop restoring the live connection's own entries). The user is compared as an
/// EFFECTIVE user (`effectiveConnectionUser`) that folds in any userinfo embedded in the server
/// URL, so `http://alice@host:8123/` and `http://bob@host:8123/` with an empty user field are kept
/// distinct even though `sameServerAddress` sees the same endpoint; without this a Back/Forward
/// could restore one user's selection into another user's session on the same host. Server
/// addresses are compared via `sameServerAddress`, tolerating
/// cosmetic differences (a root-path trailing slash, a default port) but keeping the query string
/// significant, since it is part of the request target (a different cluster/namespace/settings).
/// The password is never part of the identity — it is never serialized and is unknowable after a
/// reload — so a same server/user entry restores its database even across a password change: the
/// selected database is a namespace on (server, user), and wrong credentials fail loud on the next
/// run rather than exposing another server's data.
/// `location` already reflects the navigated entry here (and after the restamp above); an absent
/// `database=` means the server default.
///
/// An entry with no stamp (`connectionServer` undefined — written before this feature) cannot be
/// proven to belong to the live connection, and it carries no `database=` of its own (it ran
/// against the server default of whatever connection was live then). Leaving the current, newer
/// `selected_database` in place would silently send the next `Run` to that database while the
/// displayed entry/URL describe a server-default query. Fail closed: drop the selection so
/// Back/Forward to such an entry targets the server default it actually represents.
if (event.state.connectionServer === undefined) {
if (selected_database !== null) {
selected_database = null;
stampSelectedDatabaseConnection();
applySelectedDatabaseHighlight();
}
} else if (sameServerAddress(event.state.connectionServer, url_elem.value)
&& effectiveConnectionUser(event.state.connectionServer, event.state.connectionUser)
=== effectiveConnectionUser(url_elem.value, user_elem.value)) {
selected_database = new URL(location.href).searchParams.get('database');
/// The gate above confirmed the entry belongs to the live connection, so record it as the
/// connection this restored selection belongs to (keeps `resetConnectionScopedState`'s later
/// identity comparison meaningful after a history navigation).
stampSelectedDatabaseConnection();
applySelectedDatabaseHighlight();
}
/// Does this entry carry a genuine result snapshot (modern nested or legacy flat)? An entry
/// written while its tab was mid-run does NOT: a run nulls `tab.result` at launch and only
/// reinstalls it on completion, and a background run that finishes in another tab reinstalls
/// the result only in memory — it never rewrites the null entry it left behind (leaving the
/// active tab's history/URL alone). So the entry a tab was switched away from mid-run holds
/// `result: null` even after that run has produced a result.
const entry_has_result = !!(event.state.result
|| event.state.data != null || (event.state.multi && event.state.multi.length));
/// Such a degenerate null entry must not be time-travelled over the tab's newer live state:
/// aborting a still-running query and clobbering the (possibly just-completed) result would
/// blank a tab that a normal switch would show streaming or finished. When the entry has no
/// snapshot but the tab is still running or already holds a result, navigate to it like a
/// plain tab switch — `activateTab` neither materializes an in-flight tab nor aborts it, so
/// the live stream keeps rendering into its own panel and a completed result stays shown. A
/// genuinely result-less entry whose tab also has no result falls through to the plain
/// time-travel below, and an entry that DOES carry a snapshot keeps the documented
/// abort-and-re-render behavior. (The connection-scoped `selected_database` was restored from
/// the navigated URL just above, regardless of which path we take here.)
///
/// The live state may only stand in for this null entry when it is provably the SAME run the
/// entry was written for. Every entry records the identity of the run it reflects (`run_id`, see
/// `run_epoch`), and the tab carries that identity as either the still-running launch
/// (`launchRunId`) or the completed in-memory snapshot (`tab.result.run_id`). A `run_id` match is
/// exact where the old query/params heuristic was not: a partial `Run selected` writes the full
/// editor text into the entry but only the selected statement into `tab.result.query`, and a
/// rerun of the identical text produces a NEW run — both of which a text comparison would
/// mis-handle (blanking a completed background partial run, or attaching a later rerun to an
/// older entry's URL). The match stays exact across reloads because every id carries the minting
/// page instance's `run_epoch`, so a post-reload launch can never reuse the identity of an entry
/// written before the reload — no matter which history entries survived, or whether the previous
/// instance's state ever reached `IndexedDB`. If the tab has since launched or
/// completed a DIFFERENT run — jump straight
/// back to an old mid-run entry (history menu / `history.go`) after the tab moved on — the ids no
/// longer match, so fall through to plain time-travel and render the entry itself.
///
/// Legacy entries persisted before `run_id` existed carry none; for them fall back to the
/// earlier launch-query / snapshot-query heuristic so old history still resolves.
const entry_run_id = event.state.run_id;
const entry_query = event.state.query;
const entry_params = JSON.stringify(event.state.params || {});
const live_is_entry_run = entry_run_id != null
? ((tab.inFlight && tab.launchRunId === entry_run_id)
|| (tab.result && tab.result.run_id === entry_run_id))
: ((tab.inFlight && tab.launchQuery === entry_query)
|| (tab.result && tab.result.query === entry_query
&& JSON.stringify(tab.result.params || {}) === entry_params));
if (!entry_has_result && live_is_entry_run) {
activateTab(tab.id).then(scheduleSave);
return;
}
/// 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. Normalize such a flat entry into a nested snapshot on `tab.result`
/// (which `materializeResult` renders) 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 || {},
database: flat.database,
format: flat.format,
ok: flat.ok,
data: flat.data ?? null,
multi: flat.multi ?? null,
color_modes: flat.color_modes || {},
pinned_columns: flat.pinned_columns || {},
elapsed_ns: flat.elapsed_ns,
};
} else {
tab.result = null;
}
/// Back/Forward is an explicit time-travel to this entry's snapshot: stop any query still
/// running in the target tab and force its result to be re-rendered from the entry (rather
/// than keeping whatever is live in its panel). Routing through `activateTab` reveals the
/// tab's panel, restores the editor + params, re-materializes the snapshot, and repaints the
/// shared chrome + page hue, exactly like a normal switch.
abortTabQuery(tab);
tab.materialized = false;
activateTab(tab.id).then(scheduleSave);
};
if (window.location.hash) {
query_area.value = fromBase64(window.location.hash.substr(1));
query_area.dispatchEvent(new Event('input'));
}
// 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; }
/// True when the ACTIVE tab has a query in flight (drives the Run/Stop button and Ctrl+Enter).
function activeInFlight() { const t = getActiveTab(); return !!(t && t.inFlight); }
/// True when ANY tab (active or background) is running, so the favicon shows "running" while a
/// background query is still going even after switching to an idle tab.
function anyInFlight() { return tabs.some(t => t.inFlight); }
function refreshFavicon() { setFavicon(anyInFlight() ? favicon_running : favicon_default); }
/// Mark a tab's query as started. The favicon and the tab-title spinner reflect it immediately;
/// the Run/Stop button and autocompletion only when the tab is active (background runs are shown
/// solely on their tab title). Called by `postOne`/`postAll` only AFTER their lexer preflight has
/// produced a runnable statement: entering flight is destructive (it drops the tab's previous
/// result below), so a launch that turns out to be a no-op (a whitespace-only selection) or dies
/// in the preflight (a tokenizer failure on a huge query) must never get here — the tab then keeps
/// its result and never shows a stuck "running" state. `launchInteractionGen` is the editor
/// interaction generation snapshotted when the run was LAUNCHED (before the preflight await), so
/// an edit made while the preflight ran already disarms the late server-error highlight whose
/// offsets describe the launch-time text.
function beginFlight(tab, launchInteractionGen) {
tab.inFlight = true;
tab.completed = false;
tab.progressPhase = 'running';
/// Drop any progress snapshot left by the previous run in this tab. Otherwise a rerun would
/// inherit the old fill (via `updateTabIndicator` below) until the first new `query-progress`
/// event arrives, and a quick switch-away/back before that event would replay the stale
/// "Read ..." numbers through `syncActiveTabChrome`.
tab.progress = null;
/// A run renders directly into this tab's live element, which is now authoritative: mark the
/// tab materialized so switching away and back does NOT re-render it from the stored snapshot
/// (that snapshot is lossy — images and results over 100 KB persist no data — and re-rendering
/// would wipe the live DOM, reset chart zoom/selection, and defeat the whole point of
/// persistent panels). Back/Forward still forces a re-render by resetting `materialized`.
tab.materialized = true;
/// The new run supersedes the tab's stored snapshot: the live panel is authoritative now, so
/// drop the previous result immediately. Otherwise a debounced `persist` / `refreshCurrentHistoryEntry`
/// (scheduled by the editor edit that preceded this run) would serialize the OLD snapshot under
/// the NEW query text, and a rerun that is cancelled or reloaded before `saveHistory` records a
/// fresh snapshot would reopen showing the previous result attached to the new query.
/// `saveHistory` installs the new snapshot on completion; persist the cleared state now so a
/// reload mid-run does not resurrect the stale one. Clear the on-screen result at the same time
/// so the panel and the snapshot can never disagree: there is no await between here and the
/// `clearPanel` in `postSingle`/`postMulti` (the preflight ran BEFORE flight was entered), so
/// they just re-clear an already-empty panel, and a cancel from this point on leaves the
/// consistent "cancelled before the first row" state.
tab.result = null;
clearPanel(tab);
scheduleSave();
updateTabIndicator(tab);
refreshFavicon();
/// Reveal the tab bar (with its `[+]`) the moment a run starts on an otherwise-hidden bar, so
/// the first query on a fresh page can be backgrounded. Only rebuilds when hidden (a no-op cost
/// once the bar is already shown), and in that case there is a single tab, so no background
/// tab's spinner animation is reset by the rebuild.
if (tab_bar_elem.hidden) renderTabBar();
/// New run: no statement of it has claimed the editor's error marker yet, scoping the
/// arbitration in `highlightQueryError` to this run. Both error-marker fields live on the
/// TAB, and are (re)set for a background launch too: runs execute concurrently across tabs,
/// and as globals whichever run began last would overwrite the others' arbitration state,
/// letting a background tab's late error claim — or suppress — the foreground editor's marker.
tab.queryErrorOwnerStart = -1;
/// Install the launch-time editor-interaction snapshot. A late error response is only allowed
/// to paint a highlight if the user has not returned to the editor since this run was launched
/// (a tab switch bumps the generation too, via the synthetic events of the editor restore).
/// The keystroke or click that triggered the run has already bumped the generation (the
/// textarea's own handlers run before the document-level run trigger), so it is included in
/// the snapshot; an edit made during the preflight await bumps past it and disarms the paint.
tab.runEditorInteraction = launchInteractionGen;
if (tab.id === activeTabId) {
document.getElementById('run').innerText = 'Stop';
document.getElementById('run-all').style.display = 'none';
/// Hide any open autocompletion when a query starts running.
closeCompletions();
/// Drop any previous syntax-error highlight; a fresh error (if any) will be set on failure.
clearQueryError();
}
}
function endFlight(tab) {
tab.inFlight = false;
updateTabIndicator(tab);
refreshFavicon();
/// A run that ended without leaving a result (e.g. cancelled before the first row) on a lone
/// tab should let the bar collapse back to its pristine hidden state. Re-render only when that
/// hide actually applies, so the common case (bar stays visible) does not rebuild on every run.
if (!tab_bar_elem.hidden && tabs.length <= 1 && !anyInFlight()
&& !(getActiveTab() && getActiveTab().result && getActiveTab().result.ran))
{
renderTabBar();
}
if (tab.id === activeTabId) {
document.getElementById('run').blur();
updateRunButtons(isMultiQuery);
}
}
/// Run the query under cursor, or all queries intersecting the selection, in the ACTIVE tab.
async function postOne()
{
/// The run belongs to the tab active at launch; it keeps running (in the background) even if
/// the user switches away. The lexer preflight (`splitAllQueries` / `getQueryUnderCursor`)
/// awaits the WASM lexer, and a tab switch during that await would swap `query_area` to another
/// tab's content — so snapshot the editor text + selection BEFORE the await and parse the
/// snapshot, rather than dropping a launched run just because the active tab changed. Only a
/// newer run/cancel in the SAME tab (which bumps `tab.reqNum`, tracked by `my_run`) supersedes
/// it. Detect an in-flight parameter restore (the token stays non-null until a restore
/// commits) so the run sources parameters from this tab's saved snapshot, not the previous
/// tab's stale DOM inputs.
const tab = getActiveTab();
if (!tab) return;
const params_restore_pending = params_restore_pending_token !== null;
/// Snapshot this run's parameter VALUES now, from the launching tab, before any await below:
/// re-reading `param_*` / `getActiveTab` after an await would let a tab switch during it bind
/// the run to another tab's parameters (a correctness bug for parameterized queries). The
/// parameter NAMES are bound later, to the run's own tokenization of the launched text (see
/// `extractRunParamNames`): the launch-time controls can still describe an OLDER edit while
/// the keystroke-driven `updateQueryParams` rebuild is in flight.
const param_values_at_launch = resolveRunParams(params_restore_pending, tab);
/// Every value known for the launching tab: the live inputs over the tab's own saved params
/// (the latter cover names whose inputs have not been (re)built yet, e.g. a placeholder that
/// reappeared in the editor moments before the run).
const param_sources = Object.assign({}, tab.params || {}, param_values_at_launch);
/// Snapshot the connection context (server / user / password) at launch too, for the same
/// reason: these inputs are shared, and re-reading them after the preflight await (as
/// `postImpl` used to) would let a tab switch + connection edit during it send this run's
/// snapshotted query to the newly entered server or identity.
const connection = { url: url_elem.value, user: user_elem.value, password: password_elem.value };
/// Snapshot the selected database at launch too (see the connection above): the panel selection
/// is shared, so re-reading it after the preflight await would let a mid-await database change
/// retarget this run. It is threaded through to `postImpl` (the request URL) and the saved
/// result snapshot so a reload/download reproduces the run against the same database.
const query_database = selected_database;
/// Bump `activation_num` so a concurrent, still-awaiting `activateTab` restore bails instead of
/// overwriting the editor after this run starts; the run itself is no longer gated on it.
++activation_num;
const my_run = ++tab.reqNum;
/// Snapshot the editor-interaction generation at launch: `beginFlight` (deferred until the
/// preflight below has found something to run) installs it into the tab's `runEditorInteraction`.
const launch_interaction = editorInteractionGen;
document.body.scrollTo(0, 0);
/// Snapshot the shared editor before any await (see above). The launch text is also recorded
/// on the tab so `saveHistory` can tell, at completion, whether the tab's draft has since
/// moved on (an edit made while the query ran must not be clobbered by the launch text).
const editor_text = query_area.value;
tab.launchQuery = editor_text;
/// Fresh run identity for this launch (see `run_epoch`): stamped onto the mid-run history entry
/// and the completed snapshot so a later Back/Forward can match THIS run exactly.
tab.launchRunId = nextRunId();
const sel_start = query_area.selectionStart;
const sel_end = query_area.selectionEnd;
const has_selection = sel_start !== sel_end;
/// Bind the parameter names to the RUN's text. `currentQueryParams` may still describe the
/// text as it was BEFORE the latest edit (its rebuild by `updateQueryParams` is async), so
/// freezing it at launch would, for a quick `SELECT {x}` -> `SELECT {y}` edit-and-run, send
/// `param_x` and lose `param_y`. Deriving the names from the launched text itself keeps the
/// executed request and its recorded params consistent with the query that actually ran; when
/// the list cannot be derived (no lexer / text too large) fall back to every value known for
/// the launching tab (`param_sources` = live inputs over the tab's saved `params`), NOT to the
/// live inputs alone: restoring a saved parameterized tab whose query the lexer cannot tokenize
/// clears the `param_*` DOM while the authoritative bindings live only in `tab.params`, and
/// sourcing the empty inputs would drop them entirely.
const run_params = await extractRunParamNames(editor_text);
if (my_run !== tab.reqNum) return;
const paramValuesSnapshot = run_params === null
? param_sources
: pickRunParams(run_params, param_sources);
/// Entering flight (which drops the tab's previous result — see `beginFlight`) is deferred
/// until the preflight has produced a statement to run: a no-op launch and a preflight
/// failure (a tokenizer exception on a huge query) leave the tab and its result untouched.
///
/// Multi-query behavior is decided from the LAUNCHED text (`splitAllQueries` /
/// `getQueryUnderCursor(...).isMulti` on the snapshot), never from the global `isMultiQuery`
/// flag: that flag tracks whatever the shared editor currently shows, and a tab switch during
/// the preflight awaits rewrites it for the newly shown tab — deciding from it would let a
/// switch to a single-statement tab downgrade this run to "single mode", recording only the
/// statement under the cursor as the launching tab's full draft (which `saveHistory` would
/// then install over the real multi-statement draft).
let launched = false;
if (has_selection) {
const parsed = await splitAllQueries(editor_text);
if (my_run !== tab.reqNum) return;
/// A selection in a single-statement editor runs like a plain cursor launch below.
if (parsed.length > 1) {
/// 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 > sel_start && parsed[i].start < sel_end) {
selected.push(parsed[i]);
}
}
/// A whitespace/comment-only selection intersects nothing runnable: the launch is a no-op.
if (selected.length === 0) return;
beginFlight(tab, launch_interaction);
if (selected.length > 1) {
/// The run covered the whole editor only when the selection spanned EVERY
/// statement; only such an entry may keep `run=1` (see `saveHistory`).
await postMulti(tab, my_run, selected, paramValuesSnapshot, editor_text, connection,
/*ranFullEditor=*/ selected.length === parsed.length, query_database);
} else {
await postSingle(tab, my_run, selected[0].query, editor_text, paramValuesSnapshot, selected[0].queryStart, connection, query_database);
}
launched = true;
}
}
if (!launched) {
const under = await getQueryUnderCursor(editor_text, sel_start);
if (my_run !== tab.reqNum) return;
beginFlight(tab, launch_interaction);
/// Highlight and focus the chosen query only when this tab is still active: a background
/// run (the user switched away during the preflight) must not move the foreground editor's
/// selection or steal focus.
if (tab.id === activeTabId) {
if (under.selStart !== null) query_area.setSelectionRange(under.selStart, under.selEnd);
focusEditorForRun();
}
/// When the launched text held several statements, preserve the full textarea content in
/// history/URL even though only the query under the cursor ran (so refresh restores all
/// queries).
await postSingle(tab, my_run, under.query, under.isMulti ? editor_text : undefined, paramValuesSnapshot, under.queryStart, connection, query_database);
}
/// Reset the run/favicon state only if this run still owns its tab's in-flight state; a newer
/// run or cancel in the same tab (which bumped `tab.reqNum`) has taken over otherwise.
if (my_run === tab.reqNum) endFlight(tab);
}
/// Run all queries from the textarea in the ACTIVE tab.
async function postAll()
{
const tab = getActiveTab();
if (!tab) return;
/// See `postOne`: snapshot the editor before the lexer await, keep the launched run alive on a
/// tab switch, and supersede only on a newer run/cancel in the same tab (`my_run`/`tab.reqNum`).
const params_restore_pending = params_restore_pending_token !== null;
/// See `postOne`: parameter values are snapshotted from the launching tab before any await
/// (a tab switch during one must not rebind the run); the names are bound to the run's own
/// text below.
const param_values_at_launch = resolveRunParams(params_restore_pending, tab);
const param_sources = Object.assign({}, tab.params || {}, param_values_at_launch);
/// See `postOne`: the connection context and selected database are snapshotted at launch so a tab
/// switch + connection/database edit during the preflight await cannot reroute this run.
const connection = { url: url_elem.value, user: user_elem.value, password: password_elem.value };
const query_database = selected_database;
++activation_num;
const my_run = ++tab.reqNum;
const launch_interaction = editorInteractionGen; /// see `postOne`
document.body.scrollTo(0, 0);
const editor_text = query_area.value;
tab.launchQuery = editor_text; /// see `postOne`: lets `saveHistory` detect a newer draft
tab.launchRunId = nextRunId(); /// see `postOne`: fresh run identity for this launch
const sel_start = query_area.selectionStart;
/// See `postOne`: bind the parameter names to the run's own text, and on a no-lexer /
/// too-large fallback send every value known for the launching tab (`param_sources`, which
/// includes `tab.params`) rather than the possibly-empty live inputs.
const run_params = await extractRunParamNames(editor_text);
if (my_run !== tab.reqNum) return;
const paramValuesSnapshot = run_params === null
? param_sources
: pickRunParams(run_params, param_sources);
/// See `postOne`: flight is entered only once the preflight has produced a statement to run.
const parsed = await splitAllQueries(editor_text);
if (my_run !== tab.reqNum) return;
if (parsed.length > 1) {
beginFlight(tab, launch_interaction);
await postMulti(tab, my_run, parsed, paramValuesSnapshot, editor_text, connection, /*ranFullEditor=*/ true, query_database);
} else {
const under = await getQueryUnderCursor(editor_text, sel_start);
if (my_run !== tab.reqNum) return;
beginFlight(tab, launch_interaction);
if (tab.id === activeTabId) {
if (under.selStart !== null) query_area.setSelectionRange(under.selStart, under.selEnd);
focusEditorForRun();
}
await postSingle(tab, my_run, under.query, undefined, paramValuesSnapshot, under.queryStart, connection, query_database);
}
if (my_run === tab.reqNum) endFlight(tab);
}
/// Stop the ACTIVE tab's query (the Run→Stop button / Ctrl+Enter while running).
async function cancel()
{
const tab = getActiveTab();
if (!tab) return;
/// Bump the tab's run generation and abort its requests so postImpl/postSingle/postMulti
/// see a stale run token and bail out before saving history.
abortTabQuery(tab);
endFlight(tab);
/// The query was stopped, not completed: drop the 'running' progress state and its live
/// snapshot, so reopening this tab does not replay a spinning hourglass / stale "still
/// running" bar via `syncActiveTabChrome`. Cancel always targets the active tab, so also
/// clear the shared progress widget right away (it still shows the running spinner).
tab.progressPhase = 'idle';
tab.progress = null;
progressEl.clear();
}
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 (activeInFlight()) {
cancel();
} else {
postOne();
storeCredentials();
}
});
/// The "Run all" button.
document.getElementById('run-all').addEventListener('click', e =>
{
e.preventDefault();
markBootstrapDirty(e);
if (!activeInFlight()) {
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 (activeInFlight()) {
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;
}
};
/// Clear the ACTIVE tab's result pane (its own element + multi container) and the shared progress
/// bar / logo. Used by the connection-check path to blank the pane before rendering an error.
/// Background tabs are untouched (see `clearPanel`).
function clear()
{
const tab = getActiveTab();
if (tab) clearPanel(tab);
}
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/AX8DCQgCAQEDBAAAAAUDAQACBk8MfwFBgIAEC38AQYCABAt/AEGAgAQLfwBBhIAEC38AQQALfwBBgIAEC38AQYCABAt/AEGQgAQLfwBBgIAIC38AQQALfwBBAQt/AEGAgAQLB7oDFQZtZW1vcnkCABFfX3dhc21fY2FsbF9jdG9ycwAAD19fc3RhY2tfcG9pbnRlcgMAGF9aTjJEQjVMZXhlcjluZXh0VG9rZW5FdgABHV9aTjJEQjVMZXhlcjEzbmV4dFRva2VuSW1wbEV2AAIWZGF0YXN0b3JlX2xleGVyX2NyZWF0ZQADGmRhdGFzdG9yZV9sZXhlcl9uZXh0X3Rva2VuAAQkZGF0YXN0b3JlX2xleGVyX3Rva2VuX2lzX3NpZ25pZmljYW50AAUeZGF0YXN0b3JlX2xleGVyX3Rva2VuX2lzX2Vycm9yAAYcZGF0YXN0b3JlX2xleGVyX3Rva2VuX2lzX2VuZAAHFGRhdGFzdG9yZV9sZXhlcl9zaXplAwEMX19kc29faGFuZGxlAwIKX19kYXRhX2VuZAMDC19fc3RhY2tfbG93AwQMX19zdGFja19oaWdoAwUNX19nbG9iYWxfYmFzZQMGC19faGVhcF9iYXNlAwcKX19oZWFwX2VuZAMIDV9fbWVtb3J5X2Jhc2UDCQxfX3RhYmxlX2Jhc2UDChVfX3dhc21fZmlyc3RfcGFnZV9lbmQDCwwBAQqTLggCAAtUAQJ/IAAgARACAkACQAJAIAEoAgAiAkUNACABKAIMIgNFDQAgACgCCCACIANqTQ0AQTAhAiAAQTA6AAAMAQsgAC0AACICQQJJDQELIAEgAjoAEAsLsywBCn8gASgCBCIEIAEoAggiA08EQCAAIAM2AgggACADNgIEIABBJzoAAA8LIAFBBGohBgJAAkACQAJAAkACQAJAAkACQAJAAkACQAJ/AkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAELQAAIgpBIGsOXgEWBBMgFCUDBgcRDwwQDhICAgICAgICAgICGw0XFRgZHSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlCB4JGiUFJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUKHAsACyAKQQlrQQVJDQAgCkHiAUcNJCAEQQNqIgEgA0sNHiAELQABQYgBRw0eIAQtAAJBkgFHDR4gACABNgIIIAAgBDYCBCAAQRQ6AAAMLQsgBiAEQQFqIgE2AgACQCABIANPDQADQCABLQAAIgJBCWtBBU8gAkEgR3ENASAGIAFBAWoiATYCACABIANHDQALIAMhAQsMLQsgAS0AEEEPRgRAIAYgBEEBaiIBNgIAIAEgA08NKQNAIAEtAAAiAkEwa0H/AXFBCk8EQCACQd8ARw0rIAFBAWoiAiADTw0rIAItAABBMGtB/wFxQQlLDSsLIAYgAUEBaiIBNgIAIAEgA0cNAAsgAyEBDCkLIApBMEcgBEECaiIBIANPcg0lIARBAWohAgJAIAQtAAEiBUHhAE0EQCAFQcIARg0gIAVB2ABGDQEMJwsgBUHiAEYNHyAFQfgARw0mC0EBIQUgAS0AACIJQTBrQf8BcUEKSQ0kQQAgCUHBAGsiCUH/AXFBJk8NJhpCv4CAgPAHIAmtiKdBAXFFDScMJAsgBiAGKAIAQQFqIgI2AgACQANAAkAgAiADRg0AA0AgAi0AACIBQSdGIAFB3ABGcg0BIAJBAWoiAiADRw0ACyADIQILIAYgAjYCAEEqIQUCQCACIANPDQACQCACLQAAIgFB3ABHBEAgAUEnRw0DIAYgAkEBaiIBNgIAQQQhBSABIANPDQQgAS0AAEEnRg0BDAQLIAYgAkEBaiIBNgIAIAEgA08NAQsgBiACQQJqIgI2AgAMAQsLIAMhAQsgACABNgIIIAAgBDYCBCAAIAU6AAAPCyAGIAYoAgBBAWoiAjYCAAJAA0ACQCACIANGDQADQCACLQAAIgFBIkYgAUHcAEZyDQEgAkEBaiICIANHDQALIAMhAgsgBiACNgIAQSshBQJAIAIgA08NAAJAIAItAAAiAUHcAEcEQCABQSJHDQMgBiACQQFqIgE2AgBBBSEFIAEgA08NBCABLQAAQSJGDQEMBAsgBiACQQFqIgE2AgAgASADTw0BCyAGIAJBAmoiAjYCAAwBCwsgAyEBCyAAIAE2AgggACAENgIEIAAgBToAAA8LIAYgBigCAEEBaiICNgIAAkADQAJAIAIgA0YNAANAAkAgAi0AAEHcAGsOBQIAAAACAAsgAkEBaiICIANHDQALIAMhAgsgBiACNgIAQSwhBQJAIAIgA08NAAJAAkACQCACLQAAQdwAaw4FAQQEBAAECyAGIAJBAWoiATYCAEEFIQUgASADTw0EIAEtAABB4ABGDQEMBAsgBiACQQFqIgE2AgAgASADTw0BCyAGIAJBAmoiAjYCAAwBCwsgAyEBCyAAIAE2AgggACAENgIEIAAgBToAAA8LIAAgBDYCBCAAQQY6AAAMKQsgACAENgIEIABBBzoAAAwoCyAAIAQ2AgQgAEEIOgAADCcLIAAgBDYCBCAAQQk6AAAMJgsgACAENgIEIABBCjoAAAwlCyAAIAQ2AgQgAEELOgAADCQLIAAgBDYCBCAAQQw6AAAMIwsgACAENgIEIABBDToAAAwiCwJAIAQgASgCAE0NAAJAIARBAWoiAiADTw0AIAItAABBMGtB/wFxQQlLDQAgAS0AECIBQQlLQQEgAXRBrAVxRXINAQsgACACNgIIIAAgBDYCBCAAQQ86AAAgBiACNgIADwsgBiAEQQFqIgI2AgAgBCEFIAIgA08NFyAEQQJqIQFBASEFA0AgAUEBay0AACICQTBrQf8BcUEKTwRAIAUgAkHfAEdyQQFxIAEgA09yDRUgAS0AAEEwa0H/AXFBCUsNFQsgBiABNgIAIAEgA0ZBACEFIAFBAWohAUUNAAsgAUECayEFIAMhAgwXCyAAIAQ2AgQgAEETOgAADCALIAYgBEEBaiICNgIAAkAgAiADTw0AIAItAAAiAUEtRwRAIAFBPkcNASAAIAQ2AgQgAEEXOgAADCILDBwLIAAgAjYCCCAAIAQ2AgQgAEEUOgAADwsgACAENgIEIABBEDoAAAweCyAGIARBAWoiAjYCAAJAIAIgA08NACACLQAAIgFBKkcEQCABQS9HDQEMGwsgBiAEQQJqIgE2AgAgAyAEQQRqIgJPBEBBASEIA0ACQAJAAkACQCABLQAAIgVBKkcEQCAFQS9HDQMgAS0AAUEqRw0DIAYgAjYCACAIQQFqIQgMAQsgAS0AAUEvRw0CIAYgAjYCACAIQQFrIghFDQELIAIhAQwCCyAAIAI2AgggACAENgIEIABBAToAAA8LIAYgAUEBaiIBNgIACyABQQJqIgIgA00NAAsLIAAgAzYCCCAAIAQ2AgQgAEEpOgAAIAYgAzYCAA8LIAAgAjYCCCAAIAQ2AgQgAEEVOgAADwsgBiAEQQFqIgE2AgACQCABIANPDQAgAS0AAEH+AXFBIEcNAAJAA0AgAS0AAEEKRg0BIAFBAWoiASADRw0ACyADIQELDBoLDB4LIAAgBDYCBCAAQRY6AAAMGwsgBiAEQQFqIgE2AgACQCABIANPDQAgAS0AAEE9Rw0AIAYgBEECaiIBNgIACyAAIAE2AgggACAENgIEIABBHDoAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBPUcNACAAIAQ2AgQgAEEdOgAADBsLIAAgATYCCCAAIAQ2AgQgAEEtOgAADwsgBiAEQQFqIgI2AgACQCAEQQJqIgEgA08NACACLQAAQT1HDQAgAS0AAEE+Rw0AIAAgBDYCBCAAQSI6AAAgACAEQQNqIgA2AggMHAsCQCACIANPDQACQAJAIAItAABBPWsOAgABAgsgACABNgIIIAAgBDYCBCAAQSA6AAAMGAsgACABNgIIIAAgBDYCBCAAQR06AAAMFwsgACACNgIIIAAgBDYCBCAAQR46AAAPCyAGIARBAWoiATYCAAJAIAEgA08NACABLQAAQT1HDQAgACAENgIEIABBIToAAAwZCyAAIAE2AgggACAENgIEIABBHzoAAA8LIAAgBDYCBCAAQRg6AAAMFgsgACAENgIEIABBGjoAAAwVCyAGIARBAWoiATYCAAJAIAEgA08NACABLQAAQTpHDQAgACAENgIEIABBGzoAAAwWCyAAIAE2AgggACAENgIEIABBGToAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABB/ABHDQAgACAENgIEIABBJDoAAAwVCyAAIAE2AgggACAENgIEIABBIzoAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBwABHDQAgACAENgIEIABBJjoAAAwUCyAAIAE2AgggACAENgIEIABBJToAAA8LIAYgBEEBaiIBNgIAAkAgASADTw0AIAEtAABBxwBHDQAgACAENgIEIABBDjoAAAwTCwwTCyAEQQVqIANPDQUgBC0AAUGAAUcNBQJAIAQtAAIiB0GYAWsOBQAGBgYABgsgBiABNgIAIAAhAkEEQQUgB0GYAUYiABshAUEqQSsgABshACAGKAIAIQUgB0EBasBB/wFxIQkDQAJAAkAgAyAFRg0AA0AgBS0AAEHiAUYNASAFQQFqIgUgA0cNAAsgAyEFCyAGIAU2AgACQAJAIAMgBUECaiIHTQRAIAAhAQwBCyAFLQAAQeIBRw0BIAUtAAFBgAFHDQEgBy0AACAJRw0BIAYgBUEDaiIDNgIACyACIAM2AgggAiAENgIEIAIgAToAAAwBCyAGIAVBAWoiBTYCAAwBCwsPCyAEQQFqIgchBQJAIAMgB0YNAANAIAUtAABBJEYNASAFQQFqIgUgA0cNAAsgAyEFCyADIAVGDQMgBUEBaiICIARrIQsgByEBA0AgASAFSQRAIAEtAAAiCEEwayABQQFqIQFB/wFxQQpJIAhB3wFxQcEAa0H/AXFBGklyIAhB3wBGcg0BDAULCyALIAMgAmsiCUsNA0EAIQggAiEFA0BBACEBAkAgAiAERg0AA0AgASAFai0AACABIARqLQAARw0BIAsgAUEBaiIBRw0ACwwECyABIAtGDQMgBUEBaiEFIAhBAWoiCCALaiAJTQ0ACwwDC0EAIQUgAS0AAEH+AXFBMEYNBQwICyABQQFrIQIgAUECayEFDAMLIAhBf0YNACAAIAQ2AgQgAEEROgAAIAAgAiAIaiALaiIANgIIDA8LIAMgB0sEQEEBIQUgBy0AACIBQd8ARiABQTprQf8BcUH1AUtyIAFB3wFxQdsAa0H/AXFB5QFLcg0BCyAAIAc2AgggACAENgIEIABBEjoAACAGIAc2AgAPCwJAIARBAmogA08NACAELQABQSdHDQACQCAKQeEATQRAIApBwgBGIApB2ABGcg0BDAILIApB+ABGDQAgCkHiAEcNAQsgACEBIAYoAgAiAC0AACECIAYgAEECaiIANgIAAkAgAkEgckH4AEYEQCAAIANPDQEDQCAALQAAIgJBMGtB/wFxQQpJIAJB4QBrQQZJckUgAkHBAGtBBUtxDQIgBiAAQQFqIgA2AgAgACADRw0ACyADIQAMAQsCQCAAIANGDQADQCAALQAAQf4BcUEwRw0BIABBAWoiACADRw0ACyADIQALIAYgADYCAAtBKiECIAAgA0kEQCAAQQFqIAMgAC0AAEEnRiIAGyEDQQRBKiAAGyECCyABIAM2AgggASAENgIEIAEgAjoAACAGIAM2AgAPCyAKQTBrQf8BcUEKSSAKQd8BcUHBAGtB/wFxQRpJciAKQd8ARnIgBXJBAUYEQCAEQQFqIQUDQAJAIAYgBSIBNgIAIAEgA08NACABQQFqIQUgAS0AACICQd8ARiACQSRGQQF0ciACQd8BcUHBAGtB/wFxQRpJQQN0IAJBMGtB/wFxQQpJQQJ0cnINAQsLDBALAkAgAyAEIgFNDQADQAJ/QQEgAS0AACICQQlrQQVJDQAaQQEgAkEgRg0AGiACQcIBRyABQQFqIgcgA09yRQRAQQIgBy0AACICQYUBRiACQaABRnINARoMAwsgAUECaiIFIANPDQICQAJAAkACQAJAIAJB4QFrDgMBAgMACyACQe8BRw0GIActAABBuwFHDQYgBS0AAEG/AUYNAwwGCyAHLQAAQaABRw0FIAUtAABBjgFGDQIMBQsCQAJAIActAABBgAFrDgIAAQYLQQMgBSwAACICQYt/SCACQX5xQah/RnINAxpBAyACQf8BcSICQYsBa0EDSSACQa8BRnINAxoMBQsgBS0AAEHhAGpB/wFxQQJJDQEMBAsgBy0AAEGAAUcNAyAFLQAAQYABRw0DC0EDCyABaiIBIANJDQALCyAGIAE2AgAgASAETQRAIAFBAWohAgNAAkAgBiACIgE2AgAgASADTw0AIAFBAWohAiABLAAAQUBIDQELCwwNCwwJCwJAIAVBAmoiASADTw0AIAItAABBIHJB5QBHDQAgBiABNgIAAkAgBUEDaiICIANPDQACQCABLQAAQStrDgMAAQABCyAGIAI2AgAgAiEBCwJAIAEgA08NAEEBIQIDQCABLQAAIgVBMGtB/wFxQQpPBEAgAiAFQd8AR3JBAXENAiABQQFqIgIgA08NAiACLQAAQTBrQf8BcUEJSw0CCyAGIAFBAWoiATYCAEEAIQIgASADRw0ACyADIQIMAQsgASECCyAAIAI2AggMDQtBASEIIAEhAiAFIQcMAgsgBEEBaiECQQALIQcLIAYgAjYCAAJAAkAgAiADTw0AA0AgAi0AACIFQTBrIQECQAJAIAcEQCABQf8BcUEKSSAFQcEAa0EGSXINAiAFQeEAa0EGTw0BDAILIAFB/wFxQQpJDQELIAggBUHfAEdyQQFxRQRAAkACQCACQQFqIgkgA08NACAJLQAAIgVBMGshASAHRQ0BIAFB/wFxQQpJIAVBwQBrQQZJcg0AIAVB4QBrQQZPDQYLIAMgCUcNAgwECyABQf8BcUEKSQ0BDAQLIAVBLkcNAiAGIAJBAWoiAjYCACACIANPDQJBASEIA0AgAi0AACIFQTBrIQECQAJAIAcEQCABQf8BcUEKSSAFQcEAa0EGSXINAiAFQeEAa0EGTw0BDAILIAFB/wFxQQpJDQELIAggBUHfAEdyQQFxDQQCQCACQQFqIgkgA08NACAJLQAAIgVBMGshASAHBEAgAUH/AXFBCkkgBUHBAGtBBklyDQEgBUHhAGtBBk8NBwwBCyABQf8BcUEJSw0GCyADIAlGDQQLIAYgAkEBaiICNgIAQQAhCCACIANJDQALDAILIAYgAkEBaiICNgIAQQAhCCACIANHDQALCyACQQFqIgEgA08NACACLQAAIQUCQCAHBEAgBUEgckHwAEcNAgwBCyAFQSByQeUARw0BCyAGIAE2AgACQCACQQJqIANPDQACQCABLQAAQStrDgMAAQABCyAGIAFBAWoiATYCACABIANPDQILQQEhAgNAIAEtAAAiBUEwa0H/AXFBCk8EQCACIAVB3wBHckEBcQ0DIAFBAWoiAiADTw0DIAItAABBMGtB/wFxQQlLDQMLIAYgAUEBaiIBNgIAQQAhAiABIANHDQALDAELIAIhAQsCQAJAIAEgA08NACABLQAAIgJB3wBGIAJBMGtB/wFxQQpJckUEQCACQd8BcUHBAGtB/wFxQRlLDQELIAFBAWohBQNAIAYgBSIBNgIAIAEgA08NAiABQQFqIQUgAS0AACIHQTBrIQIgB0HfAEYgAkH/AXFBCklyIAdB3wFxQcEAa0H/AXFBGklyDQALDAELIAAgATYCCAwJCyABQQFrIQMCQCABIARNDQAgBEEBayEFA0AgBUEBaiIFLQAAIgJB3wBGIAJBJEZBAXRyIAJB3wFxQcEAa0H/AXFBGklBA3QgAkEwa0H/AXFBCklBAnRycgRAIAMgBUsNAQwCCwsgACABNgIIIAAgBDYCBCAAQS86AAAPCwwJCyAGIARBAmoiATYCAAJAIAEgA0YNAANAIAEtAABBCkYNASABQQFqIgEgA0cNAAsgAyEBCwsgACABNgIIIAAgBDYCBCAAQQE6AAALIAYgATYCAA8LIAAgATYCCCAAIAQ2AgQgAEEAOgAADwsgACAEQQFqIgA2AggMAgsgACAEQQJqIgA2AggMAQsgACABNgIIIAAgBDYCBCAAQSg6AAAPCyAGIAA2AgAPCyAAIAQ2AgQgAEEDOgAADwsgACABNgIIIAAgBDYCBCAAQQI6AAALNQAgAEEAOgAQIAAgAjYCCCAAIAE2AgQgACABNgIAIABBgJTr3AMgAyADQYCU69wDTxs2AgwLNgEBfyMAQRBrIgMkACADQQRqIAAQASABIAMoAgg2AgAgAiADKAIMNgIAIAMtAAQgA0EQaiQACwcAIABBAUsLBwAgAEEnSwsHACAAQSdGCwsJAQBBgIAECwEUAD8JcHJvZHVjZXJzAQxwcm9jZXNzZWQtYnkBC0FwcGxlIGNsYW5nGTE3LjAuMCAoY2xhbmctMTcwMC42LjMuMikAlAEPdGFyZ2V0X2ZlYXR1cmVzCCsPbXV0YWJsZS1nbG9iYWxzKxNub250cmFwcGluZy1mcHRvaW50KwtidWxrLW1lbW9yeSsIc2lnbi1leHQrD3JlZmVyZW5jZS10eXBlcysKbXVsdGl2YWx1ZSsPYnVsay1tZW1vcnktb3B0KxZjYWxsLWluZGlyZWN0LW92ZXJsb25n";
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.datastore_lexer_size);
memory_offset += exports.datastore_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.datastore_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.datastore_lexer_next_token(lexer, token_begin, token_end);
if (exports.datastore_lexer_token_is_error(token_type) || exports.datastore_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.datastore_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.
/// Pure w.r.t. the DOM: it takes a snapshot of the editor (`all_queries` + `cursor_position`,
/// captured by the caller before any await) and RETURNS the query to run plus the editor range to
/// highlight and the query's start offset (`{ query, selStart, selEnd, queryStart, isMulti }`).
/// `isMulti` reports whether the SNAPSHOTTED text contains more than one statement, so the caller
/// can decide multi-query behavior (e.g. what to record as the tab's full draft) from the launched
/// text itself rather than from the global `isMultiQuery` flag, which a tab switch during the
/// caller's awaits rewrites to describe another tab's content. `selStart` is
/// null when nothing should be selected (single query, or the WebAssembly-less fallback). `queryStart`
/// is the absolute character offset of the query in the editor, threaded into `highlightQueryError`
/// so a server-reported error position maps back to the statement that actually ran, even when the
/// same query text appears more than once. The caller applies the selection/focus, and only when its
/// tab is still active — so a background run cannot move the foreground editor.
async function getQueryUnderCursor(all_queries, cursor_position) {
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') {
return { query: all_queries, selStart: null, selEnd: null, queryStart: 0, isMulti: false };
}
const tokens = await tokenize(all_queries);
const should_select_query = getQueryBoundaries(tokens, all_queries).length > 1;
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) {
return {
query: all_queries.substring(current_query_start, current_offset),
selStart: should_select_query ? current_query_start : null,
selEnd: should_select_query ? current_offset : null,
queryStart: current_query_start,
isMulti: should_select_query,
};
}
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) {
return {
query: all_queries.substring(last_query_start, last_query_end),
selStart: should_select_query ? last_query_start : null,
selEnd: should_select_query ? last_query_end : null,
queryStart: last_query_start,
isMulti: should_select_query,
};
}
/// Cursor is in the last query (no trailing semicolon).
return {
query: all_queries.substring(current_query_start),
selStart: should_select_query ? current_query_start : null,
selEnd: should_select_query ? all_queries.length : null,
queryStart: current_query_start,
isMulti: should_select_query,
};
}
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, queryStart: 0 }];
}
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 raw = text.substring(current_query_start, current_offset);
const q = raw.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,
/// Exact character offset where the trimmed query text begins in `text`,
/// so a server-reported error position can be mapped to this statement even
/// when the same text appears more than once (e.g. `SELECT 1; SELECT 1`).
queryStart: current_query_start + (raw.length - raw.trimStart().length),
});
}
}
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 raw = text.substring(current_query_start);
const last = raw.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,
queryStart: current_query_start + (raw.length - raw.trimStart().length),
});
}
}
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 (!activeInFlight()) {
if (!multi) {
runBtn.innerText = 'Run';
} else {
runBtn.innerText = has_selection ? 'Run selected' : 'Run one';
}
}
runAllBtn.style.display = (multi && !selects_all) ? 'inline-block' : 'none';
document.getElementById('hint-run-all').style.display = multi ? '' : 'none';
}
/// Update the backdrop div behind the textarea: paint per-query active backgrounds
/// and per-token syntax-highlighting colors. The textarea above is in
/// `mix-blend-mode: difference`, so the colors here mix with the user's text.
const queryBackdrop = document.getElementById('query-backdrop');
function escapeHTML(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/// Numeric TokenType values, matching the order of the C++ enum in src/Parsers/Lexer.h.
/// Only the categories we actually classify are named; everything else is treated as
/// "default" (unstyled) and renders in the canonical text color via the diff blend.
const TT = {
Whitespace: 0, Comment: 1, BareWord: 2, Number: 3, StringLiteral: 4, QuotedIdentifier: 5,
OpeningRoundBracket: 6, ClosingRoundBracket: 7,
OpeningSquareBracket: 8, ClosingSquareBracket: 9,
OpeningCurlyBrace: 10, ClosingCurlyBrace: 11,
Semicolon: 13, Asterisk: 16, HereDoc: 17, DollarSign: 18,
Plus: 19, Minus: 20, Slash: 21, Percent: 22, Arrow: 23,
QuestionMark: 24, Colon: 25, Caret: 26, DoubleColon: 27,
Equals: 28, NotEquals: 29, Less: 30, Greater: 31,
LessOrEquals: 32, GreaterOrEquals: 33, Spaceship: 34,
PipeMark: 35, Concatenation: 36, At: 37, DoubleAt: 38,
};
/// SQL keywords recognized for highlighting. The lexer reports them as BareWord, so we
/// disambiguate identifiers from keywords here. Comparisons are case-insensitive.
const SQL_KEYWORDS = new Set([
'ADD', 'AFTER', 'ALL', 'ALTER', 'AND', 'ANTI', 'ANY', 'ARRAY', 'AS', 'ASC', 'ASCENDING',
'ASOF', 'AST', 'ASYNC', 'ATTACH', 'BACKUP', 'BEGIN', 'BETWEEN', 'BOTH', 'BY',
'CACHE', 'CASCADE', 'CASE', 'CAST', 'CHANGE', 'CHANGED', 'CHECK', 'CLEAR', 'CLUSTER',
'CODEC', 'COLLATE', 'COLUMN', 'COLUMNS', 'COMMENT', 'COMMIT', 'CONSTRAINT', 'CREATE',
'CROSS', 'CUBE', 'CURRENT',
'DATABASE', 'DATABASES', 'DAY', 'DEDUPLICATE', 'DEFAULT', 'DELETE', 'DESC', 'DESCENDING',
'DESCRIBE', 'DETACH', 'DICTIONARIES', 'DICTIONARY', 'DISK', 'DISTINCT', 'DISTRIBUTED',
'DROP', 'ELSE', 'END', 'ENGINE', 'ESTIMATE', 'EVENTS', 'EXCEPT', 'EXCHANGE', 'EXISTS',
'EXPLAIN', 'EXPRESSION', 'EXTENDED', 'EXTRACT',
'FALSE', 'FETCH', 'FETCHES', 'FILE', 'FILESYSTEM', 'FINAL', 'FIRST', 'FLUSH', 'FOLLOWING',
'FOR', 'FOREIGN', 'FORMAT', 'FREEZE', 'FROM', 'FULL', 'FUNCTION',
'GLOBAL', 'GRANT', 'GROUP', 'GROUPS', 'HAVING', 'HIERARCHICAL', 'HOUR',
'ID', 'IDENTIFIED', 'IF', 'ILIKE', 'IN', 'INDEX', 'INF', 'INHERIT', 'INJECTIVE',
'INNER', 'INSERT', 'INTERSECT', 'INTERVAL', 'INTO', 'INVISIBLE', 'IS', 'IS_OBJECT_ID',
'JOIN', 'KEY', 'KEYED', 'KILL',
'LAST', 'LATERAL', 'LAYOUT', 'LEADING', 'LEFT', 'LIFETIME', 'LIKE', 'LIMIT', 'LIMITS',
'LIVE', 'LOCAL', 'LOGS',
'MATERIALIZE', 'MATERIALIZED', 'MAX', 'MERGES', 'MICROSECOND', 'MILLISECOND', 'MIN',
'MINUTE', 'MODIFY', 'MONTH', 'MOVE', 'MUTATION',
'NAN_SQL', 'NEXT', 'NO', 'NONE', 'NOT', 'NULL', 'NULLS',
'OFFSET', 'ON', 'ONLY', 'OPTIMIZE', 'OPTION', 'OR', 'ORDER', 'OUTER', 'OUTFILE', 'OVER',
'PARTITION', 'PASTE', 'PERMANENTLY', 'PLAN', 'POPULATE', 'PRECEDING', 'PRECISION',
'PREWHERE', 'PRIMARY', 'PROFILE', 'PROJECTION', 'QUARTER', 'QUERY', 'QUOTA',
'RANDOMIZED', 'RANGE', 'RECURSIVE', 'REFRESH', 'REGEXP', 'RELOAD', 'REMOTE', 'RENAME',
'REPLACE', 'REPLICA', 'REPLICAS', 'RESET', 'RESTORE', 'RESTRICT', 'RESTRICTIVE',
'RETURNS', 'REVOKE', 'RIGHT', 'ROLE', 'ROLLBACK', 'ROLLUP', 'ROW', 'ROWS',
'SAMPLE', 'SECOND', 'SELECT', 'SEMI', 'SENDS', 'SET', 'SETS', 'SETTINGS', 'SHARD',
'SHOW', 'SIGNED', 'SOURCE', 'SQL_SECURITY', 'START', 'STEP', 'STORAGE', 'STRICT',
'STRICTLY_ASCENDING', 'SUBPARTITION', 'SUBSTRING', 'SUSPEND', 'SYNC', 'SYNTAX', 'SYSTEM',
'TABLE', 'TABLES', 'TEMPORARY', 'TEST', 'THEN', 'TIES', 'TIMESTAMP', 'TO', 'TOP',
'TOTALS', 'TRACKING', 'TRAILING', 'TRANSACTION', 'TRIGGER', 'TRIM', 'TRUE', 'TRUNCATE',
'TYPE',
'UNBOUNDED', 'UNFREEZE', 'UNION', 'UNIQUE', 'UNSIGNED', 'UPDATE', 'USE', 'USING',
'UUID', 'VALUES', 'VARYING', 'VIEW', 'VIRTUAL', 'VISIBLE',
'WATCH', 'WEEK', 'WHEN', 'WHERE', 'WINDOW', 'WITH', 'WORK', 'WRITABLE',
'XOR', 'YEAR', 'ZKPATH',
]);
/// Bracket token types, grouped by kind. Used for rainbow parentheses (coloring by
/// nesting depth) and matched-bracket highlighting.
const OPENING_BRACKETS = new Set([TT.OpeningRoundBracket, TT.OpeningSquareBracket, TT.OpeningCurlyBrace]);
const CLOSING_BRACKETS = new Set([TT.ClosingRoundBracket, TT.ClosingSquareBracket, TT.ClosingCurlyBrace]);
/// The closing type that matches each opening type.
const BRACKET_PAIR = {
[TT.OpeningRoundBracket]: TT.ClosingRoundBracket,
[TT.OpeningSquareBracket]: TT.ClosingSquareBracket,
[TT.OpeningCurlyBrace]: TT.ClosingCurlyBrace,
};
/// Number of distinct rainbow colors before the cycle repeats (see the --syntax-bracket-* vars).
const RAINBOW_BRACKET_COUNT = 7;
/// Compute, for every token, its rainbow-bracket depth (-1 for non-brackets and for
/// unmatched brackets) and the set of bracket tokens to embolden as the matched pair around
/// the cursor.
///
/// Depth is assigned with a type-aware stack, but only once a bracket has a real mate: the
/// matching opening and closing brackets both take the pair's nesting depth so they share a
/// color. A bracket without a counterpart — a lone `(`, a stray `)`, or a mismatched pair —
/// is left at depth -1 so it keeps the default color instead of a rainbow one, mirroring
/// `datastore-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 `datastore-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 `datastore-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 `datastore-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 = '';
/// Absolute character offset (into the textarea) of the position the server reported a syntax
/// error at, or -1 when there is no error to highlight. The token containing this offset is
/// painted with a red background in the backdrop until the user returns focus to the editor.
let queryErrorPos = -1;
/// True when the error was reported at the end of the query — the server writes a "(end of query)"
/// suffix for both an unexpected end of stream and a `;` separator (see `writeCommonErrorMessage`
/// in `src/Parsers/parseQuery.cpp`). Such an error points at the statement *boundary*, not at a
/// token, so `renderQueryBackdrop` draws a thin marker at `queryErrorPos` instead of filling the
/// `;`/whitespace token that happens to start there.
let queryErrorAtEnd = false;
/// Monotonic counter bumped whenever the user returns to the editor (refocus, typing, caret
/// movement, or a click inside it — including the synthetic events a tab switch dispatches while
/// restoring the editor). `beginFlight` snapshots it into the launching tab's
/// `runEditorInteraction` at run start; if it advances before the error response arrives,
/// `postImpl` suppresses the (now stale) syntax-error highlight. Without this, a late error
/// response could repaint the marker after the user has already resumed editing, because the
/// clear handlers only remove an already-visible marker and would not fire again on their own.
/// The run-launch snapshot and the error-marker owner offset (`queryErrorOwnerStart`) are
/// PER-TAB fields (see `tabRuntimeDefaults`): runs execute concurrently across tabs, so as
/// globals whichever run began last would overwrite the arbitration state of the others.
let editorInteractionGen = 0;
/// True only while `getQueryUnderCursor` performs its internal `query_area.focus()` to run a query.
/// That programmatic refocus happens *after* `beginFlight` has snapshotted the generation, so if it
/// were counted as the user returning to the editor it would advance `editorInteractionGen` past the
/// snapshot and suppress the syntax-error highlight for runs started from the "Run" / "Run all"
/// buttons (where the textarea was not already focused, so `focus()` really does fire a `focus`
/// event). Use `focusEditorForRun` to set this around that focus; `onReturnToEditor` then ignores it.
let suppressReturnToEditor = false;
/// Clear the syntax-error highlight and repaint. Called when the user returns to the editor
/// (focus, typing, cursor movement) or when a new query run starts.
function clearQueryError() {
if (queryErrorPos < 0) return;
queryErrorPos = -1;
queryErrorAtEnd = false;
renderQueryBackdrop();
}
/// Record that the user has interacted with the editor and clear any visible highlight. Bound to
/// the same focus/keydown/pointerdown/input events; the generation bump is what lets a late error
/// response know the user has already returned to editing (see `editorInteractionGen`).
function onReturnToEditor() {
/// A programmatic refocus performed to start a run is not a user return to the editor; ignore it
/// so it does not suppress the very highlight the run is about to produce (see `suppressReturnToEditor`).
if (suppressReturnToEditor) return;
++editorInteractionGen;
clearQueryError();
}
/// Focus the editor the way `getQueryUnderCursor` does when it selects the query to run, without
/// letting the resulting `focus` event count as the user returning to the editor (see
/// `suppressReturnToEditor`). Other `focus` listeners (e.g. the backdrop repaint) still run.
function focusEditorForRun() {
suppressReturnToEditor = true;
try {
query_area.focus();
} finally {
suppressReturnToEditor = false;
}
}
/// Convert a 0-based UTF-8 byte offset into a JS (UTF-16) string index. Datastore reports the
/// syntax-error position as a byte offset; for ASCII queries it coincides with the character
/// index, but non-ASCII text needs this conversion so the highlight lands on the right token.
function byteOffsetToCharIndex(str, byteOffset) {
if (byteOffset <= 0) return 0;
let bytes = 0;
let i = 0;
while (i < str.length) {
const cp = str.codePointAt(i);
const units = cp > 0xFFFF ? 2 : 1;
let b;
if (cp <= 0x7F) b = 1;
else if (cp <= 0x7FF) b = 2;
else if (cp <= 0xFFFF) b = 3;
else b = 4;
bytes += b;
i += units;
if (bytes >= byteOffset) return i;
}
return str.length;
}
/// Parse the server's error message for a "failed at position N" hint (see
/// `writeCommonErrorMessage` in `src/Parsers/parseQuery.cpp`) and, if found, highlight that
/// position in the editor. `query` is the exact text that was sent to the server; the reported
/// position is a 1-based byte offset into it, which we map back to the full textarea contents.
/// `queryStart` is the absolute character offset in the textarea where `query` began when the run
/// started (the caller captured it), used to disambiguate identical statements. `tab` is the tab
/// the run was launched in (the caller has already verified it is still the active one); it holds
/// the per-run marker-ownership state.
function highlightQueryError(errorText, query, queryStart, tab) {
if (typeof errorText !== 'string' || typeof query !== 'string') return;
const match = errorText.match(/failed at position (\d+)/);
if (!match) return;
/// 1-based byte offset -> 0-based.
const byteOffset = parseInt(match[1], 10) - 1;
if (!(byteOffset >= 0)) return;
/// Locate the sent query within the textarea. Prefer the exact start offset the caller
/// captured when the run began, so duplicate statements (e.g. `SELECT 1; SELECT 1`) map to the
/// one that actually failed. If the editor changed while the request was in flight and the
/// query is no longer at that offset, fall back to its first occurrence; if it is gone
/// entirely, do not paint a stale position at all.
const text = query_area.value;
let base;
if (typeof queryStart === 'number' && queryStart >= 0
&& text.substring(queryStart, queryStart + query.length) === query) {
base = queryStart;
} else {
base = text.indexOf(query);
}
if (base < 0) return;
/// Run-level arbitration for the single editor marker: several statements of one run can fail
/// concurrently (`postMulti` executes parallelizable groups with `Promise.all`), and their
/// error responses arrive in an arbitrary order. The leftmost failing statement wins
/// deterministically: a response may take the marker only if no statement has claimed it yet
/// in this run, or if its statement starts earlier in the editor than the current owner's.
/// The owner offset lives on the launching tab (reset by `beginFlight`), so the arbitration
/// is scoped to that tab's run and a concurrent run in another tab cannot interfere with it.
if (tab.queryErrorOwnerStart >= 0 && base >= tab.queryErrorOwnerStart) return;
tab.queryErrorOwnerStart = base;
const charIndex = byteOffsetToCharIndex(query, byteOffset);
const absolute = base + charIndex;
queryErrorPos = Math.min(absolute, text.length);
/// "(end of query)" means the parser reached the end of the statement or its `;` separator
/// (see `writeCommonErrorMessage`). The reported position then falls on the `;` (or on the
/// end of a trimmed statement); paint the statement boundary rather than the separator token.
queryErrorAtEnd = / \(end of query\)/.test(errorText);
renderQueryBackdrop();
}
/// 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;
/// Whether the "(end of query)" boundary marker has already been emitted (see below).
let errMarkerEmitted = false;
for (let i = 0; i < tokens.length; ++i) {
const elem = tokens[i];
const tokStart = offset;
const tokEnd = offset + elem.token.length;
offset = tokEnd;
/// An "(end of query)" error points at a statement boundary, not a token. Draw a thin
/// marker at that boundary — right before the token that starts there — rather than
/// filling the following `;`/whitespace token. A boundary at the end of the text has no
/// following token and is emitted after the loop instead.
if (queryErrorAtEnd && !errMarkerEmitted && queryErrorPos <= tokStart) {
html += '<span class="q-err-pos-end"></span>';
errMarkerEmitted = true;
}
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';
}
/// The token at the reported syntax-error position gets a red background — except for an
/// "(end of query)" error, which marks the statement boundary above rather than a token.
const isErrPos = queryErrorPos >= 0 && !queryErrorAtEnd && queryErrorPos >= tokStart && queryErrorPos < tokEnd;
if (isErrPos) {
cls = cls ? cls + ' q-err-pos' : 'q-err-pos';
}
/// 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>';
}
/// A statement-boundary ("(end of query)") error whose boundary falls at the very end of the
/// text has no following token, so emit its marker here. `errMarkerEmitted` guards against a
/// double marker when the boundary already matched a token inside the loop.
if (queryErrorAtEnd) {
if (!errMarkerEmitted) {
html += '<span class="q-err-pos-end"></span>';
errMarkerEmitted = true;
}
} else if (queryErrorPos >= 0 && queryErrorPos >= text.length) {
/// A non-boundary error past the last token (e.g. the lexer stopped early on an
/// incomplete tail): fall back to the trailing filled marker.
html += '<span class="q-err-pos"> </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);
/// Clear the syntax-error highlight as soon as the user returns to the editor: refocusing it,
/// typing, moving the cursor with the keyboard, or clicking inside it. `onReturnToEditor` also
/// bumps the interaction generation so a late error response will not repaint a stale highlight.
query_area.addEventListener('focus', onReturnToEditor);
query_area.addEventListener('keydown', onReturnToEditor);
query_area.addEventListener('pointerdown', onReturnToEditor);
query_area.addEventListener('input', onReturnToEditor);
/// 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 `datastore-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();
/// The selected database is a namespace on the SEMANTIC connection identity
/// (`sameServerAddress` + `effectiveConnectionUser`) — the same identity the `window.onpopstate`
/// restore gate uses. This handler fires on every `input` in a connection field, including edits
/// that do NOT change that identity: a cosmetic URL normalization (a root-path trailing slash or
/// a default port — the query string stays significant) or a password-only change (the password is
/// authentication, not part of the resource identity). Dropping the selection then would retarget
/// the next unqualified `Run` to the server default while history navigation still treats the
/// entry as valid — the two would disagree. So clear it only when the identity actually changed
/// relative to where it was chosen (`selected_database_connection`); keep it otherwise. An unset
/// stamp (no selection) fails the check the safe way, matching the previous unconditional clear.
const identity_changed = selected_database_connection === null
|| !sameServerAddress(selected_database_connection.server, url_elem.value)
|| effectiveConnectionUser(selected_database_connection.server, selected_database_connection.user)
!== effectiveConnectionUser(url_elem.value, user_elem.value);
const cleared_selection = selected_database !== null && identity_changed;
if (cleared_selection) {
selected_database = null;
selected_database_connection = null;
}
server_current_database = null;
/// 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();
/// Restamp the current history entry whenever the live connection moved, so its URL stops
/// describing the previous target. Two ways it can go stale:
/// - A selection was just cleared above: the entry/URL may still carry the old `database=` (and
/// its `run=1` marker), so a reload or copied link would resurrect the selection we just
/// invalidated (and possibly auto-run it against the new connection).
/// - No selection was set, but the entry is a run entry (`run=1`) stamped for the previous
/// server (`connectionServer`), which is the case even for a server-default run that carries
/// no `database=` at all. Editing the connection from server A to server B then leaves the
/// live page on B while the current entry still says `run=1`/`url=A`; reloading or sharing it
/// would auto-execute the old query against A — the same stale-target hazard the `database=`
/// restamp addresses. The database-clear branch never fires here (there is nothing to clear),
/// so detect the connection divergence directly from the entry's stamp.
/// Compared with the SAME (`sameServerAddress`, `effectiveConnectionUser`) identity as the restore
/// gate (`window.onpopstate`) and `writeHistoryEntry`'s `live_diverged`, so a cosmetic URL edit (a
/// root-path trailing slash, a default port) or a password-only change keeps the identity and does
/// NOT churn the entry. An entry without a `connectionServer` stamp (one written by a play.html
/// predating the stamp) gives no producing connection to compare against here; it is covered by
/// the bootstrap term below instead. When it did diverge, restamp from the active tab exactly as
/// `commitSelectedDatabase` does — `writeHistoryEntry` then drops `run=1` and rewrites `url=`/`user=`
/// for the live target (or, for a still-matching run entry, keeps the producing snapshot verbatim).
const current_entry = history.state;
const connection_diverged = current_entry
&& current_entry.connectionServer !== undefined
&& (!sameServerAddress(current_entry.connectionServer, url_elem.value)
|| effectiveConnectionUser(current_entry.connectionServer, current_entry.connectionUser)
!== effectiveConnectionUser(url_elem.value, user_elem.value));
/// Before the first history write there is no entry to compare against, yet a deferred startup
/// auto-run may already be armed (`defer_run_for_reconcile`) against the connection the page
/// loaded with (`initial_connection`). A trusted connection edit in that window must cancel the
/// run just as it would once an entry existed — otherwise a fresh server-default `?run=1#...`
/// link (no `database=`, so `cleared_selection` stays false; no `history.state`, so
/// `connection_diverged` stays false) would auto-execute the shared query against the just-edited
/// server/user the link never named. An entry that EXISTS but carries no `connectionServer`
/// stamp — `history.state` survives a reload, so a `?run=1#...` reload from an entry written by
/// a play.html predating the stamp lands here — is the same case: there is no authoritative
/// connection stamp yet, only the connection the page loaded with, so fall back to
/// `initial_connection` for it too. Without that, neither term fires (`connection_diverged` by
/// its `connectionServer !== undefined` guard, this one by `!current_entry`) and the deferred
/// run would still execute against the edited connection. Compared with the SAME
/// (`sameServerAddress`, `effectiveConnectionUser`) identity, so a cosmetic edit (a root-path
/// trailing slash, a default port) or a password-only change neither churns the entry nor
/// suppresses the legitimate auto-run.
const bootstrap_connection_diverged = (!current_entry || current_entry.connectionServer === undefined)
&& (!sameServerAddress(initial_connection.server, url_elem.value)
|| effectiveConnectionUser(initial_connection.server, initial_connection.user)
!== effectiveConnectionUser(url_elem.value, user_elem.value));
/// A Back/Forward restore replays these connection changes synthetically to reproduce the popped
/// entry; it must NOT restamp history here. `history.state` is already that popped entry and
/// `activeTabId` still points at the tab being left, so a restamp would overwrite the time-travel
/// target with the leaving tab's state (`restoring_connection_from_history`). `onpopstate` does the
/// authoritative entry/URL restore itself.
if ((cleared_selection || connection_diverged || bootstrap_connection_diverged)
&& !restoring_connection_from_history) {
captureActiveTab();
/// The restamp seeds/refreshes the current entry through `refreshCurrentHistoryEntry` →
/// `writeHistoryEntry`, which drops `run=1` while the bootstrap tab has no result yet and
/// thereby cancels a still-deferred startup auto-run (see `deferred_run_cancelled` and
/// `writeHistoryEntry`) — left uncancelled, it would still execute the shared query against
/// the just-edited server/user rather than the one the link specified.
refreshCurrentHistoryEntry(getActiveTab());
}
}
[url_elem, user_elem, password_elem].forEach(el => el.addEventListener('input', (e) => {
/// A genuine connection edit during the startup window must gate the deferred URL restore and
/// `run=1` auto-run the same way query/parameter edits and tab switches do: `postAll` snapshots
/// these same inputs at launch, so a deferred auto-run started after the user changed the
/// server/identity would execute against the edited target without an explicit Run. Trusted edits
/// only — reconciliation's own programmatic changes dispatch `isTrusted === false` events, which
/// `markBootstrapDirty` ignores.
markBootstrapDirty(e);
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) => {
/// This handler runs in the capture phase and calls `stopImmediatePropagation` for the keys it
/// consumes (Tab, Enter, ArrowUp/ArrowDown, ArrowRight, Escape). At the event target, a
/// capture-phase listener fires before the bubble-phase one and its `stopImmediatePropagation`
/// prevents the bubble-phase `onReturnToEditor` (bound below) from firing at all. So record the
/// editor interaction here too: pressing a key to open, navigate, or dismiss completions is a
/// return to the editor, and without this a late error response for an in-flight query would
/// repaint a stale syntax-error highlight (see `editorInteractionGen`). Bump it before the
/// selection-indent early return below, which also `stopImmediatePropagation`s and returns.
///
/// Also clear any *already visible* highlight here. Bumping the generation only suppresses a
/// future late response; because this handler `stopImmediatePropagation`s the keys it consumes
/// (Tab, Enter, arrows, Escape), the bubble-phase `onReturnToEditor` — which would clear the
/// marker — never runs for them, so a stale highlight would otherwise linger while the user
/// navigates completions. `clearQueryError` is idempotent when there is nothing to clear.
++editorInteractionGen;
clearQueryError();
/// 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'));
}
}
}
/// Snapshot the parameter VALUES a run launches with, and keep the `param_*` inputs in sync.
/// Called by `postOne`/`postAll` for the launching `tab` BEFORE any await, so the whole background
/// run stays bound to one launch-time snapshot (see the call sites): re-reading the shared
/// `param_*` inputs / `getActiveTab` after an await would let a tab switch during it rebind the
/// run to another tab's parameter values (a correctness bug for parameterized queries).
///
/// Normally the live DOM inputs are authoritative. But while a tab activation / Back-Forward
/// restore has not committed yet, the launching tab's saved parameters
/// have not been written to the inputs — that write happens only after `restoreEditor`'s
/// tokenization await, which the `++activation_num` in `postOne`/`postAll` has just superseded — 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 launching tab's own saved `params` instead (parameters are tab-owned), and write
/// them back so the inputs reflect what actually ran.
///
/// The parameter NAMES this snapshot enumerates (via `currentQueryParams` / the tab's saved keys)
/// can themselves lag the editor: both are rebuilt only after an asynchronous tokenization
/// (`updateQueryParams` on edits, `restoreEditor` on activation), so right after a quick edit they
/// may still describe the PREVIOUS text — e.g. for `SELECT {x}` -> `SELECT {y}` they would send
/// `param_x` and drop `param_y`. `postOne`/`postAll` therefore re-derive the names from the RUN's
/// own tokenization of the launched text (`extractRunParamNames`) and bind them to the values
/// snapshotted here (`pickRunParams`). When that derivation is unavailable (no lexer / text too
/// large) the caller falls back to the merged `param_sources` (this result over `tab.params`), so
/// a saved tab whose parameter inputs could not be rebuilt still runs with its persisted bindings.
function resolveRunParams(params_restore_pending, tab)
{
if (!params_restore_pending) return getParamValues();
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;
}
/// Extract the `{name:Type}` parameter placeholders from the RUN's own snapshot of the editor
/// text, tokenizing it independently of the keystroke-driven `updateQueryParams` pipeline (whose
/// `currentQueryParams` may still describe an older edit). Returns the extracted list, or null
/// when it cannot be derived (no WASM lexer, or the text is too large to tokenize) — the caller
/// then falls back to the launch-time snapshot.
async function extractRunParamNames(text)
{
if (typeof WebAssembly !== 'object' || typeof WebAssembly.instantiate !== 'function') return null;
try
{
return extractQueryParams(await tokenize(text));
}
catch (e)
{
return null;
}
}
/// Bind a run's own parameter list (`params`, extracted from the launched text) to the values
/// known for the launching tab at launch (`sources`). A name with no known value is omitted —
/// exactly like a parameter whose input never existed — so the server reports it as not set
/// instead of the run silently executing with a stale or foreign binding.
function pickRunParams(params, sources)
{
const values = {};
for (const param of params)
{
if (sources[param.name] !== undefined) values[param.name] = sources[param.name];
}
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 (`restoreEditor`
/// 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
/// While a restore is rebuilding these inputs for a just-activated tab (see
/// `params_restore_pending_token` in `restoreEditor`), the DOM elements here still
/// hold the tab being LEFT — `updateQueryParams`'s rebuild has not landed yet. Writing
/// them into `getActiveTab().params` now would attribute the previous tab's values to
/// the newly active tab (and drop the values the new tab was actually restoring).
/// `captureActiveTab` applies the same guard for the same reason; the restore's own
/// commit sets `tab.params` correctly once it resolves.
if (params_restore_pending_token !== null) return;
/// 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. `restoreEditor`/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 `restoreEditor`/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);
/// Recolor every materialized tab's chart(s), not just the active one, so a background tab's
/// chart matches the theme when it is next opened. Runs before any panel exists at startup, so
/// the query is safe (empty).
for (const el of resultPanelsEl.querySelectorAll('query-result')) el.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;
/// Seed the bootstrap tab's color modes and pinned columns from the URL (a shared/bookmarked link
/// may carry them).
tabs[0].colorModes = url_color_modes;
tabs[0].pinnedColumns = url_pinned_columns;
/// Build the active tab's panel now (synchronously), so the `resultEl` alias exists before any
/// connection check / early render runs, matching the pre-tabs invariant that it always existed.
setActivePanel(tabs[0]);
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 ''
/// Defer the startup auto-run (`run=1`) until after IndexedDB reconciliation for EVERY URL, not
/// only ones carrying a `#<query>` hash. Two reasons: (1) reconciliation may switch the active tab,
/// and a switch would abort an in-flight run, so the run must happen in the reconciled tab; (2) a
/// bare `?tab=<blank>&run=1` reload must not auto-run at the top level before reconciliation has
/// read `history.state`: that early `postAll` writes `history.state` for the bootstrap tab, after
/// which `stale_blank_reload` stops matching the pruned blank tab and the authoritative path
/// resurrects and re-persists it. The deferred run below is gated on a non-empty `query_area.value`,
/// so a bare `run=1` with nothing to run is simply dropped — as before, when it auto-ran an empty
/// query that produced nothing.
const defer_run_for_reconcile = run_immediately;
/// Reconcile the freshly loaded URL (query + tab name) against tabs saved in IndexedDB.
async function reconcileStartup() {
const loaded = await loadFromDb();
/// `bootstrap_settled` is deliberately NOT set here: reconciliation still awaits `activateTab`
/// and `updateQueryParams` below (and may fire a deferred auto-run), and a genuine user edit
/// during any of those awaits must still mark the workspace dirty so it is neither overwritten
/// by the URL-driven restore nor auto-run. Reconciliation's own synthetic input events carry
/// `isTrusted === false`, so they never set the flag. The flag is closed at each terminal path.
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;
/// Set when the URL is authoritative for the reconciled tab's result (its query was
/// substituted from a fresh/shared link, or a new tab was opened for it). In that case the
/// URL's `color_modes` payload must be applied to that tab; see the re-apply after activation.
let url_authoritative_colors = false;
/// Set by the no-saved-tabs adopt branch when it recreates the bootstrap tab for an
/// authoritative URL. The current entry is re-owned for that tab at the tail, AFTER the URL
/// parameters are reconciled into it, so the rebuilt entry/URL carry the adopted title, query
/// and `param_*` together (see the tail's `refreshCurrentHistoryEntry` call).
let reown_adopted_entry = false;
/// Reconstruct the saved workspace from IndexedDB, ordered by the persisted order,
/// and restore the id/title counters.
let savedTabs = [];
/// Stable ids of the blank tabs dropped by the pruning below. Used — together with the reloaded
/// history entry's provenance (see `stale_blank_reload`) — to recognize a bare `?tab=Name` URL
/// (no query) that is only a stale echo of a tab this load just dropped. A plain reload echoes
/// the (now pruned) active tab's history entry, while a shared bare link names a tab this pruning
/// never saw. Keying the distinction on whether IndexedDB had any records at all would not work:
/// the trailing `scheduleSave` persists the blank bootstrap tab even on a first-ever visit, so
/// every later visit would look like an all-blank reload and shared bare links would stop opening
/// the named tab. The pruned tab's title is likewise not a reliable key: renaming a tab rewrites
/// the current history entry (`?tab=` + `history.state.tabName`) synchronously, but the record is
/// only re-persisted by the debounced `scheduleSave`, so a reload right after a rename echoes the
/// NEW title while `IndexedDB` still holds the OLD one — a title match would miss it and resurrect
/// the blank tab. The tab id is stable across an unsaved rename, so match on it instead.
/// The titles are additionally kept for LEGACY history entries that predate `tabId` (see
/// `stale_blank_reload`): for those the title is the only key available, and the rename
/// race above cannot affect them — any history write under the current code (including a
/// rename's synchronous rewrite) stamps `tabId`, so an entry without one was never
/// rewritten since the upgrade and still carries the same title `IndexedDB` holds.
const pruned_blank_ids = new Set();
const pruned_blank_titles = new Set();
if (loaded && loaded.records.length) {
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);
/// Do not restore blank scratch tabs left over from a previous session: on reload they
/// would reappear as empty tabs the user never asked to keep. A tab counts as blank only
/// when its query is empty AND it carries no genuine run result: a tab whose editor was
/// cleared after a run still holds a `result.ran` snapshot, which `renderTabBar` treats as
/// meaningful persisted state, so it must be preserved (dropping it would silently erase a
/// run-backed tab from `IndexedDB`). Dropping the truly-blank tabs here lets the workspace
/// fall back to a single fresh tab when every saved tab was blank (as on a first-ever
/// visit), and the trailing `persist` prunes them from storage.
for (const r of ordered) {
if ((r.query || '').trim() !== '' || (r.result && r.result.ran)) {
savedTabs.push({
id: r.id, title: r.title, query: r.query || '',
params: r.params || {}, result: r.result || null, lastSavedQuery: r.lastSavedQuery,
/// Attach fresh transient runtime state (panels/queries are materialized lazily on
/// first activation), seeding color modes and pinned columns from the persisted
/// result snapshot.
...tabRuntimeDefaults(),
colorModes: (r.result && r.result.color_modes) || {},
pinnedColumns: (r.result && r.result.pinned_columns) || {},
});
} else {
pruned_blank_ids.add(r.id);
pruned_blank_titles.add(r.title);
}
}
if (loaded.meta) {
tabSeq = Math.max(tabSeq, loaded.meta.tabSeq || 0);
tabTitleSeq = Math.max(tabTitleSeq, loaded.meta.tabTitleSeq || 0);
}
}
/// A bare `?tab=Name` URL is a stale echo of a blank tab this load just pruned — and so NOT
/// authoritative — only when the reloaded history entry still describes one of those pruned
/// tabs. Every `?tab=` in the URL was written by `writeHistoryEntry` together with a
/// `history.state` carrying the tab's stable `tabId`, and a reload preserves that state, so a
/// plain reload of a dropped blank tab has `history.state.tabId` equal to that tab's id. Match
/// on the id, not the title: the id survives an unsaved rename (see `pruned_blank_ids`), whereas
/// the title in the URL/`history.state` can already be the new name while `IndexedDB` still holds
/// the old one — a title match would miss the pruned record and let the blank tab come back. A
/// genuine external shared bare link (typed, pasted, or followed from elsewhere) begins a fresh
/// navigation with `history.state === null`, so it stays authoritative and opens the named tab
/// even when its title happens to collide with a locally pruned blank tab. The
/// `history.state.tabName === url_tab_name` guard keeps this scoped to an entry that actually
/// describes the URL's named tab. `history.state` is still the reloaded value here: nothing has
/// rewritten it yet because the startup `run=1` auto-run — whose `postAll`/`saveHistory` would
/// stamp the bootstrap tab's state over it — is deferred to the end of this function for EVERY
/// URL (see `defer_run_for_reconcile`), not run at the top level before reconciliation.
/// A LEGACY entry predating `tabId` (supported by `resolveTabForState` via its title fallback)
/// carries only `tabName`, so for it the pruned set is matched by title instead: the title is
/// the only key such an entry has, and it is reliable here precisely because the entry is
/// legacy — every history write under the current code stamps `tabId`, so an entry without one
/// was never rewritten by a rename and cannot be affected by the rename race described at
/// `pruned_blank_ids`. Without this fallback, reloading an upgraded pre-`tabId` session whose
/// URL names a pruned blank tab would recreate and re-persist that tab.
const stale_blank_reload = !!(url_tab_name
&& history.state
&& history.state.tabName === url_tab_name
&& (history.state.tabId
? pruned_blank_ids.has(history.state.tabId)
: pruned_blank_titles.has(history.state.tabName)));
/// A `stale_blank_reload` can still leave a query fragment in the URL: the pruned tab's own
/// text was encoded into the hash by the last `writeHistoryEntry`, so `has_url_query` is true
/// even for a tab the pruning counted as blank. When that text is itself blank (whitespace-only,
/// by the same `trim()` rule the pruning uses) the hash is only a stale echo of the dropped
/// blank tab — NOT a query the user wants restored — so it must be treated exactly like a bare
/// `?tab=`: non-authoritative everywhere below, never allowed to recreate or re-adopt the blank
/// tab. Otherwise the branches keyed on `has_url_query` would rebuild it and the trailing
/// `scheduleSave` would write it straight back to `IndexedDB`, defeating the pruning. A NON-blank
/// hash on a stale reload is instead a genuine unsaved edit that never reached `IndexedDB` before
/// the reload, so it stays authoritative and the tab is recreated with that real query,
/// preserving the edit.
const stale_blank_reload_bare = stale_blank_reload && (url_query || '').trim() === '';
/// A `stale_blank_reload_bare` URL is only a stale echo of a blank tab this load just pruned, so
/// it is non-authoritative in every respect — including its `run=1` directive, which was written
/// for that pruned tab, not for whichever surviving/bootstrap tab the reconciliation below falls
/// back to. The deferred auto-run is already skipped for it (see the `!stale_blank_reload_bare`
/// guard on `postAll` below), but the directive must also be dropped from the global
/// `run_immediately`: the trailing `syncHistory` recomputes `run=1` as `run_immediately &&
/// tabReflectsRun(tab)`, so a clean, run-backed fallback tab (e.g. a surviving `Report`) would
/// otherwise be re-stamped with `run=1` and auto-execute on the NEXT reload, inheriting an
/// auto-run the user never asked of it. `defer_run_for_reconcile` was captured before startup
/// ran, so clearing the flag here leaves that guard's behavior intact and only closes the leak.
if (stale_blank_reload_bare) run_immediately = false;
if (savedTabs.length) {
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.
///
/// Once the live bootstrap workspace wins, the startup `run=1` directive is spent: it
/// was meant to auto-run the URL's original query in the bootstrap tab, but the user's
/// live interaction superseded it and the deferred auto-run is skipped for a dirty
/// startup (see the `!bootstrap_dirty` guard on `postAll` below). It must also be
/// dropped from the global `run_immediately`, exactly as for `stale_blank_reload_bare`
/// above: otherwise the flag survives, and the next `syncHistory`/`saveHistory`
/// recomputes `run=1` as `run_immediately && tabReflectsRun(tab)`, so switching into a
/// clean, run-backed tab (e.g. a restored `Report`) would re-stamp `run=1` onto its URL
/// and auto-execute it on the NEXT reload — an auto-run the user never asked of it.
run_immediately = false;
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);
/// Re-own the current browser-history entry for the winning live active tab. Renaming a
/// live tab above changes its title, but the entry can be stale in two ways, and only a
/// full re-own fixes both. (1) On a plain reload the preserved `history.state`/hash still
/// describe the previous session's active tab — which may be one of the blank tabs just
/// pruned — and the live edit typed during the load window could not claim them
/// (`refreshCurrentHistoryEntry` bails on another tab's entry). Restamping only the `?tab=`
/// URL param would leave the dropped tab's stale hash and foreign
/// `history.state` attached: on the NEXT reload startup treats that stale hash as an
/// authoritative shared URL and overwrites the persisted live query with it. (2) When the
/// entry does belong to the live tab (a fresh nav whose first trusted edit seeded it under
/// the tab's pre-rename default title), the `?tab=`/`history.state.tabName` still carry the
/// old title. Both are fixed exactly as the all-blank dirty branch below does: drop the
/// foreign state first (unless the ids coincide, where the entry is already the live tab's
/// to replace in place), then seed the entry wholesale from the live tab via
/// `refreshCurrentHistoryEntry` — rebuilding the URL, `history.state`, hash and
/// `lastSavedQuery`, and dropping the stale `?tab=`/hash. (`run_immediately` was cleared
/// above, so the rebuilt URL cannot re-stamp `run=1`.) Older load-window entries keep the
/// stable live `tabId` and are restamped lazily by `onpopstate` on Back/Forward.
const active_live = getActiveTab();
if (active_live) {
if (history.state && history.state.tabId !== active_live.id) {
history.replaceState(null, document.title, location.href);
}
refreshCurrentHistoryEntry(active_live);
}
renderTabBar();
scheduleSave();
bootstrap_settled = true; /// reconciliation is done for this path (no further awaits)
return; /// skip URL param reconciliation and auto-run: the user is already editing
}
/// Replacing the in-memory workspace with the saved one: drop the bootstrap tab's panel
/// from the DOM so it does not linger (visible) behind the restored active tab's panel.
for (const t of tabs) if (t.panel) t.panel.remove();
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;
/// Skip title matching entirely when the URL is a stale reload echo of a just-pruned
/// blank tab (`stale_blank_reload`): the reloaded history entry (its stable `tabId`,
/// or the title fallback for a legacy entry predating `tabId`) identifies the pruned
/// tab, not any surviving tab that merely shares its title, so
/// the URL is NOT authoritative and must not activate an unrelated same-titled tab.
/// This matters only in legacy persisted workspaces that still hold duplicate titles
/// (normally titles are kept unique, see `uniqueTitle`): there `named[0]` below would
/// otherwise pick a same-titled survivor and make the stale bare `?tab=` authoritative
/// again, landing a plain reload on an unrelated tab. Leaving `match` null here lets
/// control reach the non-authoritative fallback (persisted active tab, else the first
/// saved tab) — the same fall-through the guard already applies further below.
if (url_tab_name && !stale_blank_reload) {
/// 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. The tab's
/// live `colorModes` / `pinnedColumns` (seeded from its snapshot in
/// `loadFromDb`) survive the drop of `result`, and when the URL is
/// authoritative the re-apply below replaces them with the link's set.
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;
}
}
/// Whenever the URL carries a query and this is not a stale reload, the URL is
/// authoritative for this tab's color coding — including an EXACT-MATCH reconciliation
/// (the URL query equals the saved tab's). A shared/bookmarked link's `color_modes`
/// must then replace the saved palette; otherwise `syncHistory` would rewrite the URL
/// back to the saved modes and the link would silently lose its encoded colors.
if (has_url_query && !preserve_local_query) url_authoritative_colors = true;
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. The
/// URL's `color_modes` are still authoritative for it (a shared link), even on this
/// exact match, so flag it for the re-apply below.
activeTabId = match.id;
url_authoritative_colors = true;
} else if ((has_url_query && !stale_blank_reload_bare) || !url_tab_name || !stale_blank_reload) {
/// Not found, but the URL is authoritative: it carries a non-blank query (a
/// fresh/shared link, or a genuine unsaved edit whose tab was pruned before it
/// reached `IndexedDB`), is unnamed, or its `?tab=` is not a stale reload echo of a
/// just-pruned blank tab — either the reloaded history entry does not identify one
/// of the pruned blank tabs (by its stable `tabId`), or there is no history state at
/// all, showing this is a fresh external navigation rather than a reload (a shared
/// bare link, even one whose name collides with a pruned blank tab). A whitespace-only
/// hash echoing a pruned blank tab is excluded by `stale_blank_reload_bare` — it is
/// handled by the fallback below, not treated as authoritative. Open a new tab
/// carrying the name (if any) and query. A `stale_blank_reload` reaches here with
/// title matching skipped (see above), so a surviving tab may already hold
/// `url_tab_name` (a legacy duplicate-title workspace): disambiguate the recreated
/// tab's title with `uniqueTitle` so the persisted workspace keeps unique titles and
/// a later bare/shared `?tab=` can no longer resolve to — or overwrite — the wrong
/// tab. When the name does not collide (the common fresh/shared-link case)
/// `uniqueTitle` returns it unchanged, so this stays a no-op there.
const t = makeTab(url_tab_name ? uniqueTitle(url_tab_name) : undefined, url_query);
tabs.push(t);
activeTabId = t.id;
url_authoritative_colors = true;
} else {
/// `?tab=Name` echoing one of the blank tabs pruned above, on a reload (the history
/// entry still describes that tab — see `stale_blank_reload`), with no query or only
/// a blank whitespace-only one (`stale_blank_reload_bare`). This is a plain reload
/// whose active tab was a blank scratch tab we just dropped: the URL only echoes that
/// tab's title and its blank text, so it is NOT authoritative. Recreating it would
/// resurrect the blank tab (and the trailing `scheduleSave` would write it back to
/// `IndexedDB`), defeating the very pruning above. Instead fall back to the persisted
/// active tab if it survived, otherwise the first saved tab (mirrors the plain-load
/// branch).
activeTabId = (loaded.meta && savedTabs.some(t => t.id === loaded.meta.activeTabId))
? loaded.meta.activeTabId : savedTabs[0].id;
}
}
await activateTab(activeTabId);
activated = true;
/// `activateTab` → `materializeResult` reset the reconciled tab's `colorModes` from its
/// snapshot, dropping the URL's payload. When the URL is authoritative, re-apply
/// `url_color_modes` now — before the deferred auto-run binds them to the result element.
/// Mutate the modes object IN PLACE (rather than replacing it) so every element that
/// aliases it — the single result element and any restored multi-query result elements —
/// stays bound to the same object; then re-run `applyColumnColors` on each so an exact-match
/// reconciliation (which already materialized its saved result with the saved palette)
/// recolors with the URL's modes. A substituted/new tab has no rendered result yet, so the
/// recolor is a no-op there and the modes just carry into its deferred auto-run.
if (url_authoritative_colors) {
const t = getActiveTab();
if (t) {
for (const k of Object.keys(t.colorModes)) delete t.colorModes[k];
Object.assign(t.colorModes, url_color_modes);
/// The pinned columns are persisted and shared the same way as the color modes, so a
/// shared link is equally authoritative for them: reassert `url_pinned_columns` in
/// place (keeping every aliasing element bound to the same object) alongside the modes.
for (const k of Object.keys(t.pinnedColumns)) delete t.pinnedColumns[k];
Object.assign(t.pinnedColumns, url_pinned_columns);
const els = t.resultEl ? [t.resultEl] : [];
if (t.multiContainer) for (const el of t.multiContainer.querySelectorAll('query-result')) els.push(el);
for (const el of els) {
el._colorModes = t.colorModes; el.applyColumnColors();
el._pinnedColumns = t.pinnedColumns; el.applyPinnedColumns();
}
}
}
} else if (url_tab_name && ((has_url_query && !stale_blank_reload_bare) || !stale_blank_reload)) {
/// No saved tabs survived, and the URL is authoritative: it carries a non-blank query (a
/// fresh/shared link, or a genuine unsaved edit whose tab was pruned before it reached
/// `IndexedDB`), or its `?tab=` is not a stale reload echo of a just-pruned blank tab (a
/// first-ever visit, or a shared bare `?tab=Name` link — including one whose name collides
/// with a pruned blank tab, since a fresh navigation carries no history state). A
/// whitespace-only hash echoing a pruned blank tab is excluded by `stale_blank_reload_bare`,
/// falling to the stale-echo branch below instead. Adopt the name for the bootstrap tab.
const t = getActiveTab();
if (t) {
t.title = url_tab_name;
/// Re-own the current browser-history entry for this recreated bootstrap tab. The entry
/// can be stale in two ways, and only a full re-own fixes both. (1) On a
/// `stale_blank_reload` whose non-blank hash is authoritative (a genuine unsaved edit
/// whose tab was pruned before it reached `IndexedDB`), the reloaded `history.state` still
/// carries the dropped blank tab's stable `tabId`, which differs from this bootstrap tab's.
/// Restamping only `?tab=` would leave that foreign state and hash attached, so the
/// recreated tab could never re-own the entry — a later trusted edit bails in
/// `refreshCurrentHistoryEntry` (`cur.tabId !== tab.id`) — and a quick edit-then-reload
/// before the debounced save would restore the stale hash/query and lose the edit. (2) When
/// the entry does belong to this tab (a live edit before reconciliation, `bootstrap_dirty`,
/// seeded it under the tab's PRE-rename default title `Query A`), the `?tab=`/
/// `history.state.tabName` still carry the old title, so a reload of that entry would
/// recreate a second tab under the old name alongside the one actually saved. Both are
/// fixed exactly as the dirty all-blank and dirty merge branches do: drop the foreign state
/// first (unless the ids coincide — a pruned legacy `t1` record vs. the `t1` bootstrap tab
/// — where the entry is already this tab's to replace), then seed the entry wholesale from
/// the tab via `refreshCurrentHistoryEntry`. The seed is deferred to the tail
/// (`reown_adopted_entry`) so it runs AFTER the URL parameters below are reconciled into
/// the tab, letting the rebuilt entry/URL carry the authoritative `param_*` too (seeding
/// here would rebuild the URL from the tab's not-yet-reconciled params and drop them).
/// `run_immediately` is untouched: a `run=1` URL takes the tail `postAll` path instead,
/// whose own history write then owns the entry.
if (history.state && history.state.tabId !== t.id) {
history.replaceState(null, document.title, location.href);
}
reown_adopted_entry = true;
/// 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
/// tail re-own (`reown_adopted_entry`) also sets `lastSavedQuery` (to the same value, since
/// `t.query` equals `url_query` here), but do it eagerly so it holds even before that runs.
if (has_url_query) t.lastSavedQuery = url_query;
}
renderTabBar();
} else {
/// Either a plain load with no `?tab=` at all (e.g. a first-ever visit), or a plain
/// reload whose every saved tab was blank and got pruned above. In the latter case the
/// URL still carries a `?tab=<dropped title>` (with no query, or only the dropped tab's own
/// blank whitespace one) — recognized as a stale reload echo by `stale_blank_reload` (the
/// reloaded history entry's stable `tabId` matches a blank tab this load pruned) and, when a
/// whitespace hash is present, kept non-authoritative by `stale_blank_reload_bare` — but it
/// is only a stale echo of a tab we just dropped and is
/// NOT authoritative: adopting it (the branch above) would rename the fresh bootstrap tab
/// back to the dropped blank title, and the trailing `scheduleSave` would persist it again,
/// resurrecting the very blank tab the pruning removed. Keep the default fresh bootstrap
/// tab, and strip the stale `?tab=` from the URL so it matches a first-ever visit (an
/// all-blank workspace falls back to a single fresh tab). The tab bar stays hidden for the
/// lone result-less tab, so `syncHistory` is deliberately not called here (it would re-add
/// a `?tab=` for the default title); a targeted `replaceState` drops the `tab` key. The
/// reloaded entry's state must go too: it still carries the dropped tab's `tabId`/`tabName`
/// (history state survives a reload), and keeping it would make the bootstrap tab's first
/// unrun edit bail out of `refreshCurrentHistoryEntry` (`cur.tabId !== tab.id`), never
/// seeding the entry/URL/`lastSavedQuery` until a run or tab switch. Clear it to null —
/// exactly the state of a first-ever visit, whose first trusted edit seeds a fresh entry
/// owned by the bootstrap tab. When the stale echo carried a whitespace-only hash
/// (`stale_blank_reload_bare`), the pruned tab's own blank text was decoded into the editor
/// and the bootstrap tab's query at load (`query_area.value`, `makeTab` above) and left in
/// the URL fragment, so stripping only `?tab=` is not enough: that blank text would stay in
/// the hash, the editor, and the bootstrap tab, and the trailing `scheduleSave` would
/// persist it straight back to `IndexedDB` as the sole tab — resurrecting the pruned blank
/// tab (now nameless). Reset the bootstrap tab to a genuinely fresh empty one: drop the hash
/// as well, and clear the editor exactly as `restoreEditor` does (blank the textarea
/// and dispatch a synthetic 'input' event, which resyncs the syntax backdrop and parameter
/// inputs and mirrors the empty text into the active tab's query). This matches a first-ever
/// visit precisely, so the next reload sees a single blank tab with no `?tab=` or hash and
/// prunes it again.
///
/// This cleanup assumes the bootstrap tab still holds only the pruned tab's own blank text —
/// true unless the user typed into it while `IndexedDB` was still opening (`bootstrap_dirty`).
/// In that case the live tab is NEWER than the stale echo, exactly as for the merge branch
/// above: `query_area.value = ''` below would silently wipe the live edit (and the
/// dispatched 'input' event would fold that blank text into the tab, discarding it for good
/// before the trailing `scheduleSave` persists it). Keep the live workspace, exactly as the
/// dirty-startup merge above does; the tail of this function already skips URL-param
/// reconciliation and auto-run for a dirty startup, so nothing below re-applies the stale
/// URL over the live edit. But merely skipping the cleanup is not enough: the current
/// entry/URL still belong to the DROPPED blank tab — the live edit could not claim them,
/// because `refreshCurrentHistoryEntry` bails on another tab's entry — while what gets
/// persisted is the live tab under its own id/title. On the NEXT reload nothing is pruned
/// (the live tab holds a real query), so `stale_blank_reload` no longer recognizes the
/// leftover `?tab=`/`history.state` as stale, the named-URL path treats them as
/// authoritative, and a fresh blank tab under the dropped name is recreated alongside the
/// persisted live one. So re-own the entry for the live active tab instead: drop the
/// foreign state first (unless the ids happen to coincide — a pruned legacy `t1` record vs.
/// the `t1` bootstrap tab — where the entry is already the live tab's to replace), then
/// seed it exactly as a first trusted edit on a first-ever visit would, via the same
/// `refreshCurrentHistoryEntry` — which rebuilds the URL, `history.state` and
/// `lastSavedQuery` wholesale from the live tab, dropping the stale `?tab=`, `run=1` and
/// hash along the way (`run_immediately` was already cleared above, so the rebuilt URL
/// cannot re-stamp `run=1` either).
if (url_tab_name) {
if (bootstrap_dirty) {
const live = getActiveTab();
if (live) {
if (history.state && history.state.tabId !== live.id) {
history.replaceState(null, document.title, location.href);
}
refreshCurrentHistoryEntry(live);
}
} else {
const cleaned_url = new URL(location.href);
cleaned_url.searchParams.delete('tab');
/// Drop the stale `run=1` too (`run_immediately` was likewise cleared above): it was
/// the pruned blank tab's directive, and leaving it in the persisted URL would
/// auto-run the fresh bootstrap tab on the next reload (the no-hash case takes the
/// synchronous bottom-of-script `postAll`, so this replaceState is what stops it
/// recurring).
cleaned_url.searchParams.delete('run');
if (stale_blank_reload_bare) {
cleaned_url.hash = '';
query_area.value = '';
query_area.dispatchEvent(new Event('input'));
}
history.replaceState(null, document.title, cleaned_url.href);
}
}
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, and on a
/// blank stale-reload echo (`stale_blank_reload_bare`), whose whitespace hash is not
/// authoritative and must not overwrite the surviving/bootstrap tab's own parameters.
/// 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 && !stale_blank_reload_bare && !preserve_local_query && !bootstrap_dirty) {
const params_ok = await updateQueryParams();
/// The tokenization above is another await: a real edit during it makes the live params
/// newer than the URL's, so re-check the flag and yield to the user rather than overwriting.
if (bootstrap_dirty) {
/// user is editing; leave their live params in place
} else 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). Likewise on a
/// blank stale-reload echo (`stale_blank_reload_bare`): the URL is a stale echo of a pruned
/// blank tab, so its `run=1` must not auto-run whatever query the fallback tab restored. Any
/// trusted action during the startup window that made `writeHistoryEntry` drop `run=1` from the
/// live entry — a database selection, a connection edit, a query/param edit, a tab switch — also
/// cancels the run (`deferred_run_cancelled`): running anyway would execute the shared query
/// against a target or edit the live URL no longer requests it for.
if (defer_run_for_reconcile && !deferred_run_cancelled && !stale_blank_reload_bare && 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)
} else if (reown_adopted_entry) {
/// The no-saved-tabs adopt branch recreated the bootstrap tab for an authoritative URL. Re-own
/// the current entry for it now, after the URL parameters above have been reconciled into the
/// tab, so the rebuilt URL/`history.state`/hash reflect the adopted title, query AND params.
/// The foreign state (if any) was already dropped in that branch, so `refreshCurrentHistoryEntry`
/// seeds the entry in place rather than bailing on another tab's entry. A `run=1` URL took the
/// `postAll` path above instead, whose `saveHistory` owns the entry itself.
refreshCurrentHistoryEntry(getActiveTab());
}
scheduleSave();
/// Reconciliation has fully finished: any later edit is ordinary post-bootstrap interaction,
/// so stop recording it as bootstrap-dirty. (`postAll` above is fire-and-forget and never
/// marks the flag itself, so closing it here does not affect the deferred auto-run.)
bootstrap_settled = true;
}
/// 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();
}
/// No top-level auto-run: every startup `run=1` is deferred to `reconcileStartup` (see
/// `defer_run_for_reconcile`). Running it here would fire before the workspace is reconciled —
/// in the wrong (bootstrap) tab, and, for a bare `?tab=<blank>&run=1` reload, early enough to
/// overwrite the `history.state` that `stale_blank_reload` relies on and resurrect the blank tab.
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 = {};
/// The default database that produced the downloadable result (snapshotted at run time and on
/// restore), so Download stays tied to the displayed result even if the panel selection changes.
let last_database_for_download = '';
/// The server address and user that produced the downloadable result, snapshotted alongside the
/// query/params/database (at run time and on history restore) so Download reproduces the shown
/// result against the connection that produced it, not the current live connection: after running
/// on one server and then switching the connection — or navigating Back to an entry produced on
/// another server while the live connection stays elsewhere — Download would otherwise re-run the
/// query on the live server and could export different data than the result on screen. The
/// password is intentionally not snapshotted (it must never be stored in browser history); the
/// live password is used, so a mismatched connection surfaces as a visible authentication error
/// rather than silently exporting data from the wrong server.
let last_url_for_download = '';
let last_user_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();
// Reproduce the shown result against the connection that produced it, not the current live
// connection: after switching servers (or navigating Back to an entry produced elsewhere),
// the live connection may differ from the one behind the visible result. The password is not
// snapshotted (never stored in history), so the live password is used — a mismatched
// connection then surfaces as a visible authentication error rather than silently exporting
// data from the wrong server.
const user = last_user_for_download;
const password = password_elem.value;
const server_address = last_url_for_download || 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);
if (last_database_for_download) url += '&database=' + encodeURIComponent(last_database_for_download);
for (const [name, value] of Object.entries(last_params_for_download))
{
url += '&param_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
}
let downloadQuery = query.replaceAll(/\bFORMAT\s+\w+/ig, '');
// Create a form and submit it to trigger download
const form = document.createElement('form');
form.method = 'POST';
form.action = url;
form.enctype = 'multipart/form-data';
form.style.display = 'none';
const queryInput = document.createElement('input');
queryInput.type = 'hidden';
queryInput.name = 'query';
queryInput.value = downloadQuery;
form.appendChild(queryInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
// Close dropdown
document.getElementById('download-dropdown').classList.remove('show');
});
// Web Terminal integration
{
const terminalIcon = document.getElementById('terminal-icon');
const terminalPanel = document.getElementById('terminal-panel');
const terminalResizer = document.getElementById('terminal-resizer');
const mainEl = document.getElementById('main');
let terminalIframe = null;
let terminalActive = false;
let terminalHiddenByResize = false;
/// Origin the iframe was loaded from, used as the target origin for `postMessage`.
/// Captured once when the iframe is created so it stays in sync with `iframe.src`
/// even if the user later edits the server URL field.
let terminalOrigin = null;
const MIN_TERMINAL_HEIGHT = 20;
/// The `/webterminal` endpoint lives on the Datastore 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, Shift+click, or the platform's new-tab modifier click (Cmd+click on
/// macOS, Ctrl+click elsewhere) 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();
}
/// True for a modified click that should defer to the anchor's native handling rather
/// than toggling the inline terminal: Shift+click or the platform's new-tab modifier
/// click (Cmd+click on macOS, Ctrl+click elsewhere) opens a new tab or window, while
/// macOS Ctrl+click is the secondary/context-menu click. (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 modified click, fall through to the anchor's native handling (a new tab or
/// window, or the macOS Ctrl+click context menu) instead of toggling the terminal.
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>