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,222 @@
1
+ /**
2
+ * Query history: the last 50 queries that ran, per connection.
3
+ *
4
+ * Two things shape this file.
5
+ *
6
+ * The first is that history is a convenience, never a dependency. Every
7
+ * `localStorage` call is wrapped, because the API throws rather than returning
8
+ * null in three ordinary situations — Safari's private mode, a browser set to
9
+ * block site data, and a full quota — and a database browser that fails to
10
+ * paint because it could not remember what you typed is worse than one that
11
+ * never offered to. When storage refuses, the list falls back to memory and
12
+ * works until the tab is closed.
13
+ *
14
+ * The second is that history is per connection. The stored value is keyed by
15
+ * `state.activeConnection`, so switching from staging to production does not
16
+ * offer you queries against tables the new database may not even have.
17
+ */
18
+ import { $, el, loadJson, saveJson, state, toast } from './core.js';
19
+
20
+ const KEY = 'tablewalk.history.v1';
21
+
22
+ /* Enough to cover a session's worth of exploration, small enough that the
23
+ whole record stays well inside a quota even with long queries. */
24
+ const MAX_ENTRIES = 50;
25
+
26
+ /** The whole record: `{ [connectionId]: [{ q, at }] }`. Never throws. */
27
+ function readAll() {
28
+ const parsed = loadJson(KEY, {});
29
+ // Anything could be under this key — an older shape, or another tool's.
30
+ // Refusing to trust it is cheaper than defending every read site.
31
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
32
+ }
33
+
34
+ /* The in-memory mirror, and the reason a failed write is not a failed feature:
35
+ the dropdown reads from here, so history keeps working for the session even
36
+ when nothing can be persisted. */
37
+ const cache = new Map();
38
+
39
+ const scope = () => state.activeConnection ?? 'default';
40
+
41
+ /** The history for the connection in use, newest first. */
42
+ export function historyFor(connection = scope()) {
43
+ if (!cache.has(connection)) {
44
+ const stored = readAll()[connection];
45
+ cache.set(connection, Array.isArray(stored) ? stored.filter((e) => e && typeof e.q === 'string') : []);
46
+ }
47
+ return cache.get(connection);
48
+ }
49
+
50
+ /**
51
+ * Remember a query that ran.
52
+ *
53
+ * Re-running something moves it to the top instead of adding a second copy:
54
+ * the list is meant to answer "what have I been looking at", and twelve
55
+ * identical rows of `customer` push the query you actually want off the end.
56
+ */
57
+ export function recordQuery(text) {
58
+ const q = String(text ?? '').trim();
59
+ if (!q) return;
60
+ const connection = scope();
61
+ const kept = historyFor(connection).filter((entry) => entry.q !== q);
62
+ kept.unshift({ q, at: Date.now() });
63
+ kept.length = Math.min(kept.length, MAX_ENTRIES);
64
+ cache.set(connection, kept);
65
+
66
+ const all = readAll();
67
+ all[connection] = kept;
68
+ // Best effort: saveJson swallows a full or unavailable localStorage, and
69
+ // the in-memory copy above keeps the list working for the session either way.
70
+ saveJson(KEY, all);
71
+ cursor = -1;
72
+ draft = null;
73
+ if (panel && !panel.hidden) fillPanel();
74
+ }
75
+
76
+ function clearHistory() {
77
+ const connection = scope();
78
+ cache.set(connection, []);
79
+ const all = readAll();
80
+ delete all[connection];
81
+ saveJson(KEY, all);
82
+ fillPanel();
83
+ toast('Query history cleared.');
84
+ }
85
+
86
+ /* ---------- the control ---------- */
87
+
88
+ let panel = null;
89
+ let toggle = null;
90
+ let installed = false;
91
+
92
+ /* Where in the list the ↑/↓ walk currently is: -1 means "on what you typed",
93
+ and `draft` holds that text so walking back down returns it rather than
94
+ leaving the last history entry in the box. */
95
+ let cursor = -1;
96
+ let draft = null;
97
+
98
+ function ago(at) {
99
+ const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
100
+ if (seconds < 60) return 'just now';
101
+ if (seconds < 3600) return `${Math.round(seconds / 60)}m ago`;
102
+ if (seconds < 86400) return `${Math.round(seconds / 3600)}h ago`;
103
+ return `${Math.round(seconds / 86400)}d ago`;
104
+ }
105
+
106
+ function fillPanel() {
107
+ const entries = historyFor();
108
+ panel.replaceChildren();
109
+ if (!entries.length) {
110
+ panel.append(el('p', {
111
+ class: 'history-empty',
112
+ text: 'Nothing yet. Queries appear here once they run.',
113
+ }));
114
+ return;
115
+ }
116
+ const list = el('ul', { class: 'history-list' });
117
+ for (const entry of entries) {
118
+ list.append(el('li', {}, el('button', {
119
+ type: 'button',
120
+ class: 'history-item',
121
+ onclick: () => runEntry(entry.q),
122
+ }, [
123
+ el('span', { class: 'history-q', text: entry.q }),
124
+ el('span', { class: 'history-age', text: ago(entry.at ?? Date.now()) }),
125
+ ])));
126
+ }
127
+ panel.append(list, el('button', {
128
+ type: 'button',
129
+ class: 'history-clear',
130
+ text: `Clear ${entries.length} ${entries.length === 1 ? 'query' : 'queries'}`,
131
+ onclick: clearHistory,
132
+ }));
133
+ }
134
+
135
+ function runEntry(q) {
136
+ const input = $('query');
137
+ input.value = q;
138
+ setOpen(false);
139
+ cursor = -1;
140
+ draft = null;
141
+ /* `requestSubmit` rather than `submit`: it fires the form's submit event, so
142
+ the one handler in app.js decides what a query means. `submit()` would
143
+ skip it and reload the page. */
144
+ $('querybar').requestSubmit();
145
+ }
146
+
147
+ function setOpen(open) {
148
+ panel.hidden = !open;
149
+ toggle.setAttribute('aria-expanded', String(open));
150
+ if (open) fillPanel();
151
+ }
152
+
153
+ /** Step through the list in the input itself, without opening the panel. */
154
+ function step(direction) {
155
+ const entries = historyFor();
156
+ if (!entries.length) return;
157
+ const input = $('query');
158
+ if (cursor === -1) draft = input.value;
159
+ const next = Math.min(Math.max(cursor + direction, -1), entries.length - 1);
160
+ cursor = next;
161
+ input.value = next === -1 ? (draft ?? '') : entries[next].q;
162
+ // Caret to the end, or the browser leaves it where it was and the next
163
+ // keystroke lands in the middle of the recalled query.
164
+ input.setSelectionRange(input.value.length, input.value.length);
165
+ }
166
+
167
+ /**
168
+ * Attached on import rather than from a boot function.
169
+ *
170
+ * app.js wires the chrome, and this feature deliberately adds no line to it:
171
+ * importing the module is enough to get the control, which keeps the query bar
172
+ * working the same whether or not history is compiled in.
173
+ */
174
+ export function initHistory() {
175
+ if (installed) return;
176
+ const bar = $('querybar');
177
+ const input = $('query');
178
+ if (!bar || !input) return;
179
+ installed = true;
180
+
181
+ /* No button in the header any more.
182
+
183
+ Recent queries are reached two better ways: Ctrl/Cmd+↑ in the query bar,
184
+ which is where your hands already are, and ⌘K, which searches them
185
+ alongside everything else. A permanent button for a list you open once a
186
+ session was the most expensive way to offer it. */
187
+ toggle = el('button', { type: 'button', class: 'ghost history-toggle', hidden: true });
188
+ panel = el('div', { class: 'history-panel', hidden: true });
189
+ bar.append(panel);
190
+
191
+ input.addEventListener('keydown', (e) => {
192
+ // Ctrl/Cmd rather than a bare arrow: the query bar is a text field, and
193
+ // stealing plain ↑/↓ would break moving the caret in a long query.
194
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
195
+ e.preventDefault();
196
+ step(e.key === 'ArrowUp' ? 1 : -1);
197
+ return;
198
+ }
199
+ if (e.key === 'Escape' && !panel.hidden) {
200
+ e.stopPropagation();
201
+ setOpen(false);
202
+ }
203
+ });
204
+
205
+ // Typing means you have left the recalled entry and are writing your own.
206
+ input.addEventListener('input', () => {
207
+ cursor = -1;
208
+ draft = null;
209
+ });
210
+
211
+ /* Closed by a click anywhere else, which is what a menu is expected to do —
212
+ on pointerdown so it happens before the click lands on whatever is behind. */
213
+ document.addEventListener('pointerdown', (e) => {
214
+ if (!panel.hidden && !panel.contains(e.target) && e.target !== toggle) setOpen(false);
215
+ });
216
+ }
217
+
218
+ if (document.readyState === 'loading') {
219
+ document.addEventListener('DOMContentLoaded', initHistory, { once: true });
220
+ } else {
221
+ initHistory();
222
+ }
@@ -0,0 +1,116 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>tablewalk</title>
7
+ <link rel="stylesheet" href="/style.css" />
8
+ <link rel="stylesheet" href="/features.css" />
9
+ <link rel="stylesheet" href="/composer.css" />
10
+ <link rel="stylesheet" href="/sql.css" />
11
+ <link rel="stylesheet" href="/help.css" />
12
+ <link rel="stylesheet" href="/page.css" />
13
+ <link
14
+ rel="icon"
15
+ href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>&#128279;</text></svg>"
16
+ />
17
+ </head>
18
+ <body>
19
+ <header class="topbar">
20
+ <button type="button" class="brand" id="home-button" title="Back to the schema (h)">
21
+ <span class="brand-mark"></span>
22
+ <span class="brand-name">tablewalk</span>
23
+ </button>
24
+ <!-- The name of the connection is also the way in to managing them.
25
+ The sidebar has a gear beside the picker, but the sidebar collapses,
26
+ and "which database am I on" is the one question the top bar answers
27
+ at all times. Clicking the answer is the obvious gesture. -->
28
+ <button
29
+ type="button"
30
+ class="brand-db"
31
+ id="db-label"
32
+ title="Connections…"
33
+ aria-label="Connections"
34
+ ></button>
35
+ <!-- Which database, and the way to a different one, in the chrome that
36
+ is always on screen. It lived in the sidebar, which collapses, and
37
+ which is otherwise entirely about the tables *within* one database
38
+ — the wrong altitude for the control that chooses between them. -->
39
+ <div class="conn-picker" id="conn-picker"></div>
40
+ <form class="querybar" id="querybar" autocomplete="off">
41
+ <input
42
+ id="query"
43
+ type="text"
44
+ spellcheck="false"
45
+ placeholder="customer active = true sort name limit 20"
46
+ aria-label="Query"
47
+ />
48
+ <button type="submit">Run</button>
49
+ </form>
50
+ <div class="topbar-actions">
51
+ <button
52
+ type="button"
53
+ id="sql-toggle"
54
+ class="icon-button"
55
+ aria-pressed="false"
56
+ title="Show the SQL under the query bar"
57
+ aria-label="Show the SQL"
58
+ >
59
+ SQL
60
+ </button>
61
+ <button
62
+ type="button"
63
+ id="search-button"
64
+ class="icon-button"
65
+ title="Go to a table, view or database (⌘K)"
66
+ aria-label="Search"
67
+ >
68
+ <span aria-hidden="true">⌘K</span>
69
+ </button>
70
+ <button
71
+ type="button"
72
+ id="more-button"
73
+ class="icon-button"
74
+ title="More"
75
+ aria-label="More"
76
+ aria-haspopup="menu"
77
+ aria-expanded="false"
78
+ >
79
+ <span aria-hidden="true">···</span>
80
+ </button>
81
+ <button type="button" id="write-toggle" class="mode" aria-pressed="false">Read only</button>
82
+ </div>
83
+ </header>
84
+
85
+ <div class="explain-pane" id="explain-pane" hidden>
86
+ <pre class="explain" id="explain"></pre>
87
+ <button
88
+ type="button"
89
+ id="explain-edit"
90
+ class="explain-edit"
91
+ title="Open this statement in the SQL editor"
92
+ aria-label="Edit this statement and run it"
93
+ >Edit</button>
94
+ </div>
95
+ <div class="errors" id="errors" hidden></div>
96
+ <div class="toast" id="toast" role="status" hidden></div>
97
+
98
+ <main class="layout">
99
+ <nav class="sidebar" aria-label="Tables">
100
+ <input id="table-filter" type="search" placeholder="Filter tables…" aria-label="Filter tables" />
101
+ <ul id="table-list"></ul>
102
+ </nav>
103
+
104
+ <section class="content" id="content">
105
+ <div class="empty" id="empty">
106
+ <h1>Walk the database, don't list it.</h1>
107
+ <p>
108
+ Pick a table on the left, or type a query above. Click any row to open it, and
109
+ tablewalk will show you every row elsewhere that points at it.
110
+ </p>
111
+ </div>
112
+ </section>
113
+ </main>
114
+ </body>
115
+ <script type="module" src="/app.js"></script>
116
+ </html>
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Adding a row, in a form the schema wrote.
3
+ *
4
+ * Nobody declares a form for this: the columns, their types, what is
5
+ * required and what the database will fill are all in the catalog already.
6
+ * Required is NOT NULL with no default and no auto-assigned key; everything
7
+ * else is optional, and an empty box means "leave it to the database" — its
8
+ * defaults are part of the schema's design and pre-filling them here would
9
+ * turn every default into a value somebody appears to have chosen.
10
+ *
11
+ * What this form deliberately is not: a validator. It marks what the
12
+ * catalog says and sends what was typed; a UNIQUE violation or a bad
13
+ * reference comes back in the database's own words, which name the
14
+ * constraint better than a paraphrase would.
15
+ */
16
+ import { api, columnKind, el, go, rowLabel, rowView, state, toast } from './core.js';
17
+ import { canDelete } from './delete.js';
18
+
19
+ /** The same gate every write affordance uses: writable, write mode, a table. */
20
+ export const canInsert = canDelete;
21
+
22
+ export function newRowButton(table) {
23
+ if (!canInsert(table)) return null;
24
+ return el('button', {
25
+ type: 'button',
26
+ class: 'ghost table-new-row',
27
+ title: `Add a ${table.name} row`,
28
+ text: '+ New row',
29
+ onclick: () => openInsert(table),
30
+ });
31
+ }
32
+
33
+ /** A single INTEGER primary key is the database's to assign. */
34
+ function autoKey(table) {
35
+ const pk = table.columns.filter((c) => c.primaryKey);
36
+ return pk.length === 1 && /int/i.test(pk[0].type) ? pk[0].name : null;
37
+ }
38
+
39
+ function openInsert(table) {
40
+ const assigned = autoKey(table);
41
+ const inputs = new Map();
42
+ const problem = el('p', { class: 'insert-problem', hidden: true });
43
+
44
+ const field = (column) => {
45
+ const required = !column.nullable && column.default === undefined && column.name !== assigned;
46
+ const hint = column.name === assigned
47
+ ? 'assigned by the database'
48
+ : column.default !== undefined
49
+ ? `default: ${column.default}`
50
+ : column.references
51
+ ? `→ ${column.references.table}`
52
+ : '';
53
+
54
+ /* A select for booleans — three states, none of them typed — and a text
55
+ box for everything else. Numbers and dates arrive as text and are
56
+ coerced against the declared type on the server, exactly as edits are. */
57
+ let input;
58
+ if (column.allowed?.length) {
59
+ /* The database already said what this column may hold, so typing it is
60
+ a chance to get it wrong for nothing. Still a free-text-shaped
61
+ "leave it" option, because an empty box means "the database
62
+ decides" here as everywhere else on this form. */
63
+ input = el('select', { class: 'insert-input', 'aria-label': `${column.name} (one of ${column.allowed.join(', ')})` }, [
64
+ el('option', { value: '', text: hint || '—' }),
65
+ ...column.allowed.map((value) => el('option', { value, text: value })),
66
+ ]);
67
+ } else if (columnKind(column.type) === 'boolean') {
68
+ input = el('select', { class: 'insert-input', 'aria-label': `${column.name} (${column.type})` }, [
69
+ el('option', { value: '', text: hint || '—' }),
70
+ el('option', { value: '1', text: 'yes' }),
71
+ el('option', { value: '0', text: 'no' }),
72
+ ]);
73
+ } else {
74
+ input = el('input', {
75
+ type: 'text',
76
+ class: 'insert-input',
77
+ placeholder: hint,
78
+ 'aria-label': `${column.name} (${column.type})`,
79
+ autocomplete: 'off',
80
+ spellcheck: 'false',
81
+ });
82
+ }
83
+ inputs.set(column.name, input);
84
+
85
+ return el('div', { class: 'insert-field' }, [
86
+ el('label', {}, [
87
+ document.createTextNode(column.name),
88
+ el('span', { class: 'type', text: column.type.toLowerCase() }),
89
+ column.primaryKey ? el('span', { class: 'pk', text: 'key' }) : null,
90
+ required ? el('span', { class: 'req', title: 'NOT NULL with no default', text: 'required' }) : null,
91
+ ].filter(Boolean)),
92
+ input,
93
+ ]);
94
+ };
95
+
96
+ const save = async () => {
97
+ /* Only what was typed. An empty box is not the empty string — it is
98
+ "the database decides", which is what makes defaults and the
99
+ auto-assigned key work without this form knowing how to compute
100
+ either. */
101
+ const values = {};
102
+ for (const [name, input] of inputs) {
103
+ if (input.value !== '') values[name] = input.value;
104
+ }
105
+
106
+ let result;
107
+ try {
108
+ result = await api('/api/insert', { table: table.id, values });
109
+ } catch (err) {
110
+ problem.textContent = err.message;
111
+ problem.hidden = false;
112
+ return;
113
+ }
114
+ if (result.errors?.length) {
115
+ problem.textContent = result.errors[0].message;
116
+ problem.hidden = false;
117
+ return;
118
+ }
119
+
120
+ dialog.close();
121
+ const label = result.row ? rowLabel(table, result.row) : Object.values(result.key).join('/');
122
+ toast(`Added ${table.name} · ${label}`, 'ok');
123
+ /* Straight to the new record: the form's answer is the row the database
124
+ stored — defaults filled, key assigned — and the record page is where
125
+ that answer is legible. */
126
+ go(rowView(table.id, result.key, label), 'push');
127
+ };
128
+
129
+ const dialog = el('dialog', { class: 'confirm insert-dialog' }, [
130
+ el('h2', { text: `New ${table.name}` }),
131
+ el('p', {
132
+ class: 'insert-lede',
133
+ text: 'Empty fields are left to the database — defaults apply, keys are assigned.',
134
+ }),
135
+ el('div', { class: 'insert-fields' }, table.columns.map(field)),
136
+ problem,
137
+ el('div', { class: 'confirm-actions' }, [
138
+ el('button', { type: 'button', class: 'ghost', text: 'Cancel', onclick: () => dialog.close() }),
139
+ el('button', { type: 'button', class: 'primary', text: 'Add row', onclick: () => void save() }),
140
+ ]),
141
+ ]);
142
+
143
+ dialog.addEventListener('close', () => dialog.remove());
144
+ document.body.append(dialog);
145
+ dialog.showModal();
146
+ /* The first box someone has to fill, not the first box. */
147
+ const firstRequired = table.columns.find(
148
+ (c) => !c.nullable && c.default === undefined && c.name !== assigned,
149
+ );
150
+ (inputs.get(firstRequired?.name) ?? inputs.values().next().value)?.focus();
151
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The overflow menu, and the registry behind it.
3
+ *
4
+ * The toolbar had six buttons of equal weight — Run, SQL, Recent, Schema,
5
+ * Views, Read only — which is six things with no order of importance between
6
+ * them. Two of them were duplicates of something else (Schema is what the
7
+ * wordmark already does; Recent is what ⌘K now covers), one is a mode rather
8
+ * than an action, and one is the primary action. Flattening all of that into
9
+ * a row of identical outlined rectangles makes the reader read every label
10
+ * every time to find the one they want.
11
+ *
12
+ * So: `Run` stays attached to the field it runs, the mode sits apart at the
13
+ * far end because it is a state and not a verb, and everything else lives
14
+ * behind one button. The registry exists so a module can add its own item
15
+ * without reaching into the header — the composer adds "Views…" the same way
16
+ * the app adds "Show SQL", and neither needs to know the other exists.
17
+ */
18
+ import { $, el } from './core.js';
19
+
20
+ /** @type {Array<{label: string, onSelect: () => void, checked?: () => boolean, detail?: string, group?: string}>} */
21
+ const items = [];
22
+
23
+ /**
24
+ * Register a menu item. Order of registration is order of appearance.
25
+ *
26
+ * `checked` makes the item a toggle: it is read each time the menu opens, so
27
+ * an item reflects the current state rather than the state at registration.
28
+ */
29
+ export function addMenuItem(item) {
30
+ items.push(item);
31
+ }
32
+
33
+ /**
34
+ * Replace every item in a group.
35
+ *
36
+ * Registration is a one-time act for the fixed commands, and wrong for the
37
+ * ones that come from data: the pages this connection has change when someone
38
+ * builds one, deletes one, or switches database. Adding them again would
39
+ * leave the old ones beside the new, and a page you deleted still listed in
40
+ * ⌘K is a command that opens nothing.
41
+ *
42
+ * By group rather than by id, because "these are the pages now" is the whole
43
+ * statement — anything previously in the group that is not in the list is
44
+ * gone, which is the part a per-item update cannot say.
45
+ */
46
+ /**
47
+ * Who to tell when the registered commands change.
48
+ *
49
+ * The palette snapshots the commands when it opens, and a group registered
50
+ * a beat later — pages arriving from the server on a fresh load — was
51
+ * invisible until it was closed and reopened. Searching for a page you can
52
+ * see in the menu and being told nothing matches is the palette lying.
53
+ */
54
+ const watchers = new Set();
55
+
56
+ /** Register for command-list changes. Returns the way to stop listening. */
57
+ export function whenMenuChanges(run) {
58
+ watchers.add(run);
59
+ return () => watchers.delete(run);
60
+ }
61
+
62
+ export function setMenuGroup(group, entries) {
63
+ for (let i = items.length - 1; i >= 0; i -= 1) {
64
+ if (items[i].group === group) items.splice(i, 1);
65
+ }
66
+ items.push(...entries.map((entry) => ({ ...entry, group })));
67
+ for (const run of [...watchers]) run();
68
+ }
69
+
70
+ /**
71
+ * Everything registered, for anywhere else that wants to offer it.
72
+ *
73
+ * The command palette lists these alongside tables, so an action is reachable
74
+ * by typing its name as well as by opening the menu — registered once, offered
75
+ * twice. Two lists of commands maintained separately is how a menu item ends
76
+ * up missing from search.
77
+ */
78
+ export const menuItems = () => items;
79
+
80
+ /** A menu item's detail, which may be a function of the moment. */
81
+ export const detailOf = (item) =>
82
+ (typeof item.detail === 'function' ? item.detail() : item.detail);
83
+
84
+ let list = null;
85
+
86
+ function close() {
87
+ if (!list || list.hidden) return;
88
+ list.hidden = true;
89
+ $('more-button')?.setAttribute('aria-expanded', 'false');
90
+ document.removeEventListener('pointerdown', onOutside, true);
91
+ document.removeEventListener('keydown', onKey, true);
92
+ window.removeEventListener('scroll', close, true);
93
+ window.removeEventListener('resize', close);
94
+ }
95
+
96
+ function onOutside(e) {
97
+ if (!list.contains(e.target) && e.target !== $('more-button')) close();
98
+ }
99
+
100
+ function onKey(e) {
101
+ if (e.key === 'Escape') {
102
+ // Stopped as well: see the palette. An open menu owns the key.
103
+ e.preventDefault();
104
+ e.stopPropagation();
105
+ close();
106
+ $('more-button')?.focus();
107
+ }
108
+ }
109
+
110
+ function open() {
111
+ const button = $('more-button');
112
+ list.replaceChildren(...items.map((item, i) => {
113
+ const checked = item.checked?.() ?? null;
114
+ return el('div', {
115
+ class: `more-item${checked ? ' checked' : ''}`,
116
+ role: checked === null ? 'menuitem' : 'menuitemcheckbox',
117
+ 'aria-checked': checked === null ? undefined : String(checked),
118
+ tabindex: -1,
119
+ onclick: () => { close(); item.onSelect(); },
120
+ onkeydown: (e) => {
121
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); close(); item.onSelect(); }
122
+ if (e.key === 'ArrowDown') { e.preventDefault(); list.children[i + 1]?.focus(); }
123
+ if (e.key === 'ArrowUp') { e.preventDefault(); list.children[i - 1]?.focus(); }
124
+ },
125
+ }, [
126
+ // A tick column, always present, so labels line up whether or not the
127
+ // item is a toggle.
128
+ el('span', { class: 'more-tick', 'aria-hidden': 'true', text: checked ? '✓' : '' }),
129
+ el('span', { class: 'more-label', text: item.label }),
130
+ /* Read when the menu opens, not when the item was registered, so a
131
+ detail that describes current state — "credit_limit on customer 4" —
132
+ is the state now rather than the state at boot. */
133
+ detailOf(item) ? el('span', { class: 'more-detail', text: detailOf(item) }) : null,
134
+ ].filter(Boolean));
135
+ }));
136
+
137
+ list.hidden = false;
138
+ button.setAttribute('aria-expanded', 'true');
139
+
140
+ // Fixed to the viewport, like every other menu here, so a scrolling
141
+ // ancestor cannot clip it.
142
+ const box = button.getBoundingClientRect();
143
+ list.style.position = 'fixed';
144
+ list.style.top = `${box.bottom + 6}px`;
145
+ list.style.right = `${Math.max(8, window.innerWidth - box.right)}px`;
146
+
147
+ list.children[0]?.focus();
148
+ document.addEventListener('pointerdown', onOutside, true);
149
+ document.addEventListener('keydown', onKey, true);
150
+ window.addEventListener('scroll', close, true);
151
+ window.addEventListener('resize', close);
152
+ }
153
+
154
+ export function initMenu() {
155
+ const button = $('more-button');
156
+ if (!button) return;
157
+ list = el('div', { class: 'more-list', role: 'menu', hidden: true });
158
+ document.body.append(list);
159
+ button.addEventListener('click', () => (list.hidden ? open() : close()));
160
+ }