tablewalk 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client: boot, chrome, and dispatch to a view.
|
|
3
|
+
*
|
|
4
|
+
* Plain ES modules, no framework and no build step, because the promise of
|
|
5
|
+
* this tool is `npx tablewalk mydb.sqlite` and nothing else. That constraint
|
|
6
|
+
* is worth the hand-written DOM: a bundler in the pipeline would mean a
|
|
7
|
+
* `dist/` to keep current and a reason for the published package to drift
|
|
8
|
+
* from the source. Browsers resolve the imports below natively, so the split
|
|
9
|
+
* into modules costs nothing at runtime.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is wiring. The views live in their own files and reach the
|
|
12
|
+
* shared state through core.js, which imports nothing itself.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
$, api, backTo, connectionChanged, currentEntry, el, explainVisible, findTable, go, pageView, render,
|
|
16
|
+
setCrumbRenderer, setExplaining, setRenderer, state, tableView, toast,
|
|
17
|
+
breakdownView, svgEl,
|
|
18
|
+
} from './core.js';
|
|
19
|
+
import { renderTable } from './table.js';
|
|
20
|
+
import { renderRow } from './record.js';
|
|
21
|
+
import { schemaDiagram } from './diagram.js';
|
|
22
|
+
import { initWriteMode } from './write.js';
|
|
23
|
+
import { initConnections, refreshConnections } from './connections.js';
|
|
24
|
+
import { listConnections, showWelcome } from './welcome.js';
|
|
25
|
+
import { initTour } from './tour.js';
|
|
26
|
+
import { openAgentConsole } from './agent.js';
|
|
27
|
+
import { openFindValue } from './findvalue.js';
|
|
28
|
+
import { openShapeCheck } from './shapecheck.js';
|
|
29
|
+
import { openConnections } from './connmanager.js';
|
|
30
|
+
import { looksLikeBreakdown, renderBreakdown } from './breakdown.js';
|
|
31
|
+
import { openAsk } from './ask.js';
|
|
32
|
+
import { cycleTheme, initTheme, themeLabel } from './theme.js';
|
|
33
|
+
import { allSavedFilters, savedViewsBar } from './views.js';
|
|
34
|
+
import { allowSync, applyHash, connectionFromHash, fromHash, resolveLabels, syncHash, whileApplying } from './url.js';
|
|
35
|
+
import { initComplete } from './complete.js';
|
|
36
|
+
import { addMenuItem, initMenu, setMenuGroup } from './menu.js';
|
|
37
|
+
import { initSql, openSql } from './sql.js';
|
|
38
|
+
import { schemaSummary } from './summary.js';
|
|
39
|
+
import { pagesFor, renderPage, whenPagesChange } from './page.js';
|
|
40
|
+
import { pinnedPages, recentVisits } from './pagemarks.js';
|
|
41
|
+
import { loadSavedPages } from './pagebuilder.js';
|
|
42
|
+
import { initPeek } from './peek.js';
|
|
43
|
+
import { initUndo, refreshUndo, undoDescription, undoLast } from './undo.js';
|
|
44
|
+
import { loadSavedSql } from './sqlsaved.js';
|
|
45
|
+
import { loadTemplates } from './template.js';
|
|
46
|
+
import { initPalette, initSidebarToggle, openPalette } from './palette.js';
|
|
47
|
+
import { initHelp } from './help.js';
|
|
48
|
+
/* Imported for its side effect: composer.js attaches its own entry point, the
|
|
49
|
+
way history.js does. Loading it from a stylesheet's onload worked, but a
|
|
50
|
+
script that runs because a CSS file finished downloading is a dependency
|
|
51
|
+
nobody would guess from reading either file. */
|
|
52
|
+
import './composer.js';
|
|
53
|
+
|
|
54
|
+
/* ---------- dispatch ---------- */
|
|
55
|
+
|
|
56
|
+
function paint() {
|
|
57
|
+
const content = $('content');
|
|
58
|
+
const entry = currentEntry();
|
|
59
|
+
// Every render is a state change worth linking to, so the address bar is
|
|
60
|
+
// updated here rather than at each of the dozen places that call go().
|
|
61
|
+
syncHash();
|
|
62
|
+
if (!entry) {
|
|
63
|
+
// With nothing selected, the most useful thing to show is the shape of
|
|
64
|
+
// the database itself — the whole point of the tool, before a click.
|
|
65
|
+
highlightTable(null);
|
|
66
|
+
content.replaceChildren(el('div', { class: 'panel-pad' }, [
|
|
67
|
+
/* Saved filters come first. Someone who has saved one is coming back to
|
|
68
|
+
it; making them re-derive the table from a diagram every time is the
|
|
69
|
+
landing page failing at its only job. */
|
|
70
|
+
/* Two columns: what you were doing, beside what this database is.
|
|
71
|
+
|
|
72
|
+
They answer different questions and both are short, so stacking them
|
|
73
|
+
pushed the schema — the thing a first-time reader needs — below the
|
|
74
|
+
fold for anyone who had ever saved anything. Side by side, both are
|
|
75
|
+
above it.
|
|
76
|
+
|
|
77
|
+
A first visit has no work to resume, and `workPanel` returns nothing
|
|
78
|
+
at all rather than an empty card; the survivor is then an only child
|
|
79
|
+
and takes the whole width, which is the layout that page wants and
|
|
80
|
+
needs no branch to say so. */
|
|
81
|
+
el('div', { class: 'landing-top' }, [
|
|
82
|
+
/* The database first, on the left where reading starts: it is the
|
|
83
|
+
answer for everyone, including the person who has never been here.
|
|
84
|
+
What you were doing sits beside it, because it is only an answer
|
|
85
|
+
once there is some. */
|
|
86
|
+
schemaSummary(),
|
|
87
|
+
/* One card, not four bands. Saved filters, pinned pages, where you
|
|
88
|
+
were and every page there is were four full-width sections, each
|
|
89
|
+
with its own heading and none the same width — a pile rather than
|
|
90
|
+
an answer. They are all work you already did, waiting to be
|
|
91
|
+
resumed. */
|
|
92
|
+
workPanel(),
|
|
93
|
+
].filter(Boolean)),
|
|
94
|
+
schemaDiagram(),
|
|
95
|
+
].filter(Boolean)));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
highlightTable(entry.table);
|
|
99
|
+
content.replaceChildren(breadcrumb());
|
|
100
|
+
if (entry.kind === 'table') {
|
|
101
|
+
/* Saved filters sit above the view tabs and below the trail: they are a
|
|
102
|
+
property of the table you are on, not of the walk that got you here. */
|
|
103
|
+
const views = savedViewsBar(entry);
|
|
104
|
+
if (views) content.append(views);
|
|
105
|
+
void renderTable(entry, content);
|
|
106
|
+
} else if (entry.kind === 'breakdown') {
|
|
107
|
+
/* Named explicitly, like `page` below and for the same reason: a dispatch
|
|
108
|
+
whose last branch is "or else a row" turns every kind it has not heard
|
|
109
|
+
of into a record view asking for a key nobody supplied. */
|
|
110
|
+
void renderBreakdown(entry, content);
|
|
111
|
+
} else if (entry.kind === 'page') {
|
|
112
|
+
/* Named explicitly rather than left to the `else`. This dispatch used to
|
|
113
|
+
be "table, or else a row", so any third kind of entry silently rendered
|
|
114
|
+
as a record — a page would have asked for the row `undefined` and shown
|
|
115
|
+
an error about a key nobody supplied. */
|
|
116
|
+
void renderPage(entry, content);
|
|
117
|
+
} else {
|
|
118
|
+
void renderRow(entry, content);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The landing page's shortcut back to work.
|
|
124
|
+
*
|
|
125
|
+
* Absent entirely when nothing is saved — an empty "Saved" heading above a
|
|
126
|
+
* schema diagram is a promise the tool has not kept yet.
|
|
127
|
+
*/
|
|
128
|
+
/**
|
|
129
|
+
* Everything you already did, in one card.
|
|
130
|
+
*
|
|
131
|
+
* Three questions in the order they are asked: what am I coming back to
|
|
132
|
+
* (pinned), where was I (a page *and* the record), and what else is there
|
|
133
|
+
* (saved filters, then every page, behind a filter box because a database
|
|
134
|
+
* with forty pages is one where finding a page matters more than seeing
|
|
135
|
+
* them all).
|
|
136
|
+
*/
|
|
137
|
+
function workPanel() {
|
|
138
|
+
const groups = [];
|
|
139
|
+
const pages = knownPages();
|
|
140
|
+
const byId = new Map(pages.map((p) => [p.id, p]));
|
|
141
|
+
const openPage = (pageId, base, key, label) =>
|
|
142
|
+
go(pageView(pageId, byId.get(pageId)?.base ?? base, key, label), 'reset');
|
|
143
|
+
|
|
144
|
+
/* A link, not a card.
|
|
145
|
+
|
|
146
|
+
Four groups of two-line boxes made a wall of rectangles in four
|
|
147
|
+
different widths — "way more broken/jumbled up", and fairly. The card
|
|
148
|
+
under this one has answered four questions for months as a dim label
|
|
149
|
+
and a line of links, and nobody has ever complained about it. So this
|
|
150
|
+
card uses the same idiom: the label says which question, the links are
|
|
151
|
+
the answer, and there is one box on the screen instead of eleven. */
|
|
152
|
+
const link = (name, detail, title, onclick) => el('button', {
|
|
153
|
+
type: 'button', class: 'landing-link', title, onclick,
|
|
154
|
+
}, [
|
|
155
|
+
el('span', { class: 'landing-link-name', text: name }),
|
|
156
|
+
detail ? el('span', { class: 'landing-link-detail', text: detail }) : null,
|
|
157
|
+
].filter(Boolean));
|
|
158
|
+
|
|
159
|
+
const line = (label, content) => el('li', { class: 'shape-line' }, [
|
|
160
|
+
el('span', { class: 'shape-label', text: label }),
|
|
161
|
+
el('span', { class: 'landing-links' }, content),
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const pinned = pinnedPages();
|
|
165
|
+
if (pinned.length) {
|
|
166
|
+
groups.push(line('Pinned', pinned.map((page) =>
|
|
167
|
+
link(page.name, page.base, `A page about one ${page.base}`, () => openPage(page.id, page.base)))));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* A position, not a page: the record is what makes it worth going back to,
|
|
171
|
+
and a list of page names would send you to the chooser you already got
|
|
172
|
+
past. */
|
|
173
|
+
const positions = recentVisits().filter((v) => v.label);
|
|
174
|
+
if (positions.length) {
|
|
175
|
+
groups.push(line('Where you were', positions.slice(0, 6).map((visit) =>
|
|
176
|
+
link(visit.label, visit.name, `${visit.name} — ${visit.label}`,
|
|
177
|
+
() => openPage(visit.pageId, visit.base, visit.key, visit.label)))));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const saved = allSavedFilters();
|
|
181
|
+
if (saved.length) {
|
|
182
|
+
groups.push(line('Saved filters', saved.map((view) =>
|
|
183
|
+
link(view.name, view.table, view.query, () => go(tableView(view.table, view.query), 'reset')))));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (pages.length) {
|
|
187
|
+
const list = el('div', { class: 'landing-page-list' });
|
|
188
|
+
const draw = (needle) => {
|
|
189
|
+
const shown = pages.filter((p) => `${p.name} ${p.base}`.toLowerCase().includes(needle));
|
|
190
|
+
list.replaceChildren(...(shown.length
|
|
191
|
+
? shown.map((page) => el('button', {
|
|
192
|
+
type: 'button',
|
|
193
|
+
class: 'landing-page',
|
|
194
|
+
onclick: () => openPage(page.id, page.base),
|
|
195
|
+
}, [
|
|
196
|
+
el('span', { class: 'landing-page-name', text: page.name }),
|
|
197
|
+
el('span', { class: 'landing-page-base', text: page.base }),
|
|
198
|
+
el('span', {
|
|
199
|
+
class: 'landing-page-source',
|
|
200
|
+
text: page.source === 'config' ? 'config' : page.source === 'saved' ? 'yours' : 'suggested',
|
|
201
|
+
}),
|
|
202
|
+
]))
|
|
203
|
+
: [el('p', { class: 'note', text: 'No page of that name.' })]));
|
|
204
|
+
};
|
|
205
|
+
draw('');
|
|
206
|
+
|
|
207
|
+
groups.push(el('li', { class: 'shape-line landing-all' }, [
|
|
208
|
+
el('span', { class: 'shape-label', text: `All pages (${pages.length})` }),
|
|
209
|
+
el('div', {}, [
|
|
210
|
+
el('input', {
|
|
211
|
+
type: 'search',
|
|
212
|
+
class: 'landing-page-filter',
|
|
213
|
+
placeholder: 'Filter pages…',
|
|
214
|
+
'aria-label': 'Filter pages',
|
|
215
|
+
oninput: (e) => draw(e.target.value.trim().toLowerCase()),
|
|
216
|
+
}),
|
|
217
|
+
list,
|
|
218
|
+
]),
|
|
219
|
+
]));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* Nothing to resume is not an empty card: on a database nobody has worked
|
|
223
|
+
in yet, the schema below really is the most useful thing on screen. */
|
|
224
|
+
if (!groups.length) return null;
|
|
225
|
+
|
|
226
|
+
return el('section', { class: 'landing-pages shape' }, [
|
|
227
|
+
el('h2', { class: 'landing-heading', text: 'Your work' }),
|
|
228
|
+
el('ul', { class: 'shape-lines' }, groups),
|
|
229
|
+
]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* The pages this connection has, as the last read left them. Synchronous,
|
|
233
|
+
because the landing is drawn before anything is fetched — `registerPages`
|
|
234
|
+
has already filled the cache by the time anyone has a page to come back
|
|
235
|
+
to, and a first visit has none to show anyway. */
|
|
236
|
+
let landingPages = [];
|
|
237
|
+
const knownPages = () => landingPages;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Back to the schema, from anywhere.
|
|
241
|
+
*
|
|
242
|
+
* At module scope because the trail needs it as well as the wordmark — see
|
|
243
|
+
* the root crumb in `breadcrumb`.
|
|
244
|
+
*/
|
|
245
|
+
function goHome() {
|
|
246
|
+
state.stack = [];
|
|
247
|
+
state.page = 0;
|
|
248
|
+
$('query').value = '';
|
|
249
|
+
render();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function breadcrumb() {
|
|
253
|
+
const bar = el('nav', { class: 'crumbs', 'aria-label': 'Trail' });
|
|
254
|
+
|
|
255
|
+
/* The trail starts at the schema, and says so.
|
|
256
|
+
|
|
257
|
+
It used to start at whatever you were looking at, so a link opened deep —
|
|
258
|
+
which is how most links are opened — offered no way back to the landing
|
|
259
|
+
page at all. The wordmark has always been that button, but a wordmark is
|
|
260
|
+
not where anybody looks for one, and nothing on screen said it was
|
|
261
|
+
clickable. A trail whose first step is missing is the one place the
|
|
262
|
+
absence is obvious, so that is where it goes. */
|
|
263
|
+
/* An icon rather than the word "Schema".
|
|
264
|
+
|
|
265
|
+
A house is the one glyph everybody already reads as "back to the start",
|
|
266
|
+
and it does not compete with the table names beside it for the eye — the
|
|
267
|
+
crumbs are text, so the one thing in the trail that is not a step should
|
|
268
|
+
not look like one. Drawn rather than typed: `⌂` renders at wildly
|
|
269
|
+
different weights across platforms and is missing outright on some. */
|
|
270
|
+
bar.append(el('button', {
|
|
271
|
+
type: 'button',
|
|
272
|
+
class: 'crumb-home',
|
|
273
|
+
title: 'The schema, your work, the diagram (h)',
|
|
274
|
+
'aria-label': 'Schema',
|
|
275
|
+
onclick: goHome,
|
|
276
|
+
}, svgEl('svg', {
|
|
277
|
+
viewBox: '0 0 16 16', width: '17', height: '17', 'aria-hidden': 'true',
|
|
278
|
+
fill: 'none', stroke: 'currentColor', 'stroke-width': '1.5',
|
|
279
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
280
|
+
}, [
|
|
281
|
+
svgEl('path', { d: 'M2 7.2 8 2.4l6 4.8' }),
|
|
282
|
+
svgEl('path', { d: 'M3.4 6.6V13a.6.6 0 0 0 .6.6h8a.6.6 0 0 0 .6-.6V6.6' }),
|
|
283
|
+
])));
|
|
284
|
+
|
|
285
|
+
state.stack.forEach((entry, i) => {
|
|
286
|
+
bar.append(el('span', { class: 'sep', text: '›' }));
|
|
287
|
+
const last = i === state.stack.length - 1;
|
|
288
|
+
|
|
289
|
+
/* A row crumb names its table when the walk has changed table — which is
|
|
290
|
+
exactly what following a foreign key does. Without it a trail reads
|
|
291
|
+
`work_order › WO-100008 › Southerly Surveying`, and nothing says that
|
|
292
|
+
the last step landed on a customer. Repeating the table when it has not
|
|
293
|
+
changed would be noise: `work_order › work_order WO-100008` says
|
|
294
|
+
nothing the crumb before it did not. */
|
|
295
|
+
const previousTable = i > 0 ? state.stack[i - 1].table : null;
|
|
296
|
+
/* A page crumb is a record crumb that happens to be drawn as a page, so
|
|
297
|
+
it names its table on the same rule. A page with no root chosen has no
|
|
298
|
+
label yet and is named by its table alone. */
|
|
299
|
+
const named = entry.kind === 'row' || entry.kind === 'page';
|
|
300
|
+
const showsTable = named && entry.label != null && entry.table !== previousTable;
|
|
301
|
+
/* A page reached by link knows its own name before it knows its table —
|
|
302
|
+
the link deliberately does not carry the base, since the page names it
|
|
303
|
+
— so the crumb falls through to the page rather than to `undefined`,
|
|
304
|
+
which is what it read while the page was loading. */
|
|
305
|
+
const name = entry.kind === 'table' || entry.kind === 'breakdown'
|
|
306
|
+
? entry.query
|
|
307
|
+
: entry.label ?? entry.table ?? entry.pageId ?? '…';
|
|
308
|
+
|
|
309
|
+
const content = showsTable
|
|
310
|
+
? [el('span', { class: 'crumb-table', text: entry.table }), document.createTextNode(name)]
|
|
311
|
+
: [document.createTextNode(name)];
|
|
312
|
+
|
|
313
|
+
bar.append(
|
|
314
|
+
last
|
|
315
|
+
? el('span', { class: 'current' }, content)
|
|
316
|
+
: el('button', { type: 'button', onclick: () => backTo(i) }, content),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/* One step back along the trail, at the end of the trail it steps along.
|
|
321
|
+
|
|
322
|
+
It does what clicking the second-to-last crumb does, rather than calling
|
|
323
|
+
history.back() — a trail arrived at by pasting a link has crumbs but no
|
|
324
|
+
browser history behind it, and a Back button that did nothing on exactly
|
|
325
|
+
the pages people share links to would be worse than none. The browser's
|
|
326
|
+
own Back still works; this is the walk's back, which is a different
|
|
327
|
+
thing: it goes up the trail, not to whatever you looked at last. */
|
|
328
|
+
if (state.stack.length > 1) {
|
|
329
|
+
const previous = state.stack[state.stack.length - 2];
|
|
330
|
+
bar.append(el('button', {
|
|
331
|
+
type: 'button',
|
|
332
|
+
class: 'crumb-back',
|
|
333
|
+
title: `Back to ${previous.kind === 'table' || previous.kind === 'breakdown' ? previous.query : previous.label}`,
|
|
334
|
+
onclick: () => backTo(state.stack.length - 2),
|
|
335
|
+
}, [
|
|
336
|
+
el('span', { 'aria-hidden': 'true', text: '\u2190' }),
|
|
337
|
+
el('span', { text: 'Back' }),
|
|
338
|
+
]));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return bar;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* The tab the current view is on, if it is a table view.
|
|
346
|
+
*
|
|
347
|
+
* Rows for anything else: arriving from a record, the reader has not chosen a
|
|
348
|
+
* tab, and defaulting to whatever they last looked at three walks ago would
|
|
349
|
+
* be guessing.
|
|
350
|
+
*/
|
|
351
|
+
function stickyTab() {
|
|
352
|
+
const entry = currentEntry();
|
|
353
|
+
return entry?.kind === 'table' ? entry.tab ?? 'rows' : 'rows';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function highlightTable(tableId) {
|
|
357
|
+
const buttons = sidebarButtons();
|
|
358
|
+
buttons.forEach((button, i) => {
|
|
359
|
+
const current = button.dataset.table === tableId;
|
|
360
|
+
button.setAttribute('aria-current', String(current));
|
|
361
|
+
/* The keyboard cursor follows the mouse — class as well as index.
|
|
362
|
+
Clicking `invoice` and then pressing Down should move to
|
|
363
|
+
`invoice_line`, not to the second table in the list because the cursor
|
|
364
|
+
was still parked where the last arrow key left it; and the parked
|
|
365
|
+
row's `.active` styling used to stay behind too, a second selected
|
|
366
|
+
row that nothing would clear until the next arrow key. */
|
|
367
|
+
button.classList.toggle('active', current);
|
|
368
|
+
if (current) sidebarIndex = i;
|
|
369
|
+
});
|
|
370
|
+
// Cursor and current are the same row again; see highlightSidebar.
|
|
371
|
+
$('table-list')?.classList.remove('skimming');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ---------- sidebar ---------- */
|
|
375
|
+
|
|
376
|
+
/** Index of the keyboard-highlighted table, or -1. Reset whenever the
|
|
377
|
+
filter changes, because the list it indexes into has changed. */
|
|
378
|
+
let sidebarIndex = -1;
|
|
379
|
+
|
|
380
|
+
function sidebarButtons() {
|
|
381
|
+
return [...document.querySelectorAll('#table-list button')];
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function highlightSidebar(index) {
|
|
385
|
+
const buttons = sidebarButtons();
|
|
386
|
+
if (!buttons.length) return;
|
|
387
|
+
// Wraps, so holding one arrow key cycles rather than sticking at the end.
|
|
388
|
+
sidebarIndex = (index + buttons.length) % buttons.length;
|
|
389
|
+
buttons.forEach((b, i) => b.classList.toggle('active', i === sidebarIndex));
|
|
390
|
+
/* Whether the cursor has run ahead of the table on screen. The preview
|
|
391
|
+
behind the arrow keys is debounced, so for a beat the row being read
|
|
392
|
+
and the row being pointed at are different rows — and both carried the
|
|
393
|
+
full "you are here" treatment, two selected tables flashing during any
|
|
394
|
+
quick run down the list. The class lets the stale one step back until
|
|
395
|
+
the preview catches up (highlightTable clears it). */
|
|
396
|
+
$('table-list')?.classList.toggle(
|
|
397
|
+
'skimming',
|
|
398
|
+
buttons[sidebarIndex].getAttribute('aria-current') !== 'true',
|
|
399
|
+
);
|
|
400
|
+
buttons[sidebarIndex].scrollIntoView({ block: 'nearest' });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Move focus into the panel beside the list.
|
|
405
|
+
*
|
|
406
|
+
* The first thing worth landing on rather than the first focusable thing:
|
|
407
|
+
* a column header or a row, not the breadcrumb you just came through. If the
|
|
408
|
+
* panel has nothing to focus, the container itself takes it, so the keyboard
|
|
409
|
+
* is at least on the right side of the page.
|
|
410
|
+
*/
|
|
411
|
+
function focusContent() {
|
|
412
|
+
const content = $('content');
|
|
413
|
+
const target =
|
|
414
|
+
content.querySelector('tbody tr, .th-sort, .related-item, .field-group-head') ?? content;
|
|
415
|
+
if (target === content) content.tabIndex = -1;
|
|
416
|
+
target.focus({ preventScroll: true });
|
|
417
|
+
target.scrollIntoView?.({ block: 'nearest' });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Pending preview, so holding an arrow key does not fire a query per row. */
|
|
421
|
+
let previewTimer = null;
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Move the highlight, and show what it lands on.
|
|
425
|
+
*
|
|
426
|
+
* Arrowing down a list of sixty-six tables and having the panel beside it not
|
|
427
|
+
* change makes the arrow keys a way of scrolling rather than a way of
|
|
428
|
+
* looking — you still have to press Enter on each one to find out what is in
|
|
429
|
+
* it. Highlight moves immediately; the query follows a beat later, so holding
|
|
430
|
+
* the key down skims the list without firing sixty-six queries on the way
|
|
431
|
+
* past.
|
|
432
|
+
*
|
|
433
|
+
* Navigation is a replace, not a push: skimming should not fill the browser's
|
|
434
|
+
* history with every table you scrolled past on the way to the one you
|
|
435
|
+
* wanted.
|
|
436
|
+
*/
|
|
437
|
+
function moveSidebar(delta) {
|
|
438
|
+
highlightSidebar(sidebarIndex + delta);
|
|
439
|
+
const button = sidebarButtons()[sidebarIndex];
|
|
440
|
+
if (!button) return;
|
|
441
|
+
|
|
442
|
+
/* Focus moves with the highlight.
|
|
443
|
+
|
|
444
|
+
Leaving it on the row you arrowed away from drew two selections at once —
|
|
445
|
+
a focus ring on the old table and the current-row styling on the new one —
|
|
446
|
+
which looks like the list cannot decide where you are. The exception is
|
|
447
|
+
the filter field: if you are typing there, focus belongs to what you are
|
|
448
|
+
typing in, and the highlight is a cursor into the list rather than a
|
|
449
|
+
place your hands are. */
|
|
450
|
+
if (document.activeElement !== $('table-filter')) button.focus({ preventScroll: true });
|
|
451
|
+
|
|
452
|
+
clearTimeout(previewTimer);
|
|
453
|
+
previewTimer = setTimeout(() => {
|
|
454
|
+
go(tableView(button.dataset.table, undefined, stickyTab()), 'replace');
|
|
455
|
+
}, 110);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function renderTableList(filterText = '') {
|
|
459
|
+
const list = $('table-list');
|
|
460
|
+
const needle = filterText.trim().toLowerCase();
|
|
461
|
+
sidebarIndex = -1;
|
|
462
|
+
list.replaceChildren();
|
|
463
|
+
for (const table of state.schema.tables) {
|
|
464
|
+
if (needle && !table.id.toLowerCase().includes(needle)) continue;
|
|
465
|
+
list.append(
|
|
466
|
+
el('li', {}, el('button', {
|
|
467
|
+
type: 'button',
|
|
468
|
+
'data-table': table.id,
|
|
469
|
+
/* Out of the tab order, in the arrow order.
|
|
470
|
+
|
|
471
|
+
Sixty-six tables in the tab sequence means Tab is useless for what
|
|
472
|
+
Tab is for — moving between the regions of the page. The list is a
|
|
473
|
+
single stop reached through the filter field, and moved within by
|
|
474
|
+
the arrow keys, which is the roving-tabindex pattern every real
|
|
475
|
+
listbox uses. Focus is still set programmatically when arrowing. */
|
|
476
|
+
tabindex: -1,
|
|
477
|
+
/* Stays on the tab you were reading.
|
|
478
|
+
|
|
479
|
+
Comparing two tables' diagrams meant picking the next table and
|
|
480
|
+
landing back on Rows, then clicking Diagram again — the sidebar
|
|
481
|
+
was answering "show me this table" by also silently answering
|
|
482
|
+
"and go back to the default view of it". */
|
|
483
|
+
onclick: () => go(tableView(table.id, undefined, stickyTab()), 'reset'),
|
|
484
|
+
}, [
|
|
485
|
+
el('span', { text: table.name }),
|
|
486
|
+
table.isView
|
|
487
|
+
? el('span', { class: 'view-tag', text: 'view' })
|
|
488
|
+
: table.approxRows != null
|
|
489
|
+
? el('span', { class: 'rows', text: table.approxRows.toLocaleString() })
|
|
490
|
+
: null,
|
|
491
|
+
])),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (!list.childElementCount) list.append(el('li', {}, el('p', { class: 'note', text: 'No matches.' })));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Every page this connection has, as one command each.
|
|
499
|
+
*
|
|
500
|
+
* Registered rather than given a nav item: a database with one page does not
|
|
501
|
+
* deserve a permanent tab, and one with nine cannot fit them. `addMenuItem`
|
|
502
|
+
* puts each in the overflow menu and in ⌘K from a single registration, which
|
|
503
|
+
* is where someone looks for "the customer page" anyway.
|
|
504
|
+
*/
|
|
505
|
+
async function registerPages() {
|
|
506
|
+
try {
|
|
507
|
+
/* `onSelect` and `detail`, which is what the registry reads. It had been
|
|
508
|
+
`run` and `hint` — names nothing on the other side of this call knows —
|
|
509
|
+
so every page in ⌘K and in the overflow menu threw on click and did
|
|
510
|
+
nothing visible. The one command group built from data was the one
|
|
511
|
+
group nobody could use. */
|
|
512
|
+
const pages = await pagesFor();
|
|
513
|
+
landingPages = pages.filter((p) => !p.error);
|
|
514
|
+
/* The home screen is drawn before this arrives — it is the first paint —
|
|
515
|
+
so it is drawn again once the pages are known. Only there: anywhere
|
|
516
|
+
else the reader is looking at something, and redrawing it under them
|
|
517
|
+
to add a list they cannot see is worse than the wait. */
|
|
518
|
+
if (!currentEntry()) render();
|
|
519
|
+
setMenuGroup('Pages', pages.map((page) => ({
|
|
520
|
+
label: page.name,
|
|
521
|
+
detail: page.error ? page.error : `one ${page.base}`,
|
|
522
|
+
onSelect: () => {
|
|
523
|
+
if (page.error) {
|
|
524
|
+
toast(page.error, 'error');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
go(pageView(page.id, page.base), 'reset');
|
|
528
|
+
},
|
|
529
|
+
})));
|
|
530
|
+
} catch {
|
|
531
|
+
/* A connection whose pages cannot be read is a connection with no pages
|
|
532
|
+
in the menu. It is not a reason to stop starting up. */
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* ---------- boot ---------- */
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Switch connection, then rebuild the trail from the link.
|
|
540
|
+
*
|
|
541
|
+
* The order is the point: the hash is parsed only after the new schema has
|
|
542
|
+
* arrived, because a trail into another database names tables this one does
|
|
543
|
+
* not have and would parse to nothing.
|
|
544
|
+
*/
|
|
545
|
+
async function switchAndRestore(id, hash) {
|
|
546
|
+
try {
|
|
547
|
+
const { schema } = await api('/api/connections/select', { id });
|
|
548
|
+
state.schema = schema;
|
|
549
|
+
state.activeConnection = id;
|
|
550
|
+
/* The picker names this tab's connection, not the server's idea of an
|
|
551
|
+
active one. A link into one database with the sidebar naming another is
|
|
552
|
+
the interface disagreeing with itself about where you are — and now
|
|
553
|
+
that every request carries its own connection, the server's active is
|
|
554
|
+
not this tab's answer to anything. */
|
|
555
|
+
void refreshConnections();
|
|
556
|
+
// Layouts and saved pages are stored per connection, so the new
|
|
557
|
+
// connection's are pulled before anything is drawn with them — and
|
|
558
|
+
// before the menu is rebuilt, which reads the saved-pages mirror.
|
|
559
|
+
await loadTemplates();
|
|
560
|
+
await loadSavedPages();
|
|
561
|
+
await loadSavedSql();
|
|
562
|
+
// A different database has different pages.
|
|
563
|
+
void registerPages();
|
|
564
|
+
/* Same signal the picker sends, so a deep link into another database
|
|
565
|
+
rebuilds the long-lived surfaces the way a switch does. */
|
|
566
|
+
connectionChanged();
|
|
567
|
+
renderTableList();
|
|
568
|
+
const parsed = fromHash(
|
|
569
|
+
hash,
|
|
570
|
+
(t) => findTable(t) ?? state.schema.tables.find((x) => x.name === t),
|
|
571
|
+
{ isBreakdown: looksLikeBreakdown },
|
|
572
|
+
);
|
|
573
|
+
whileApplying(() => {
|
|
574
|
+
state.stack = parsed?.stack ?? [];
|
|
575
|
+
state.page = 0;
|
|
576
|
+
state.navMode = 'replace';
|
|
577
|
+
render();
|
|
578
|
+
});
|
|
579
|
+
/* Written explicitly, after the suppressed render. Boot renders once
|
|
580
|
+
before the link is applied and stamps the *previous* connection into
|
|
581
|
+
the address bar; without this the URL keeps claiming a database the
|
|
582
|
+
page is no longer showing, and copying it hands someone the wrong
|
|
583
|
+
link. */
|
|
584
|
+
/* Open the address gate here, not in boot: on a cold deep link into
|
|
585
|
+
another database the boot deliberately left it shut so that no render
|
|
586
|
+
between then and now could stamp the outgoing connection into the bar.
|
|
587
|
+
Now the target is loaded and current, so its address is the right one
|
|
588
|
+
to write. Idempotent for the picker/popstate callers, where it is
|
|
589
|
+
already open. */
|
|
590
|
+
allowSync();
|
|
591
|
+
state.navMode = 'replace';
|
|
592
|
+
syncHash();
|
|
593
|
+
$('db-label').textContent = `${schema.label} · ${schema.dialect}`;
|
|
594
|
+
document.title = `${schema.label} — tablewalk`;
|
|
595
|
+
if (parsed?.stack.length) void resolveLabels(parsed.stack, (t) => findTable(t));
|
|
596
|
+
} catch (err) {
|
|
597
|
+
/* A dead end otherwise.
|
|
598
|
+
|
|
599
|
+
A bookmarked link naming a connection that has since been removed used
|
|
600
|
+
to render this message and stop, on a page whose only way forward was
|
|
601
|
+
editing the URL by hand — while the database the session *is* connected
|
|
602
|
+
to sat there, loaded and unreachable. The fallback is made out loud
|
|
603
|
+
instead: say which link failed, then show the connection we do have.
|
|
604
|
+
Silently substituting it would be the other error, and the worse one. */
|
|
605
|
+
const said = el('p', {
|
|
606
|
+
class: 'note',
|
|
607
|
+
text: `${err.message} Showing ${state.schema?.label ?? 'the current database'} instead.`,
|
|
608
|
+
});
|
|
609
|
+
if (!state.schema) {
|
|
610
|
+
$('content').replaceChildren(said);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
state.stack = [];
|
|
614
|
+
state.page = 0;
|
|
615
|
+
allowSync();
|
|
616
|
+
state.navMode = 'replace';
|
|
617
|
+
render();
|
|
618
|
+
syncHash();
|
|
619
|
+
// Above the landing page rather than instead of it: the reason and the
|
|
620
|
+
// way forward want to be on screen together.
|
|
621
|
+
$('content').prepend(said);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function boot() {
|
|
626
|
+
/* Captured before anything can render. The first paint calls syncHash and
|
|
627
|
+
would otherwise overwrite the incoming link with the empty state — the
|
|
628
|
+
deep link would be destroyed a few milliseconds before being read. */
|
|
629
|
+
/* The path, not the hash. A link written before these were paths still
|
|
630
|
+
arrives as `#/conn/...` — it is read here and rewritten to its path form
|
|
631
|
+
straight away, so the address bar agrees with what was restored and the
|
|
632
|
+
old spelling never reaches `syncHash` to be compared against. */
|
|
633
|
+
const legacy = window.location.hash;
|
|
634
|
+
const incoming = legacy.startsWith('#/') ? legacy.slice(1) : window.location.pathname;
|
|
635
|
+
if (legacy.startsWith('#/')) window.history.replaceState(null, '', incoming);
|
|
636
|
+
|
|
637
|
+
/* Before the first paint, so the page never flashes the wrong palette on
|
|
638
|
+
the way to the right one. */
|
|
639
|
+
initTheme();
|
|
640
|
+
|
|
641
|
+
setRenderer(paint);
|
|
642
|
+
setCrumbRenderer(() => {
|
|
643
|
+
const existing = $('content').querySelector('.crumbs');
|
|
644
|
+
if (existing) existing.replaceWith(breadcrumb());
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
try {
|
|
648
|
+
state.schema = await api('/api/schema');
|
|
649
|
+
} catch (err) {
|
|
650
|
+
/* A 409 here means there is no connection to read a schema *from*, which
|
|
651
|
+
is a screen rather than an error — and its message, "No connection is
|
|
652
|
+
open.", is true, unhelpful, and the last thing a first-time reader
|
|
653
|
+
should meet.
|
|
654
|
+
|
|
655
|
+
Asked this way round rather than by checking the connection list first,
|
|
656
|
+
which is what this did: that put a second request in front of every
|
|
657
|
+
ordinary load to answer a question only the empty case ever asks. The
|
|
658
|
+
failure already carries the answer. */
|
|
659
|
+
if (err.status === 409) {
|
|
660
|
+
/* Unconditionally, and with whatever is listed. A 409 here means no
|
|
661
|
+
connection is *open* — which covers both an empty server and one
|
|
662
|
+
whose only connection failed to open. The second used to fall
|
|
663
|
+
through to "Could not read the schema: No connection is open.",
|
|
664
|
+
a true sentence with nothing to click. */
|
|
665
|
+
showWelcome(await listConnections());
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
$('content').replaceChildren(el('p', { class: 'note', text: `Could not read the schema: ${err.message}` }));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
$('db-label').textContent = `${state.schema.label} · ${state.schema.dialect}`;
|
|
672
|
+
document.title = `${state.schema.label} — tablewalk`;
|
|
673
|
+
renderTableList();
|
|
674
|
+
initWriteMode();
|
|
675
|
+
initComplete();
|
|
676
|
+
initPalette();
|
|
677
|
+
initPeek();
|
|
678
|
+
/* Registered here rather than on a connection switch, which is where it
|
|
679
|
+
used to be — so on an ordinary boot nothing was listening, and a page
|
|
680
|
+
built in the builder did not appear in the menu or in ⌘K until the tab
|
|
681
|
+
was reloaded. The one place that runs on every start is this one. */
|
|
682
|
+
whenPagesChange(() => void registerPages());
|
|
683
|
+
initSidebarToggle();
|
|
684
|
+
initSql();
|
|
685
|
+
initUndo();
|
|
686
|
+
void refreshUndo();
|
|
687
|
+
/* Connections first, then the per-connection stores, then the first
|
|
688
|
+
render. The stores are keyed by connection id, and loading them before
|
|
689
|
+
`initConnections` has named one filed everything under a connection
|
|
690
|
+
called nothing — templates got away with it because every browser that
|
|
691
|
+
saved one also mirrored it locally under the right key; the pages file
|
|
692
|
+
restore was the first reader with no local copy to fall back on. */
|
|
693
|
+
await initConnections(() => renderTableList($('table-filter').value));
|
|
694
|
+
await loadTemplates();
|
|
695
|
+
await loadSavedPages();
|
|
696
|
+
await loadSavedSql();
|
|
697
|
+
/* After the saved-pages mirror is filled, or the menu and ⌘K are built
|
|
698
|
+
from an empty list and a page restored from the file is invisible until
|
|
699
|
+
something else rebuilds them. */
|
|
700
|
+
void registerPages();
|
|
701
|
+
|
|
702
|
+
/* A link present on load wins over the default view: someone arriving from
|
|
703
|
+
a pasted link should land where the link points, not on the schema.
|
|
704
|
+
|
|
705
|
+
The empty state is painted only when there is no link to honour. It used
|
|
706
|
+
to be painted unconditionally, first, which meant a deep link spent a
|
|
707
|
+
moment showing the *active* connection's landing page — and that paint
|
|
708
|
+
wrote its own address into the bar over the link that had not been read
|
|
709
|
+
yet. `allowSync` keeps the bar shut until this is done either way. */
|
|
710
|
+
const linked = Boolean(connectionFromHash(incoming));
|
|
711
|
+
if (!linked) render();
|
|
712
|
+
|
|
713
|
+
const restored = applyHash(incoming, {
|
|
714
|
+
findTableById: (id) => findTable(id) ?? state.schema.tables.find((t) => t.name === id),
|
|
715
|
+
selectConnection: switchAndRestore,
|
|
716
|
+
});
|
|
717
|
+
/* `'switching'` means applyHash handed off to an async connection switch.
|
|
718
|
+
That switch owns *both* the render and opening the address gate
|
|
719
|
+
(`switchAndRestore` calls `allowSync`), so the boot does neither here.
|
|
720
|
+
Opening the gate now would let any render that lands mid-switch — a
|
|
721
|
+
pages-change rebuild is the one that bit — write the OUTGOING connection
|
|
722
|
+
into the bar for a frame, which is the cold-tab `/demo` leak. */
|
|
723
|
+
if (restored !== 'switching') {
|
|
724
|
+
allowSync();
|
|
725
|
+
if (restored || linked) render();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* Last, and after the first render: every stop measures an element that has
|
|
729
|
+
to exist, and the menu item it registers should sit below the ones the
|
|
730
|
+
features added rather than above them. */
|
|
731
|
+
initTour();
|
|
732
|
+
|
|
733
|
+
/* `popstate`, because the address is written with pushState and a path:
|
|
734
|
+
Back and Forward fire this, and a hash listener would never hear them
|
|
735
|
+
again. */
|
|
736
|
+
window.addEventListener('popstate', () => {
|
|
737
|
+
applyHash(window.location.pathname, {
|
|
738
|
+
findTableById: (id) => findTable(id) ?? state.schema.tables.find((t) => t.name === id),
|
|
739
|
+
selectConnection: switchAndRestore,
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
const filterInput = $('table-filter');
|
|
744
|
+
filterInput.addEventListener('input', (e) => renderTableList(e.target.value));
|
|
745
|
+
|
|
746
|
+
/* Filtering to one table and then having to reach for the mouse is the
|
|
747
|
+
kind of small friction that makes a tool feel unfinished. Typing narrows,
|
|
748
|
+
arrows choose, Enter opens — and with a single match, Enter alone is
|
|
749
|
+
enough. */
|
|
750
|
+
/* Bound on the whole sidebar rather than on the filter field.
|
|
751
|
+
|
|
752
|
+
Arrows only worked while the field had focus, so clicking a table and
|
|
753
|
+
then pressing Down scrolled the page instead of moving to the next
|
|
754
|
+
table — the list looked keyboard-navigable and was not, which is worse
|
|
755
|
+
than not offering it. */
|
|
756
|
+
document.querySelector('.sidebar').addEventListener('keydown', (e) => {
|
|
757
|
+
const buttons = sidebarButtons();
|
|
758
|
+
if (e.key === 'ArrowDown') {
|
|
759
|
+
e.preventDefault();
|
|
760
|
+
moveSidebar(1);
|
|
761
|
+
} else if (e.key === 'ArrowUp') {
|
|
762
|
+
e.preventDefault();
|
|
763
|
+
moveSidebar(-1);
|
|
764
|
+
} else if (e.key === 'ArrowRight' && e.target !== filterInput) {
|
|
765
|
+
/* Rightwards is where the table you highlighted is. The list chooses
|
|
766
|
+
what to show; the panel is where you then work, and reaching it
|
|
767
|
+
should not mean going back to the mouse. */
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
focusContent();
|
|
770
|
+
} else if (e.key === 'Enter' && e.target === filterInput) {
|
|
771
|
+
e.preventDefault();
|
|
772
|
+
// No explicit highlight means "the obvious one": the only match, or
|
|
773
|
+
// the first of several.
|
|
774
|
+
const target = buttons[sidebarIndex >= 0 ? sidebarIndex : 0];
|
|
775
|
+
if (target) {
|
|
776
|
+
// Enter commits: a push, so the table you chose is a place you can
|
|
777
|
+
// come back to, unlike the ones you skimmed to reach it.
|
|
778
|
+
go(tableView(target.dataset.table, undefined, stickyTab()), 'reset');
|
|
779
|
+
filterInput.blur();
|
|
780
|
+
}
|
|
781
|
+
} else if (e.key === 'Escape' && e.target === filterInput) {
|
|
782
|
+
if (filterInput.value) {
|
|
783
|
+
// First Escape clears the filter, a second leaves the field. Clearing
|
|
784
|
+
// and blurring together loses the list you were about to pick from.
|
|
785
|
+
e.preventDefault();
|
|
786
|
+
e.stopPropagation();
|
|
787
|
+
filterInput.value = '';
|
|
788
|
+
renderTableList('');
|
|
789
|
+
} else {
|
|
790
|
+
filterInput.blur();
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
/* The schema diagram is reachable at any time, not only from the empty
|
|
796
|
+
state — it is how you re-orient after walking somewhere unfamiliar. */
|
|
797
|
+
|
|
798
|
+
$('search-button').addEventListener('click', openPalette);
|
|
799
|
+
/* The connection name is a link to the connections dialog. It is the only
|
|
800
|
+
way in when there is a single unconfigured database — the picker that
|
|
801
|
+
otherwise carries the gear is hidden in exactly that case. */
|
|
802
|
+
$('db-label').addEventListener('click', () => void openConnections());
|
|
803
|
+
// The wordmark is the one thing every web app makes a home link, and not
|
|
804
|
+
// doing it is a small surprise on every page.
|
|
805
|
+
$('home-button').addEventListener('click', goHome);
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Run a line of the query language, from wherever it was written.
|
|
809
|
+
*
|
|
810
|
+
* The header's form and the ⌘/ modal both land here. Which endpoint answers
|
|
811
|
+
* a line is a decision with one home: a second copy in the modal would be
|
|
812
|
+
* the one that forgets about `by` the next time the language grows.
|
|
813
|
+
*/
|
|
814
|
+
const runQueryText = (raw) => {
|
|
815
|
+
const text = String(raw ?? '').trim();
|
|
816
|
+
if (!text) return;
|
|
817
|
+
const tableName = text.split(/\s+/)[0];
|
|
818
|
+
const table = findTable(tableName) ?? state.schema.tables.find((t) => t.name === tableName);
|
|
819
|
+
/* `by` is the one keyword that changes which endpoint answers, so the
|
|
820
|
+
decision is made here rather than by letting the query parser fail on a
|
|
821
|
+
line it was never going to understand. That failure was the whole bug:
|
|
822
|
+
`invoice count, sum total as billed by status` came back as
|
|
823
|
+
`"billed" is not a column on invoice` — an error about a column nobody
|
|
824
|
+
claimed was one, with nothing to say that the thing being asked for
|
|
825
|
+
exists and has its own answer. */
|
|
826
|
+
const entry = looksLikeBreakdown(text)
|
|
827
|
+
? breakdownView(table ? table.id : tableName, text)
|
|
828
|
+
: tableView(table ? table.id : tableName, text);
|
|
829
|
+
const top = currentEntry();
|
|
830
|
+
/* Refining the query you are already looking at replaces that crumb;
|
|
831
|
+
querying a different table is a fresh start, not a step in the walk. */
|
|
832
|
+
const sameTable = top && top.kind === entry.kind && top.table === entry.table;
|
|
833
|
+
go(entry, sameTable ? 'replace' : 'reset');
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
$('querybar').addEventListener('submit', (e) => {
|
|
837
|
+
e.preventDefault();
|
|
838
|
+
runQueryText($('query').value);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* ⌘/ — the query bar with room to write in.
|
|
843
|
+
*
|
|
844
|
+
* Bound at the document with capture, the same way ⌘K is and for the same
|
|
845
|
+
* reason: it has to work from wherever the hands are, including from inside
|
|
846
|
+
* the header's bar. `/` still focuses that bar — it is documented and people
|
|
847
|
+
* use it — so this adds a surface rather than moving one.
|
|
848
|
+
*/
|
|
849
|
+
document.addEventListener('keydown', (e) => {
|
|
850
|
+
if (e.key !== '/' || !(e.metaKey || e.ctrlKey)) return;
|
|
851
|
+
e.preventDefault();
|
|
852
|
+
openAsk({ initial: $('query').value, onRun: runQueryText });
|
|
853
|
+
}, true);
|
|
854
|
+
|
|
855
|
+
/* SQL stays in the header rather than going into the menu with the rest.
|
|
856
|
+
|
|
857
|
+
It is a state you leave on while you work — learning what the language
|
|
858
|
+
compiles to, or checking a query you are unsure of — not an action you
|
|
859
|
+
take once. A toggle you flip several times an hour does not belong two
|
|
860
|
+
clicks deep, and unlike the others it has to *show* whether it is on. */
|
|
861
|
+
const sqlToggle = $('sql-toggle');
|
|
862
|
+
sqlToggle.addEventListener('click', () => {
|
|
863
|
+
setExplaining(!explainVisible());
|
|
864
|
+
sqlToggle.setAttribute('aria-pressed', String(explainVisible()));
|
|
865
|
+
});
|
|
866
|
+
addMenuItem({
|
|
867
|
+
label: 'Schema',
|
|
868
|
+
detail: 'every table in this database',
|
|
869
|
+
onSelect: goHome,
|
|
870
|
+
});
|
|
871
|
+
/* Registers its own menu item, so it lands between Schema and Connections —
|
|
872
|
+
next to the other "what is this database" answer rather than at the end. */
|
|
873
|
+
initHelp();
|
|
874
|
+
addMenuItem({
|
|
875
|
+
/* Named rather than generic. "Undo" alone makes you remember what you did;
|
|
876
|
+
the label says it, so the decision does not need a memory. */
|
|
877
|
+
label: 'Undo the last edit',
|
|
878
|
+
// A function, so it describes the last edit rather than the state at boot.
|
|
879
|
+
detail: () => undoDescription() ?? 'nothing to undo',
|
|
880
|
+
onSelect: () => void undoLast().then(() => render()),
|
|
881
|
+
});
|
|
882
|
+
addMenuItem({
|
|
883
|
+
/* The palette has been in the stylesheet all along, reachable only by
|
|
884
|
+
changing your operating system. A function for the detail, so the menu
|
|
885
|
+
says what is in force rather than what was in force at boot. */
|
|
886
|
+
label: 'Theme',
|
|
887
|
+
detail: () => themeLabel(),
|
|
888
|
+
onSelect: () => { cycleTheme(); render(); },
|
|
889
|
+
});
|
|
890
|
+
addMenuItem({
|
|
891
|
+
/* The same surface the key opens. A shortcut nobody is told about is a
|
|
892
|
+
feature for whoever wrote it. */
|
|
893
|
+
label: 'Write a query…',
|
|
894
|
+
detail: 'the query bar, with room (⌘/)',
|
|
895
|
+
onSelect: () => openAsk({ initial: $('query').value, onRun: runQueryText }),
|
|
896
|
+
});
|
|
897
|
+
addMenuItem({
|
|
898
|
+
/* The question you have when a string arrives from somewhere else and
|
|
899
|
+
you cannot yet name the table it belongs to — which is the one search
|
|
900
|
+
every other search here cannot start. */
|
|
901
|
+
label: 'Find a value…',
|
|
902
|
+
detail: 'which table mentions this?',
|
|
903
|
+
/* Starting from the cell under the grid cursor when there is one: the
|
|
904
|
+
question nearly always arrives *while* looking at a value, and
|
|
905
|
+
retyping what is on screen is the tax this whole tool exists to
|
|
906
|
+
remove. `title` before `textContent`, because a shortened cell keeps
|
|
907
|
+
the whole value in its tooltip. */
|
|
908
|
+
onSelect: () => openFindValue(
|
|
909
|
+
document.querySelector('.cell-cursor')?.getAttribute('title')
|
|
910
|
+
?? document.querySelector('.cell-cursor')?.textContent?.trim()
|
|
911
|
+
?? '',
|
|
912
|
+
),
|
|
913
|
+
});
|
|
914
|
+
addMenuItem({
|
|
915
|
+
/* The same question `lint` answers for an agent and `--lint` answers for
|
|
916
|
+
CI, asked by the person who can actually fix the unindexed key. */
|
|
917
|
+
label: 'Check the shape…',
|
|
918
|
+
detail: 'what this schema will cost',
|
|
919
|
+
onSelect: () => openShapeCheck(),
|
|
920
|
+
});
|
|
921
|
+
addMenuItem({
|
|
922
|
+
label: 'Agent console',
|
|
923
|
+
detail: 'drive the MCP tools by hand, see what an agent sees',
|
|
924
|
+
onSelect: () => openAgentConsole(),
|
|
925
|
+
});
|
|
926
|
+
addMenuItem({
|
|
927
|
+
label: 'Connections…',
|
|
928
|
+
detail: 'add, edit, save to a file',
|
|
929
|
+
onSelect: () => void openConnections(),
|
|
930
|
+
});
|
|
931
|
+
initMenu();
|
|
932
|
+
|
|
933
|
+
document.addEventListener('keydown', (e) => {
|
|
934
|
+
if (e.key === 'Escape') $('query').blur();
|
|
935
|
+
const active = document.activeElement;
|
|
936
|
+
const typing = /input|textarea/i.test(active?.tagName ?? '')
|
|
937
|
+
|| active?.getAttribute?.('contenteditable') === 'true';
|
|
938
|
+
/* And not while something is open over the page.
|
|
939
|
+
|
|
940
|
+
These are single-key shortcuts on the document, so they fired through
|
|
941
|
+
any surface that did not stop them: Backspace with the focus on a
|
|
942
|
+
dialog's button walked the trail *behind* the modal, which then closed
|
|
943
|
+
onto a different record than the one it was opened from. A dialog is
|
|
944
|
+
modal by definition; the page under it is not taking keys. */
|
|
945
|
+
const covered = Boolean(
|
|
946
|
+
active?.closest?.('[role="dialog"], dialog[open], .pb, .help-panel, .palette, .conn-manager'),
|
|
947
|
+
);
|
|
948
|
+
if (covered) return;
|
|
949
|
+
|
|
950
|
+
// Backspace-as-back is a habit from file browsers, but only when the
|
|
951
|
+
// caret is not in a text field.
|
|
952
|
+
if (!typing && (e.key === 'Backspace' || (e.key === '[' && e.metaKey)) && state.stack.length > 1) {
|
|
953
|
+
e.preventDefault();
|
|
954
|
+
backTo(state.stack.length - 2);
|
|
955
|
+
}
|
|
956
|
+
if (e.key === '/' && !typing) {
|
|
957
|
+
e.preventDefault();
|
|
958
|
+
$('query').focus();
|
|
959
|
+
}
|
|
960
|
+
// `t` for tables, next to `/` for the query bar.
|
|
961
|
+
if (e.key === 'h' && !typing && !e.metaKey && !e.ctrlKey) {
|
|
962
|
+
e.preventDefault();
|
|
963
|
+
goHome();
|
|
964
|
+
}
|
|
965
|
+
if (e.key === 't' && !typing && !e.metaKey && !e.ctrlKey) {
|
|
966
|
+
e.preventDefault();
|
|
967
|
+
$('table-filter').focus();
|
|
968
|
+
$('table-filter').select();
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
void boot();
|