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,962 @@
1
+ /**
2
+ * Connections, as a place rather than a dropdown.
3
+ *
4
+ * The picker in the sidebar answers "switch to another database" and nothing
5
+ * else. Everything a person actually wants to do with a connection — see why
6
+ * one will not open, correct a typed host, notice that a password is sitting
7
+ * in a URL, remove one they added by mistake, make a session connection
8
+ * permanent — had no home at all.
9
+ *
10
+ * Three ideas hold this together:
11
+ *
12
+ * - **A connection is a card, not a row.** Its state is not one value: it
13
+ * has a target, a dialect, whether it is open, how many tables it found,
14
+ * where its password came from, and whether anything is worth warning
15
+ * about. A table of columns would either drop most of that or be unreadable.
16
+ *
17
+ * - **Editing is session-scoped until you say otherwise.** Changes take
18
+ * effect immediately in the running process and touch no file. Writing
19
+ * them to disk is a separate button that names the file it is about to
20
+ * write. A page that silently edits a config file is a surprise, and this
21
+ * tool's whole argument is against surprises.
22
+ *
23
+ * - **Failure is shown, not hidden.** A connection that cannot open stays in
24
+ * the list with its error, because a database missing from the list looks
25
+ * like one you never configured.
26
+ */
27
+ import { $, api, el, render, state, toast, trapFocus } from './core.js';
28
+ import { promptFor } from './prompt.js';
29
+ import { dropdown } from './dropdown.js';
30
+ import { selectConnection } from './connections.js';
31
+ import {
32
+ DEFAULT_PORTS, DRIVERS, buildConnectionUrl, isEnvReference, looksSchemeless, parseConnectionUrl,
33
+ redactConnectionUrl,
34
+ } from './connurl.js';
35
+
36
+ let panel = null;
37
+ /** Undoes the focus trap, and hands the keyboard back where it came from. */
38
+ let release = null;
39
+
40
+ /** Latest list from the server, so a redraw does not need a round trip. */
41
+ let connections = [];
42
+ let configFile = null;
43
+ /** Connections currently being introspected, by id. */
44
+ const busy = new Set();
45
+
46
+ export function connectionsOpen() {
47
+ return Boolean(panel);
48
+ }
49
+
50
+ export async function openConnections() {
51
+ if (panel) return;
52
+ panel = el('div', { class: 'conn-manager', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Connections' });
53
+ document.body.append(panel);
54
+ release = trapFocus(panel);
55
+ document.addEventListener('keydown', onKey, true);
56
+ paint({ loading: true });
57
+ await refresh();
58
+ }
59
+
60
+ function close() {
61
+ release?.();
62
+ release = null;
63
+ panel?.remove();
64
+ panel = null;
65
+ document.removeEventListener('keydown', onKey, true);
66
+ }
67
+
68
+ function onKey(e) {
69
+ if (e.key !== 'Escape') return;
70
+ // Not while a dialog of our own is on top of us, and not mid-edit.
71
+ if (document.querySelector('dialog[open]')) return;
72
+ const tag = document.activeElement?.tagName;
73
+ if (tag === 'INPUT' || tag === 'TEXTAREA') { document.activeElement.blur(); return; }
74
+ e.preventDefault();
75
+ close();
76
+ }
77
+
78
+ async function refresh() {
79
+ try {
80
+ const data = await api('/api/connections');
81
+ connections = data.connections ?? [];
82
+ if (data.active) state.activeConnection = data.active;
83
+ } catch (err) {
84
+ toast(err.message, 'error');
85
+ }
86
+ paint();
87
+ }
88
+
89
+ /** Run something that changes the list, then redraw from the server's answer. */
90
+ async function mutate(path, body, { success } = {}) {
91
+ try {
92
+ const data = await api(path, body);
93
+ if (data.connections) connections = data.connections;
94
+ if (data.active) state.activeConnection = data.active;
95
+ paint();
96
+ if (success) toast(success(data), 'ok');
97
+ return data;
98
+ } catch (err) {
99
+ toast(err.message, 'error');
100
+ await refresh();
101
+ return null;
102
+ }
103
+ }
104
+
105
+ /* ---------- rendering ---------- */
106
+
107
+ /** What the reader has typed into the filter. Kept across redraws. */
108
+ let needle = '';
109
+ /** Keyboard cursor into the filtered list. */
110
+ let cursor = -1;
111
+
112
+ function matching() {
113
+ const q = needle.trim().toLowerCase();
114
+ if (!q) return connections;
115
+ return connections.filter((c) =>
116
+ `${c.name} ${c.detail} ${c.dialect} ${c.source}`.toLowerCase().includes(q));
117
+ }
118
+
119
+ function paint({ loading = false } = {}) {
120
+ if (!panel) return;
121
+
122
+ const filter = el('input', {
123
+ type: 'search',
124
+ class: 'conn-filter',
125
+ placeholder: 'Filter connections…',
126
+ 'aria-label': 'Filter connections',
127
+ value: needle,
128
+ oninput: (e) => {
129
+ needle = e.target.value;
130
+ cursor = -1;
131
+ paint();
132
+ // Repainting replaces the field, so the caret has to be put back.
133
+ const next = panel.querySelector('.conn-filter');
134
+ next.focus();
135
+ next.setSelectionRange(next.value.length, next.value.length);
136
+ },
137
+ onkeydown: onFilterKey,
138
+ });
139
+
140
+ const head = el('div', { class: 'conn-head' }, [
141
+ el('div', { class: 'conn-head-titles' }, [
142
+ /* The screen is full-bleed on purpose — a place you finish and leave —
143
+ but a place with no wordmark read as a different application: the
144
+ chrome vanished and the URL still named a table. The mark says whose
145
+ house this room is in. */
146
+ el('div', { class: 'conn-brand', 'aria-hidden': 'true' }, [
147
+ el('span', { class: 'brand-mark' }),
148
+ el('span', { class: 'conn-brand-name', text: 'tablewalk' }),
149
+ ]),
150
+ el('h2', { text: 'Connections' }),
151
+ el('p', {
152
+ class: 'conn-lede',
153
+ text: 'Changes apply to this session straight away. Nothing is written to disk until you save.',
154
+ }),
155
+ ]),
156
+ el('div', { class: 'conn-head-actions' }, [
157
+ el('button', { type: 'button', class: 'ghost', text: '+ Add', onclick: () => void addDialog() }),
158
+ el('button', { type: 'button', class: 'ghost', text: 'Save to file…', onclick: () => void saveDialog() }),
159
+ el('button', { type: 'button', text: 'Done', onclick: close }),
160
+ ]),
161
+ ]);
162
+
163
+ const shown = matching();
164
+ const list = el('div', { class: 'conn-list', role: 'listbox', 'aria-label': 'Connections' });
165
+
166
+ if (loading) {
167
+ list.append(el('p', { class: 'loading', text: 'Reading connections…' }));
168
+ } else if (!connections.length) {
169
+ list.append(el('p', { class: 'note', text: 'No connections. Add one to get started.' }));
170
+ } else if (!shown.length) {
171
+ list.append(el('p', { class: 'note', text: `Nothing matches "${needle.trim()}".` }));
172
+ } else {
173
+ shown.forEach((conn, i) => list.append(row(conn, i)));
174
+ }
175
+
176
+ panel.replaceChildren(el('div', { class: 'conn-inner' }, [
177
+ head,
178
+ el('div', { class: 'conn-toolbar' }, [
179
+ filter,
180
+ el('span', {
181
+ class: 'conn-count',
182
+ text: needle.trim()
183
+ ? `${shown.length} of ${connections.length}`
184
+ : `${connections.length} connection${connections.length === 1 ? '' : 's'}`,
185
+ }),
186
+ ]),
187
+ list,
188
+ footnote(),
189
+ ]));
190
+ }
191
+
192
+ function onFilterKey(e) {
193
+ const shown = matching();
194
+ if (e.key === 'ArrowDown') {
195
+ e.preventDefault();
196
+ move(cursor + 1, shown.length);
197
+ } else if (e.key === 'ArrowUp') {
198
+ e.preventDefault();
199
+ move(cursor - 1, shown.length);
200
+ } else if (e.key === 'Enter') {
201
+ e.preventDefault();
202
+ // No explicit highlight means the obvious one: the only match, or the first.
203
+ const conn = shown[cursor >= 0 ? cursor : 0];
204
+ if (conn) void open(conn);
205
+ }
206
+ }
207
+
208
+ function move(next, total) {
209
+ if (!total) return;
210
+ cursor = (next + total) % total;
211
+ const rows = [...panel.querySelectorAll('.conn-row')];
212
+ rows.forEach((node, i) => {
213
+ node.classList.toggle('cursor', i === cursor);
214
+ node.setAttribute('aria-selected', String(i === cursor));
215
+ });
216
+ rows[cursor]?.scrollIntoView({ block: 'nearest' });
217
+ }
218
+
219
+ async function open(conn) {
220
+ if (conn.id === state.activeConnection) { close(); return; }
221
+ close();
222
+ await selectConnection(conn.id);
223
+ }
224
+
225
+ /**
226
+ * One connection, one line.
227
+ *
228
+ * Cards were legible and did not scale: at 170 pixels each, fifty connections
229
+ * is eight thousand pixels of scrolling to find one name. A row is forty, and
230
+ * the filter above means you rarely scroll at all.
231
+ *
232
+ * Nothing is dropped to get there — the target, the state, the table count and
233
+ * the password source all still show. What moves is the *actions*, which
234
+ * appear on hover or focus. They are the same four for every row, so a
235
+ * permanent copy beside each one is fifty repetitions of a fixed list.
236
+ */
237
+ function row(conn, index) {
238
+ const active = conn.id === state.activeConnection;
239
+
240
+ const status = busy.has(conn.id)
241
+ ? { tone: 'busy', text: 'reading…' }
242
+ : conn.error
243
+ ? { tone: 'bad', text: 'will not open' }
244
+ : conn.connected
245
+ ? { tone: 'ok', text: `${conn.tables ?? 0} table${conn.tables === 1 ? '' : 's'}` }
246
+ : { tone: 'idle', text: 'not opened' };
247
+
248
+ const action = (label, title, onclick, extra = '') =>
249
+ el('button', {
250
+ type: 'button', class: `conn-action ${extra}`, title, text: label,
251
+ // The row itself opens the connection; an action inside it must not
252
+ // also do that on its way through.
253
+ onclick: (e) => { e.stopPropagation(); onclick(); },
254
+ });
255
+
256
+ return el('div', {
257
+ class: `conn-row${active ? ' active' : ''}${conn.error ? ' failed' : ''}${index === cursor ? ' cursor' : ''}${busy.has(conn.id) ? ' busy' : ''}`,
258
+ role: 'option',
259
+ 'aria-selected': String(index === cursor),
260
+ tabindex: 0,
261
+ title: active ? 'Already open' : `Open ${conn.name}`,
262
+ onclick: () => void open(conn),
263
+ onkeydown: (e) => {
264
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); void open(conn); }
265
+ },
266
+ }, [
267
+ el('span', { class: `conn-dot ${status.tone}`, 'aria-hidden': 'true' }),
268
+
269
+ el('span', { class: 'conn-name' }, [
270
+ el('span', { class: 'conn-name-text', text: conn.name }),
271
+ active ? el('span', { class: 'conn-badge current', text: 'current' }) : null,
272
+ ].filter(Boolean)),
273
+
274
+ // Host and database, or a file name. Never a password.
275
+ el('span', { class: 'conn-detail', title: conn.detail, text: conn.detail }),
276
+
277
+ el('span', { class: 'conn-meta' }, [
278
+ el('span', { class: 'conn-badge', text: conn.dialect }),
279
+ el('span', { class: `conn-badge source-${conn.source}`, text: conn.source }),
280
+ conn.secret && conn.secret !== 'none'
281
+ ? el('span', { class: 'conn-badge', title: `Password from ${conn.secret}`, text: conn.secret })
282
+ : null,
283
+ /* Said where the connection is chosen, not discovered when a query
284
+ comes back refused: this one answers the schema and nothing out of
285
+ its rows. */
286
+ conn.rows === false
287
+ ? el('span', {
288
+ class: 'conn-badge shape-only',
289
+ title: 'Shape only: the catalog, counts and plans answer; no row values leave this connection.',
290
+ text: 'shape only',
291
+ })
292
+ : null,
293
+ ].filter(Boolean)),
294
+
295
+ el('span', { class: `conn-status ${status.tone}`, text: status.text }),
296
+
297
+ el('span', { class: 'conn-actions' }, [
298
+ action('Edit', `Edit ${conn.name}`, () => void editDialog(conn)),
299
+ busy.has(conn.id)
300
+ ? el('span', { class: 'conn-action working', text: 'Introspecting…' })
301
+ : action('Introspect', 'Read this database\u2019s schema again', () => void introspect(conn)),
302
+ action('Remove', `Remove ${conn.name}`, () => void removeConnection(conn), 'conn-remove'),
303
+ ]),
304
+
305
+ /* The reason a row is red, on its own line under it. A one-line list has
306
+ nowhere to put an error message, and an error hidden in a tooltip is an
307
+ error nobody reads. */
308
+ conn.error ? el('p', { class: 'conn-error', text: conn.error }) : null,
309
+ conn.warning ? el('p', { class: 'conn-warning', text: conn.warning }) : null,
310
+ ].filter(Boolean));
311
+ }
312
+
313
+ function footnote() {
314
+ /* Said once, in the place where someone is about to type a password into a
315
+ text field, rather than in a README they will not be reading at the time. */
316
+ return el('div', { class: 'conn-note' }, [
317
+ el('p', { text: 'Passwords: tablewalk stores none of its own.' }),
318
+ el('ul', {}, [
319
+ el('li', { text: 'postgres://app:${PROD_PASSWORD}@host/db — expanded from the environment, never stored.' }),
320
+ el('li', { text: 'The macOS keychain, looked up by connection name.' }),
321
+ el('li', { text: 'A credentials file at mode 600, refused if anyone else can read it.' }),
322
+ ]),
323
+ ]);
324
+ }
325
+
326
+ /* ---------- actions ---------- */
327
+
328
+ /**
329
+ * Read a database's schema again, saying so while it happens.
330
+ *
331
+ * Introspection is not instant — a Postgres catalog query across sixty tables
332
+ * takes long enough to notice, and a remote database over a slow link takes
333
+ * much longer. A button that does nothing visible for four seconds looks
334
+ * broken, and the second click that produces is a second full introspection.
335
+ *
336
+ * So the row shows its own state: the action becomes a label, the status
337
+ * becomes "reading…", the row is marked, and the button cannot be pressed
338
+ * again. The request itself is left to run in the background — nothing is
339
+ * blocked while it does, and the rest of the manager stays usable.
340
+ */
341
+ async function introspect(conn) {
342
+ busy.add(conn.id);
343
+ paint();
344
+ try {
345
+ await api('/api/connections/refresh', { id: conn.id });
346
+ toast(`Read ${conn.name}\u2019s schema again.`, 'ok');
347
+ } catch (err) {
348
+ toast(err.message, 'error');
349
+ } finally {
350
+ busy.delete(conn.id);
351
+ // Refreshed from the server rather than patched locally: the table count
352
+ // and any error are what the introspection was for.
353
+ await refresh();
354
+ }
355
+ }
356
+
357
+ async function removeConnection(conn) {
358
+ const typed = await promptFor({
359
+ title: `Remove "${conn.name}"?`,
360
+ label: 'Type the name to confirm',
361
+ hint:
362
+ conn.source === 'config'
363
+ ? 'This connection came from the config file. Removing it here affects this session only, until you save.'
364
+ : 'Removing it here affects this session only.',
365
+ placeholder: conn.name,
366
+ });
367
+ if (typed?.trim() !== conn.name) {
368
+ if (typed !== null && typed !== undefined) toast('Names did not match — nothing removed.', 'error');
369
+ return;
370
+ }
371
+ const data = await mutate('/api/connections/remove', { id: conn.id }, { success: () => `Removed ${conn.name}.` });
372
+ /* Removing the last one leaves a page whose sidebar, query bar and grid all
373
+ describe a database that is gone. The first-run screen is the honest view
374
+ of that, and boot is the only thing that draws it — so the same reload
375
+ the first-run screen uses on the way in is used on the way back out. */
376
+ if (data && !data.connections?.length) window.location.reload();
377
+ }
378
+
379
+ function urlField(value) {
380
+ return el('input', {
381
+ type: 'text',
382
+ class: 'cell-edit',
383
+ value: value ?? '',
384
+ placeholder: 'postgres://user@host/db or /path/to/file.db',
385
+ /* A placeholder is not a label: it disappears the moment there is a value
386
+ in the box, which is exactly when someone re-reading the form needs to
387
+ know what the box is. */
388
+ 'aria-label': 'Connection URL or file path',
389
+ spellcheck: 'false',
390
+ });
391
+ }
392
+
393
+ /** The shared shape of Add and Edit: same fields, different verb. */
394
+ function connectionDialog({ title, name: nameValue, url: urlValue, schemas: schemasValue, verb, run }) {
395
+ /* Two ways to say the same thing.
396
+
397
+ One box wanting `postgres://user:password@host:5432/db` is fine if you
398
+ already know the shape and unhelpful if you do not — and the people most
399
+ likely not to know are the ones this tool is for. Pasting is still how
400
+ most connections start, from a colleague or a dashboard, so both are
401
+ offered and kept in sync: `connurl.js` is the pair of pure functions that
402
+ lets them be the same thing rather than two forms that disagree. */
403
+ const url = urlField(urlValue);
404
+ const name = el('input', {
405
+ type: 'text', class: 'cell-edit', value: nameValue ?? '',
406
+ placeholder: 'Optional name', 'aria-label': 'Connection name',
407
+ });
408
+
409
+ /**
410
+ * What to call it, if nobody says.
411
+ *
412
+ * Left blank, the server falls back to the public form of the URL —
413
+ * `postgres@127.0.0.1/tablewalk` — which is correct, unambiguous, and
414
+ * thirty characters of mostly punctuation in the top bar, in the sidebar
415
+ * heading, and slugged into every link as `postgres-127-0-0-1-tablewalk`.
416
+ * It is also the one part that cannot be fixed afterwards: renaming changes
417
+ * the label and deliberately not the id, because the id is what saved
418
+ * links, pages and layouts already point at.
419
+ *
420
+ * The database's own name is what people call it out loud, so that is the
421
+ * suggestion. It goes in the placeholder rather than the value, so the box
422
+ * still reads as optional and still shows what leaving it empty will do —
423
+ * and it is sent when empty, rather than left for the server to guess
424
+ * something longer.
425
+ */
426
+ const suggestedName = () => (parts.driver === 'sqlite'
427
+ ? String(parts.file ?? '').split('/').pop() ?? ''
428
+ : parts.database || parts.host || '').trim();
429
+
430
+ /** What to file it under: what they typed, or the suggestion. */
431
+ const chosenName = () => name.value.trim() || suggestedName();
432
+
433
+ /**
434
+ * `user@host/db` with no `://`, and the one click that fixes it.
435
+ *
436
+ * The name tablewalk prints for a connection is its public URL form —
437
+ * `postgres@127.0.0.1/tablewalk` — so copying what the app showed you and
438
+ * pasting it back is a completely reasonable thing to do, and it landed in
439
+ * the file path branch and failed with "unable to open database file".
440
+ *
441
+ * Which driver it wants is the one thing the string does not say, so it is
442
+ * asked rather than guessed: two buttons, one click, and the paste is
443
+ * re-read as what it plainly was.
444
+ */
445
+ const schemeHint = el('p', { class: 'conn-scheme-hint', hidden: true });
446
+
447
+ function paintSchemeHint() {
448
+ const missing = looksSchemeless(url.value);
449
+ schemeHint.hidden = !missing;
450
+ if (!missing) return;
451
+ const repair = (scheme) => el('button', {
452
+ type: 'button',
453
+ class: 'conn-scheme-fix',
454
+ text: `${scheme}://`,
455
+ onclick: () => {
456
+ url.value = `${scheme}://${url.value.trim()}`;
457
+ url.dispatchEvent(new Event('input'));
458
+ url.focus();
459
+ },
460
+ });
461
+ schemeHint.replaceChildren(
462
+ el('span', { text: 'No scheme, so this is being read as a file path. Add one:' }),
463
+ repair('postgres'),
464
+ repair('mysql'),
465
+ );
466
+ }
467
+ const schemas = el('input', {
468
+ type: 'text', class: 'cell-edit', spellcheck: 'false',
469
+ value: (schemasValue ?? []).join(', '),
470
+ placeholder: 'public (Postgres only, comma separated)',
471
+ 'aria-label': 'Postgres schemas to browse',
472
+ });
473
+ /**
474
+ * What happened, when something happens.
475
+ *
476
+ * This was a `<p class="note">` sitting between the Schemas field and the
477
+ * fine print, in the same grey as both — so the one line on the form that
478
+ * answers a question you just asked read as more static help text. Somebody
479
+ * pressed Test and had to hunt for the reply.
480
+ *
481
+ * It is a block with a mark on it now, coloured by outcome, and it sits
482
+ * against the buttons rather than in the middle of the fields. `role=status`
483
+ * because the whole point is that it appears after an action, and a screen
484
+ * reader has even less chance of noticing a paragraph quietly changing
485
+ * halfway up a dialog.
486
+ */
487
+ const result = el('div', {
488
+ class: 'conn-result', hidden: true, role: 'status', 'aria-live': 'polite',
489
+ });
490
+
491
+ function showResult(ok, text, detail) {
492
+ result.hidden = false;
493
+ result.className = `conn-result ${ok ? 'ok' : 'bad'}`;
494
+ result.replaceChildren(
495
+ el('span', { class: 'conn-result-mark', 'aria-hidden': 'true', text: ok ? '✓' : '✕' }),
496
+ el('div', {}, [
497
+ el('p', { class: 'conn-result-text', text }),
498
+ ...(detail ? [el('p', { class: 'conn-result-detail', text: detail })] : []),
499
+ ]),
500
+ );
501
+ /* The form is taller than the screen on a laptop, and this sits at the
502
+ bottom of it — so on the one press where somebody is waiting for an
503
+ answer, the answer could arrive below the fold. `nearest` scrolls only
504
+ when it has to, which leaves the common case where the whole dialog
505
+ fits exactly where it was. */
506
+ result.scrollIntoView({ block: 'nearest' });
507
+ }
508
+
509
+ let parts = parseConnectionUrl(urlValue ?? '');
510
+
511
+ const field = (label, key, attrs = {}) => {
512
+ const input = el('input', {
513
+ type: 'text', class: 'cell-edit', spellcheck: 'false',
514
+ value: parts[key] ?? '', 'aria-label': label, ...attrs,
515
+ });
516
+ input.addEventListener('input', () => {
517
+ parts = { ...parts, [key]: input.value };
518
+ syncFromParts();
519
+ });
520
+ return { input, row: el('label', { class: 'field-stack' }, [el('span', { text: label }), input]) };
521
+ };
522
+
523
+ /* The app's own dropdown, like the connection picker and the page size — a
524
+ native `<select>` opens a system popup in the middle of a dialog that has
525
+ its own look, and reads as something the page did not draw. */
526
+ const driver = dropdown({
527
+ ariaLabel: 'Driver',
528
+ value: parts.driver,
529
+ items: DRIVERS.map((d) => ({ value: d, label: d })),
530
+ onChange: (chosen) => {
531
+ parts = { ...parts, driver: chosen };
532
+ syncFromParts();
533
+ paintForm();
534
+ },
535
+ });
536
+ const driverRow = el('label', { class: 'field-stack' }, [el('span', { text: 'Driver' }), driver]);
537
+
538
+ const host = field('Host', 'host', { placeholder: '127.0.0.1' });
539
+ const port = field('Port', 'port', { placeholder: String(DEFAULT_PORTS[parts.driver] ?? ''), inputmode: 'numeric' });
540
+ const database = field('Database', 'database', { placeholder: 'appdb' });
541
+ const user = field('User', 'user', { placeholder: 'postgres' });
542
+ /* A real password field. The value is about to be moved to the keychain on
543
+ save, and until then it should not be sitting on screen in plain text
544
+ where a shoulder or a screen share can reach it. */
545
+ /* Where the password comes from, offered at the moment somebody is deciding.
546
+
547
+ The panel behind this dialog has always listed the options — an
548
+ environment reference, the keychain, a credentials file — and the form
549
+ offered one box, so the only one anybody met was the worst one. A
550
+ reference is the arrangement the documentation recommends first and it
551
+ stores nothing anywhere; it deserves to be a choice rather than a fact
552
+ you have to already know to type. */
553
+ let secretMode = /^\$\{?[A-Za-z_]/.test(parts.password ?? '') ? 'env' : 'typed';
554
+ const envName = (value) => String(value ?? '').replace(/^\$\{?/, '').replace(/\}$/, '');
555
+
556
+ const password = field('Password', 'password', { type: 'password', placeholder: 'kept out of the config file' });
557
+ /* Its own input: a variable name is not a secret and must not be masked —
558
+ the whole value of seeing it is catching a misspelling. */
559
+ const envVar = el('input', {
560
+ type: 'text', class: 'cell-edit', spellcheck: 'false',
561
+ value: envName(parts.password), 'aria-label': 'Environment variable',
562
+ placeholder: 'PROD_PASSWORD',
563
+ });
564
+ envVar.addEventListener('input', () => {
565
+ parts = { ...parts, password: envVar.value.trim() ? `\${${envVar.value.trim()}}` : '' };
566
+ syncFromParts();
567
+ });
568
+
569
+ const secretTab = (label, which, hint) => el('button', {
570
+ type: 'button',
571
+ class: `conn-tab${secretMode === which ? ' active' : ''}`,
572
+ 'aria-pressed': String(secretMode === which),
573
+ title: hint,
574
+ text: label,
575
+ onclick: () => {
576
+ secretMode = which;
577
+ /* Switching clears rather than converts: `${PROD_PASSWORD}` is not a
578
+ password anyone typed, and a literal turned into a variable name
579
+ would be a guess about what they meant. */
580
+ parts = { ...parts, password: '' };
581
+ password.input.value = '';
582
+ envVar.value = '';
583
+ syncFromParts();
584
+ paintSecret();
585
+ },
586
+ });
587
+
588
+ const secretRow = el('div', { class: 'field-stack conn-secret' });
589
+ function paintSecret() {
590
+ secretRow.replaceChildren(
591
+ el('div', { class: 'conn-secret-head' }, [
592
+ el('span', { text: 'Password' }),
593
+ el('div', { class: 'conn-tabs conn-tabs-small' }, [
594
+ secretTab('Type it', 'typed', 'Moved to the keychain when you save to a file'),
595
+ secretTab('From the environment', 'env', 'Stored nowhere — read from the environment each time'),
596
+ ]),
597
+ ]),
598
+ secretMode === 'env' ? envVar : password.input,
599
+ el('p', {
600
+ class: 'conn-secret-note',
601
+ text: secretMode === 'env'
602
+ ? 'The URL will hold ${NAME}. Nothing is stored, here or anywhere.'
603
+ : 'Kept in this session only, and moved to the keychain if you save to a file.',
604
+ }),
605
+ );
606
+ }
607
+ const file = field('File', 'file', { placeholder: '/path/to/database.sqlite' });
608
+
609
+ /* What the parts add up to, with the secret dotted out — so it can be
610
+ checked before it is saved without the checking being the leak. */
611
+ const preview = el('code', { class: 'conn-preview' });
612
+
613
+ /* One driver control, moved between the two shapes rather than copied into
614
+ both. A clone carries no listener, so choosing `sqlite` from the file
615
+ pane's copy would have changed nothing at all — the control that is only
616
+ reachable *after* switching to sqlite would have been the dead one. */
617
+ const serverParts = el('div', { class: 'conn-parts' }, [host.row, port.row, database.row, user.row, secretRow]);
618
+ const fileParts = el('div', { class: 'conn-parts' }, [file.row]);
619
+
620
+ /**
621
+ * Whether the password is one that must not be shown.
622
+ *
623
+ * An environment reference is not a secret — `${PROD_PASSWORD}` names a
624
+ * variable and reveals nothing, and reading it back is the only way to
625
+ * notice the name is misspelt. A typed one is.
626
+ */
627
+ const secretIsHidden = () => Boolean(parts.password) && !isEnvReference(parts.password);
628
+
629
+ /**
630
+ * The URL as the box shows it: everything except a typed password.
631
+ *
632
+ * This is what makes one screen possible. With the two shapes on tabs, the
633
+ * box only ever held what somebody had pasted into it, so a password on
634
+ * screen was one they had put there. Side by side, typing into the masked
635
+ * Password field would echo it in plain text a few lines below — undoing
636
+ * the one decision the form had already made about that.
637
+ *
638
+ * So the box shows the shareable half and `parts` stays the single source
639
+ * of truth. What is submitted is built from `parts`, not read out of the
640
+ * box, which is what lets the two disagree safely.
641
+ */
642
+ const shownUrl = () => buildConnectionUrl(secretIsHidden() ? { ...parts, password: '' } : parts);
643
+
644
+ /** What will actually be opened. */
645
+ const targetUrl = () => buildConnectionUrl(parts);
646
+
647
+ function syncFromParts() {
648
+ name.placeholder = suggestedName() || 'Optional name';
649
+ url.value = shownUrl();
650
+ paintSchemeHint();
651
+ preview.textContent = redactConnectionUrl(targetUrl()) || '—';
652
+ /* Only worth a line when it says something the box above does not, which
653
+ is exactly when a password is being held back from it. */
654
+ previewRow.hidden = !secretIsHidden();
655
+ }
656
+
657
+ /* Typing or pasting in the URL box fills the parts below, which is the
658
+ whole point of having both on screen: paste what a colleague sent, then
659
+ read it back as fields and correct the one that is wrong. */
660
+ url.addEventListener('input', () => {
661
+ const text = url.value.trim();
662
+ /* Before the early return below: the hint is about what is in the box,
663
+ not about whether the parts were rebuilt from it. */
664
+ paintSchemeHint();
665
+ const parsed = parseConnectionUrl(text);
666
+ /* A half-typed URL is not a value, and this fires on every keystroke.
667
+ Applying one emptied every field under the cursor — invisible while the
668
+ parts were behind a tab, and the first thing you would see now.
669
+
670
+ Two shapes of half-typed, and the second is the interesting one. A
671
+ string with no `://` in it is a file path, which is how SQLite is named
672
+ everywhere in this tool — so `postgres:/`, one slash into a URL, parses
673
+ as a *file* called `postgres:/` and switched the whole form to SQLite
674
+ mid-word. A scheme with nothing after it yet is a URL someone is still
675
+ writing. `sqlite:/path/to.db` is not, and still lands. */
676
+ const started = /^[a-z][\w+.-]*:\/?$/i.test(text);
677
+ if (text && (started || (!parsed.host && !parsed.file))) return;
678
+ /* A URL with no password does not clear one that was typed into the field
679
+ below, because the box is not showing it in the first place. Pasting a
680
+ URL that *does* carry one replaces it. */
681
+ const password_ = !parsed.password && secretIsHidden() ? parts.password : parsed.password;
682
+ parts = { ...parsed, password: password_ };
683
+ secretMode = isEnvReference(parts.password) ? 'env' : 'typed';
684
+ driver.value = parts.driver;
685
+ host.input.value = parts.host;
686
+ port.input.value = parts.port;
687
+ database.input.value = parts.database;
688
+ user.input.value = parts.user;
689
+ password.input.value = isEnvReference(parts.password) ? '' : parts.password;
690
+ envVar.value = envName(parts.password);
691
+ file.input.value = parts.file;
692
+ preview.textContent = redactConnectionUrl(targetUrl()) || '—';
693
+ previewRow.hidden = !secretIsHidden();
694
+ name.placeholder = suggestedName() || 'Optional name';
695
+ paintForm();
696
+ });
697
+
698
+ /**
699
+ * Both shapes, at once.
700
+ *
701
+ * They used to be two tabs, which meant the form opened by guessing which
702
+ * one you wanted and hid the other behind a click. The guess is not
703
+ * knowable: pasting is how most connections start, from a colleague or a
704
+ * dashboard, and filling in the parts is what you want when you are working
705
+ * out what the connection even is. A tab makes you answer that question
706
+ * before you have looked at either.
707
+ *
708
+ * They were always two views of one value — `connurl.js` exists to keep
709
+ * them the same thing rather than two forms that disagree — so showing both
710
+ * costs a divider and removes a decision.
711
+ */
712
+ const previewRow = el('p', { class: 'conn-preview-row', hidden: true }, [
713
+ el('span', { text: 'Opens as' }),
714
+ preview,
715
+ ]);
716
+ const body = el('div');
717
+
718
+ function paintForm() {
719
+ body.replaceChildren(
720
+ el('label', { class: 'field-stack' }, [el('span', { text: 'Paste a URL' }), url, schemeHint]),
721
+ el('div', { class: 'conn-or', role: 'separator' }, [el('span', { text: 'or fill in the parts' })]),
722
+ /* `driverRow` is appended, not cloned — appending moves it. */
723
+ driverRow,
724
+ parts.driver === 'sqlite' ? fileParts : serverParts,
725
+ previewRow,
726
+ );
727
+ }
728
+
729
+ paintSecret();
730
+ syncFromParts();
731
+ paintForm();
732
+
733
+ const dialog = el('dialog', { class: 'confirm conn-dialog' }, [
734
+ el('h2', { text: title }),
735
+ el('p', { text: 'Opened straight away, so a bad target fails here rather than later.' }),
736
+ body,
737
+ el('label', { class: 'field-stack' }, [
738
+ el('span', { text: 'Name' }),
739
+ name,
740
+ el('span', {
741
+ class: 'field-hint',
742
+ text: 'What it is called in the top bar, the picker, and its links. Renaming '
743
+ + 'later changes the label, not the links.',
744
+ }),
745
+ ]),
746
+ el('label', { class: 'field-stack' }, [el('span', { text: 'Schemas' }), schemas]),
747
+ el('p', {
748
+ class: 'conn-fineprint',
749
+ /* Three facts in one faint monospace line, one of them pointing at a
750
+ button on a screen you cannot see from here. Somebody read it and
751
+ asked what it meant, which is the whole report.
752
+
753
+ Two sentences now: what happens if you do nothing, then what to do
754
+ about it and what that costs. "On the Connections screen" because
755
+ "Save to file…" is not in this dialog and never was. */
756
+ text: 'This connection lasts until you stop tablewalk. To keep it, use '
757
+ + '"Save to file…" on the Connections screen — the password moves to your '
758
+ + 'keychain and the file gets the URL without it.',
759
+ }),
760
+ result,
761
+ el('div', { class: 'confirm-actions' }, [
762
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
763
+ /* Try it without keeping it.
764
+
765
+ Adding was the only way to find out whether a target worked, and a
766
+ failed add leaves the entry behind on purpose — so a mistyped
767
+ password is a correction rather than a retype. That is right for
768
+ adding and wrong for checking: somebody who is not sure yet should
769
+ not have to tidy up after asking. */
770
+ el('button', {
771
+ type: 'button',
772
+ class: 'ghost',
773
+ text: 'Test',
774
+ onclick: async (e) => {
775
+ const target = targetUrl().trim();
776
+ /* Nothing to test, and saying so rather than doing nothing.
777
+
778
+ This mattered most where it looked most broken: the Edit dialog.
779
+ A stored connection string never leaves the server — only its
780
+ redacted form does — so Edit opens with every field blank on
781
+ purpose, and pressing Test there produced no reply of any kind.
782
+ A button that answers sometimes and silently ignores you the
783
+ rest of the time is worse than one that is disabled. */
784
+ if (!target) {
785
+ showResult(false, 'Nothing to test yet. Paste a URL, or fill in the parts — '
786
+ + 'a saved connection string is kept on the server, so this form starts empty.');
787
+ return;
788
+ }
789
+ const button = e.currentTarget;
790
+ const was = button.textContent;
791
+ button.disabled = true;
792
+ button.textContent = 'Testing…';
793
+ result.hidden = true;
794
+ try {
795
+ const answer = await api('/api/connections/test', {
796
+ url: target, name: chosenName(), schemas: schemas.value.split(',').map((x) => x.trim()).filter(Boolean),
797
+ });
798
+ /* The table count is the part that says it *read* the database
799
+ rather than merely reached it, and where the password came from
800
+ is half the answer to "will this work" — one that works because
801
+ a secret is sitting in the URL is a different answer from one
802
+ that works from the keychain. */
803
+ const where = answer.secret && answer.secret !== 'none'
804
+ ? ` Password from ${answer.secret}.` : '';
805
+ showResult(
806
+ answer.ok,
807
+ answer.ok
808
+ ? `Connected. ${answer.dialect}, ${answer.tables} table${answer.tables === 1 ? '' : 's'}.${where}`
809
+ : answer.error,
810
+ /* The server works this out and the form used to drop it on the
811
+ floor. "Password from inline" is a fact; the warning is the
812
+ part that says why anyone should care. */
813
+ answer.ok ? answer.warning : undefined,
814
+ );
815
+ } catch (err) {
816
+ showResult(false, err.message);
817
+ } finally {
818
+ button.disabled = false;
819
+ button.textContent = was;
820
+ }
821
+ },
822
+ }),
823
+ el('button', {
824
+ type: 'button',
825
+ text: verb,
826
+ onclick: async () => {
827
+ result.hidden = true;
828
+ const list = schemas.value.split(',').map((s) => s.trim()).filter(Boolean);
829
+ const outcome = await run({
830
+ url: targetUrl().trim(),
831
+ name: chosenName(),
832
+ schemas: list,
833
+ });
834
+ if (outcome?.error) {
835
+ showResult(false, outcome.error);
836
+ return;
837
+ }
838
+ dialog.close();
839
+ },
840
+ }),
841
+ ]),
842
+ ]);
843
+
844
+ document.body.append(dialog);
845
+ dialog.addEventListener('close', () => dialog.remove());
846
+ dialog.showModal();
847
+ /* The URL box, always. It is the first field on the form, it is what a
848
+ paste wants, and it fills in everything below when it lands. */
849
+ url.focus();
850
+ }
851
+
852
+ /**
853
+ * Add a connection without the manager around it.
854
+ *
855
+ * The first-run screen has one thing to ask for and no list to show — opening
856
+ * the whole manager to reach its "+ Add" button would put an empty table of
857
+ * connections between someone and the only action on the page. The dialog
858
+ * mounts on `document.body` and stands alone, so it is offered alone.
859
+ *
860
+ * `paint` and `refresh` below are no-ops when the manager is not open; they
861
+ * already check for the panel, because every one of them can be reached from
862
+ * a dialog that outlives a redraw.
863
+ */
864
+ export function addConnection(onAdded) {
865
+ addDialog(onAdded);
866
+ }
867
+
868
+ function addDialog(onAdded) {
869
+ connectionDialog({
870
+ title: 'Add a connection',
871
+ verb: 'Add and open',
872
+ run: async ({ url, name, schemas }) => {
873
+ if (!url) return { error: 'A connection string or file path is required.' };
874
+ try {
875
+ const data = await api('/api/connections', { url, name, schemas });
876
+ connections = data.connections ?? connections;
877
+ paint();
878
+ toast(`Added ${name || url}.`, 'ok');
879
+ if (onAdded) {
880
+ /* The first-run screen has its own idea of what happens next — a
881
+ reload, because a browser with no schema cannot switch to one. */
882
+ onAdded(data);
883
+ return null;
884
+ }
885
+ /* "Add and open" said open, and did not.
886
+
887
+ `add` on the server makes a connection active only when there is
888
+ not one already, so adding a second connection left you looking at
889
+ the first — and if that first one was broken, at its error, with
890
+ the connection you had just successfully added sitting unselected
891
+ in the list. The button names the act; this is the act. */
892
+ if (data.added) {
893
+ close();
894
+ await selectConnection(data.added);
895
+ }
896
+ return null;
897
+ } catch (err) {
898
+ // Refreshed even on failure: the server may have kept the entry with
899
+ // its error attached, and a list that does not show it would be lying.
900
+ await refresh();
901
+ return { error: err.message };
902
+ }
903
+ },
904
+ });
905
+ }
906
+
907
+ function editDialog(conn) {
908
+ connectionDialog({
909
+ title: `Edit "${conn.name}"`,
910
+ name: conn.name,
911
+ /* The stored URL is not sent to the browser — only its redacted form — so
912
+ there is nothing to prefill here. Left blank with the current target
913
+ shown as the placeholder: typing a new one replaces it, leaving it empty
914
+ keeps it. That is the direct consequence of the rule that connection
915
+ strings never leave the server, and it is worth the small awkwardness. */
916
+ url: '',
917
+ schemas: conn.schemas,
918
+ verb: 'Save changes',
919
+ run: async ({ url, name, schemas }) => {
920
+ if (!url && !name) return { error: 'Nothing to change.' };
921
+ const body = { id: conn.id };
922
+ if (url) body.url = url;
923
+ if (name && name !== conn.name) body.name = name;
924
+ if (schemas.length) body.schemas = schemas;
925
+ const data = await mutate('/api/connections/update', body, { success: () => `Updated ${name || conn.name}.` });
926
+ return data ? null : { error: 'Could not update the connection.' };
927
+ },
928
+ });
929
+ }
930
+
931
+ async function saveDialog() {
932
+ const path = await promptFor({
933
+ title: 'Write connections to a file',
934
+ label: 'Path',
935
+ value: configFile ?? 'tablewalk.json',
936
+ hint: `${connections.length} connection${connections.length === 1 ? '' : 's'} will be written, with their URLs exactly as typed. A password written into a URL is written to the file as well.`,
937
+ placeholder: 'tablewalk.json',
938
+ });
939
+ if (!path) return;
940
+ try {
941
+ const data = await api('/api/connections/save', { path });
942
+ configFile = data.path;
943
+ /* Say where the passwords went.
944
+
945
+ Saving moves an inline password out of the URL and into the keychain —
946
+ or a 0600 credentials file where there is no keychain — so the file can
947
+ be shared or committed without thinking about it. That is a helpful
948
+ thing to do and a surprising thing to discover later, so it is said at
949
+ the moment it happens rather than left in a README. */
950
+ const moved = data.secrets ?? [];
951
+ const where = moved.every((m) => m.stored === 'keychain') ? 'the keychain' : 'a credentials file (mode 600)';
952
+ const secrets = moved.length
953
+ ? ` ${moved.length} password${moved.length === 1 ? '' : 's'} moved to ${where} — the file has none.`
954
+ : '';
955
+ toast(
956
+ `Wrote ${data.count} connection${data.count === 1 ? '' : 's'} to ${data.path}.${secrets}`,
957
+ 'ok',
958
+ );
959
+ } catch (err) {
960
+ toast(err.message, 'error');
961
+ }
962
+ }