mirror of
https://github.com/hanzoai/datastore.git
synced 2026-09-26 13:55:07 +00:00
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]>
12654 lines
644 KiB
HTML
12654 lines
644 KiB
HTML
<!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"> (<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/">☁</a>
|
||
</p>
|
||
<div id="download-dropdown">
|
||
<div>
|
||
<div class="download-option">
|
||
<label for="download-format">Format:</label>
|
||
<select id="download-format">
|
||
<option value="CSV">CSV</option>
|
||
<option value="CSVWithNames">CSVWithNames</option>
|
||
<option value="TSV">TSV</option>
|
||
<option value="JSON">JSON</option>
|
||
<option value="NDJSON" selected>JSONLines</option>
|
||
<option value="Parquet">Parquet</option>
|
||
<option value="Markdown">Markdown</option>
|
||
<option value="other">Other...</option>
|
||
</select>
|
||
</div>
|
||
<input type="text" id="download-format-other" placeholder="Enter format name (e.g., Arrow, Avro)" />
|
||
</div>
|
||
<button id="download-button">Download</button>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
|
||
<script type="text/javascript">
|
||
|
||
class QueryProgressElement extends HTMLElement
|
||
{
|
||
constructor()
|
||
{
|
||
super();
|
||
this.attachShadow({ mode: 'open' });
|
||
this.shadowRoot.innerHTML = `
|
||
<style>
|
||
:host
|
||
{
|
||
display: grid;
|
||
/* Progress and stats share the available width equally, and both can
|
||
shrink below their content size (the zero minimum), so they resize
|
||
uniformly when the row is narrow instead of one hogging the space. */
|
||
grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 1rem;
|
||
align-items: center;
|
||
}
|
||
|
||
#hourglass, #check-mark
|
||
{
|
||
grid-column: 1;
|
||
grid-row: 1;
|
||
}
|
||
|
||
#hourglass
|
||
{
|
||
display: none;
|
||
font-size: 110%;
|
||
color: #888;
|
||
animation: hourglass-animation 2s linear infinite;
|
||
}
|
||
|
||
#check-mark
|
||
{
|
||
display: none;
|
||
font-size: 110%;
|
||
color: #080;
|
||
}
|
||
|
||
#progress
|
||
{
|
||
display: none;
|
||
width: 100%;
|
||
color: var(--misc-text-color);
|
||
background: linear-gradient(to right,
|
||
transparent 0%,
|
||
var(--progress-color) var(--progress, 0%),
|
||
transparent var(--progress, 0%));
|
||
}
|
||
|
||
#stats
|
||
{
|
||
color: var(--misc-text-color);
|
||
}
|
||
|
||
#stats b
|
||
{
|
||
color: var(--progress-color);
|
||
}
|
||
|
||
@keyframes hourglass-animation
|
||
{
|
||
0% { transform: none; }
|
||
25% { transform: rotate(180deg); }
|
||
50% { transform: rotate(180deg); }
|
||
75% { transform: rotate(360deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
</style>
|
||
<span id="hourglass">⧗</span>
|
||
<span id="check-mark">✔</span>
|
||
<span id="progress"></span>
|
||
<span id="stats"></span>
|
||
`;
|
||
this._hourglass = this.shadowRoot.getElementById('hourglass');
|
||
this._checkMark = this.shadowRoot.getElementById('check-mark');
|
||
this._progress = this.shadowRoot.getElementById('progress');
|
||
this._stats = this.shadowRoot.getElementById('stats');
|
||
}
|
||
|
||
clear()
|
||
{
|
||
this._hourglass.style.display = 'none';
|
||
this._checkMark.style.display = 'none';
|
||
this._stats.innerText = '';
|
||
this._progress.style.display = 'none';
|
||
this._progress.innerText = '';
|
||
this.style.setProperty('--progress', '0%');
|
||
}
|
||
|
||
start()
|
||
{
|
||
this._checkMark.style.display = 'none';
|
||
this._hourglass.style.display = 'inline-block';
|
||
}
|
||
|
||
finish()
|
||
{
|
||
this._hourglass.style.display = 'none';
|
||
this._checkMark.style.display = 'inline-block';
|
||
}
|
||
|
||
updateProgress(progress)
|
||
{
|
||
const rows = +progress.read_rows;
|
||
const bytes = +progress.read_bytes;
|
||
const total_rows = +progress.total_rows_to_read;
|
||
const elapsed_ns = +progress.elapsed_ns;
|
||
|
||
let formatted_rows = formatReadableRows(rows);
|
||
let formatted_bytes = formatReadableBytes(bytes);
|
||
|
||
const rps = rows * 1e9 / elapsed_ns;
|
||
const bps = bytes * 1e9 / elapsed_ns;
|
||
|
||
let formatted_rps = formatReadableRows(rps) + '/sec';
|
||
let formatted_bps = formatReadableBytes(bps) + '/sec';
|
||
|
||
if (rows >= 1e11) { formatted_rows = `<b>${formatted_rows}<\/b>`; }
|
||
if (bytes >= 1e12) { formatted_bytes = `<b>${formatted_bytes}<\/b>`; }
|
||
if (rps >= 1e10) { formatted_rps = `<b>${formatted_rps}<\/b>`; }
|
||
if (bps >= 1e10) { formatted_bps = `<b>${formatted_bps}<\/b>`; }
|
||
|
||
let text = '';
|
||
|
||
if (total_rows) { text += (100 * Math.min(1.0, rows / total_rows)).toFixed(1) + '%, '; }
|
||
|
||
text += `Read ${formatted_rows} rows, ${formatted_bytes}`;
|
||
|
||
if (rps > 1e6) { text += ` (${formatted_rps}, ${formatted_bps})`; }
|
||
|
||
this._stats.innerHTML = text;
|
||
|
||
this.style.setProperty('--progress',
|
||
rows && total_rows ? (100 * rows / total_rows) + '%' : '0');
|
||
}
|
||
|
||
updateText(rowCount, elapsedNs, incomplete)
|
||
{
|
||
let progress_text = '';
|
||
if (rowCount)
|
||
{
|
||
progress_text += `${rowCount}${incomplete ? '+' : ''} rows in result`;
|
||
if (elapsedNs) progress_text += ', ';
|
||
}
|
||
if (elapsedNs) { progress_text += `${(elapsedNs / 1e9).toFixed(2)} sec.`; }
|
||
this._progress.innerText = progress_text;
|
||
this._progress.style.display = 'block';
|
||
}
|
||
}
|
||
customElements.define('query-progress', QueryProgressElement);
|
||
|
||
|
||
/// Per-column color coding modes chosen by the user, keyed by column name. Only columns with
|
||
/// an explicit (non-default) choice are stored. 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`,
|
||
'¶m_database=' + encodeURIComponent(database) + '¶m_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`,
|
||
'¶m_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 `<` (for `<`) and `&` (for `&`). Unescape `&` last so a literal `<`
|
||
/// in the message (written as `&lt;`) is not turned into `<`.
|
||
function unescapeXMLText(s)
|
||
{
|
||
return s.replace(/</g, '<').replace(/&/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 (`<` → `<`), 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 += '¶m_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
|
||
}
|
||
|
||
/// Extremes only if the format is likely to be unchanged. This is imprecise.
|
||
if (!query.match(/\bFORMAT\s+\w+/i)) url += '&extremes=1';
|
||
|
||
targetResultEl.queryText = query;
|
||
|
||
let is_table = false;
|
||
let is_raw = false;
|
||
let is_chart = false;
|
||
let is_image = false;
|
||
let is_error = false;
|
||
|
||
/// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/// Numeric TokenType values, matching the order of the C++ enum in src/Parsers/Lexer.h.
|
||
/// Only the categories we actually classify are named; everything else is treated as
|
||
/// "default" (unstyled) and renders in the canonical text color via the diff blend.
|
||
const TT = {
|
||
Whitespace: 0, Comment: 1, BareWord: 2, Number: 3, StringLiteral: 4, QuotedIdentifier: 5,
|
||
OpeningRoundBracket: 6, ClosingRoundBracket: 7,
|
||
OpeningSquareBracket: 8, ClosingSquareBracket: 9,
|
||
OpeningCurlyBrace: 10, ClosingCurlyBrace: 11,
|
||
Semicolon: 13, Asterisk: 16, HereDoc: 17, DollarSign: 18,
|
||
Plus: 19, Minus: 20, Slash: 21, Percent: 22, Arrow: 23,
|
||
QuestionMark: 24, Colon: 25, Caret: 26, DoubleColon: 27,
|
||
Equals: 28, NotEquals: 29, Less: 30, Greater: 31,
|
||
LessOrEquals: 32, GreaterOrEquals: 33, Spaceship: 34,
|
||
PipeMark: 35, Concatenation: 36, At: 37, DoubleAt: 38,
|
||
};
|
||
|
||
/// SQL keywords recognized for highlighting. The lexer reports them as BareWord, so we
|
||
/// disambiguate identifiers from keywords here. Comparisons are case-insensitive.
|
||
const SQL_KEYWORDS = new Set([
|
||
'ADD', 'AFTER', 'ALL', 'ALTER', 'AND', 'ANTI', 'ANY', 'ARRAY', 'AS', 'ASC', 'ASCENDING',
|
||
'ASOF', 'AST', 'ASYNC', 'ATTACH', 'BACKUP', 'BEGIN', 'BETWEEN', 'BOTH', 'BY',
|
||
'CACHE', 'CASCADE', 'CASE', 'CAST', 'CHANGE', 'CHANGED', 'CHECK', 'CLEAR', 'CLUSTER',
|
||
'CODEC', 'COLLATE', 'COLUMN', 'COLUMNS', 'COMMENT', 'COMMIT', 'CONSTRAINT', 'CREATE',
|
||
'CROSS', 'CUBE', 'CURRENT',
|
||
'DATABASE', 'DATABASES', 'DAY', 'DEDUPLICATE', 'DEFAULT', 'DELETE', 'DESC', 'DESCENDING',
|
||
'DESCRIBE', 'DETACH', 'DICTIONARIES', 'DICTIONARY', 'DISK', 'DISTINCT', 'DISTRIBUTED',
|
||
'DROP', 'ELSE', 'END', 'ENGINE', 'ESTIMATE', 'EVENTS', 'EXCEPT', 'EXCHANGE', 'EXISTS',
|
||
'EXPLAIN', 'EXPRESSION', 'EXTENDED', 'EXTRACT',
|
||
'FALSE', 'FETCH', 'FETCHES', 'FILE', 'FILESYSTEM', 'FINAL', 'FIRST', 'FLUSH', 'FOLLOWING',
|
||
'FOR', 'FOREIGN', 'FORMAT', 'FREEZE', 'FROM', 'FULL', 'FUNCTION',
|
||
'GLOBAL', 'GRANT', 'GROUP', 'GROUPS', 'HAVING', 'HIERARCHICAL', 'HOUR',
|
||
'ID', 'IDENTIFIED', 'IF', 'ILIKE', 'IN', 'INDEX', 'INF', 'INHERIT', 'INJECTIVE',
|
||
'INNER', 'INSERT', 'INTERSECT', 'INTERVAL', 'INTO', 'INVISIBLE', 'IS', 'IS_OBJECT_ID',
|
||
'JOIN', 'KEY', 'KEYED', 'KILL',
|
||
'LAST', 'LATERAL', 'LAYOUT', 'LEADING', 'LEFT', 'LIFETIME', 'LIKE', 'LIMIT', 'LIMITS',
|
||
'LIVE', 'LOCAL', 'LOGS',
|
||
'MATERIALIZE', 'MATERIALIZED', 'MAX', 'MERGES', 'MICROSECOND', 'MILLISECOND', 'MIN',
|
||
'MINUTE', 'MODIFY', 'MONTH', 'MOVE', 'MUTATION',
|
||
'NAN_SQL', 'NEXT', 'NO', 'NONE', 'NOT', 'NULL', 'NULLS',
|
||
'OFFSET', 'ON', 'ONLY', 'OPTIMIZE', 'OPTION', 'OR', 'ORDER', 'OUTER', 'OUTFILE', 'OVER',
|
||
'PARTITION', 'PASTE', 'PERMANENTLY', 'PLAN', 'POPULATE', 'PRECEDING', 'PRECISION',
|
||
'PREWHERE', 'PRIMARY', 'PROFILE', 'PROJECTION', 'QUARTER', 'QUERY', 'QUOTA',
|
||
'RANDOMIZED', 'RANGE', 'RECURSIVE', 'REFRESH', 'REGEXP', 'RELOAD', 'REMOTE', 'RENAME',
|
||
'REPLACE', 'REPLICA', 'REPLICAS', 'RESET', 'RESTORE', 'RESTRICT', 'RESTRICTIVE',
|
||
'RETURNS', 'REVOKE', 'RIGHT', 'ROLE', 'ROLLBACK', 'ROLLUP', 'ROW', 'ROWS',
|
||
'SAMPLE', 'SECOND', 'SELECT', 'SEMI', 'SENDS', 'SET', 'SETS', 'SETTINGS', 'SHARD',
|
||
'SHOW', 'SIGNED', 'SOURCE', 'SQL_SECURITY', 'START', 'STEP', 'STORAGE', 'STRICT',
|
||
'STRICTLY_ASCENDING', 'SUBPARTITION', 'SUBSTRING', 'SUSPEND', 'SYNC', 'SYNTAX', 'SYSTEM',
|
||
'TABLE', 'TABLES', 'TEMPORARY', 'TEST', 'THEN', 'TIES', 'TIMESTAMP', 'TO', 'TOP',
|
||
'TOTALS', 'TRACKING', 'TRAILING', 'TRANSACTION', 'TRIGGER', 'TRIM', 'TRUE', 'TRUNCATE',
|
||
'TYPE',
|
||
'UNBOUNDED', 'UNFREEZE', 'UNION', 'UNIQUE', 'UNSIGNED', 'UPDATE', 'USE', 'USING',
|
||
'UUID', 'VALUES', 'VARYING', 'VIEW', 'VIRTUAL', 'VISIBLE',
|
||
'WATCH', 'WEEK', 'WHEN', 'WHERE', 'WINDOW', 'WITH', 'WORK', 'WRITABLE',
|
||
'XOR', 'YEAR', 'ZKPATH',
|
||
]);
|
||
|
||
/// Bracket token types, grouped by kind. Used for rainbow parentheses (coloring by
|
||
/// nesting depth) and matched-bracket highlighting.
|
||
const OPENING_BRACKETS = new Set([TT.OpeningRoundBracket, TT.OpeningSquareBracket, TT.OpeningCurlyBrace]);
|
||
const CLOSING_BRACKETS = new Set([TT.ClosingRoundBracket, TT.ClosingSquareBracket, TT.ClosingCurlyBrace]);
|
||
/// The closing type that matches each opening type.
|
||
const BRACKET_PAIR = {
|
||
[TT.OpeningRoundBracket]: TT.ClosingRoundBracket,
|
||
[TT.OpeningSquareBracket]: TT.ClosingSquareBracket,
|
||
[TT.OpeningCurlyBrace]: TT.ClosingCurlyBrace,
|
||
};
|
||
/// Number of distinct rainbow colors before the cycle repeats (see the --syntax-bracket-* vars).
|
||
const RAINBOW_BRACKET_COUNT = 7;
|
||
|
||
/// Compute, for every token, its rainbow-bracket depth (-1 for non-brackets and for
|
||
/// unmatched brackets) and the set of bracket tokens to embolden as the matched pair around
|
||
/// the cursor.
|
||
///
|
||
/// Depth is assigned with a type-aware stack, but only once a bracket has a real mate: the
|
||
/// matching opening and closing brackets both take the pair's nesting depth so they share a
|
||
/// color. A bracket without a counterpart — a lone `(`, a stray `)`, or a mismatched pair —
|
||
/// is left at depth -1 so it keeps the default color instead of a rainbow one, mirroring
|
||
/// `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 += '¶m_' + encodeURIComponent(name) + '=' + encodeURIComponent(value);
|
||
}
|
||
|
||
let downloadQuery = query.replaceAll(/\bFORMAT\s+\w+/ig, '');
|
||
|
||
// Create a form and submit it to trigger download
|
||
const form = document.createElement('form');
|
||
form.method = 'POST';
|
||
form.action = url;
|
||
form.enctype = 'multipart/form-data';
|
||
form.style.display = 'none';
|
||
|
||
const queryInput = document.createElement('input');
|
||
queryInput.type = 'hidden';
|
||
queryInput.name = 'query';
|
||
queryInput.value = downloadQuery;
|
||
form.appendChild(queryInput);
|
||
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
document.body.removeChild(form);
|
||
|
||
// Close dropdown
|
||
document.getElementById('download-dropdown').classList.remove('show');
|
||
});
|
||
|
||
// Web Terminal integration
|
||
{
|
||
const terminalIcon = document.getElementById('terminal-icon');
|
||
const terminalPanel = document.getElementById('terminal-panel');
|
||
const terminalResizer = document.getElementById('terminal-resizer');
|
||
const mainEl = document.getElementById('main');
|
||
let terminalIframe = null;
|
||
let terminalActive = false;
|
||
let terminalHiddenByResize = false;
|
||
/// Origin the iframe was loaded from, used as the target origin for `postMessage`.
|
||
/// Captured once when the iframe is created so it stays in sync with `iframe.src`
|
||
/// even if the user later edits the server URL field.
|
||
let terminalOrigin = null;
|
||
const MIN_TERMINAL_HEIGHT = 20;
|
||
|
||
/// The `/webterminal` endpoint lives on the 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>
|