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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. 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();