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,223 @@
1
+ /**
2
+ * One value, rendered by what it is — shared by every view that shows fields.
3
+ *
4
+ * The record view and the page view each grew their own idea of what a value
5
+ * looks like, and the page's was the worse one: `active` rendered as `1`,
6
+ * dates kept their midnights, and a foreign key was a bare integer. Two
7
+ * renderers for the same value will always disagree eventually; this is the
8
+ * one, and the grid's planner is its rule book.
9
+ */
10
+ import { api, el, findTable, go, labelColumn, openRecord, primaryKey, rowLabel, state } from './core.js';
11
+ import { planColumn } from './columns.js';
12
+ import { peekable } from './peek.js';
13
+
14
+ /**
15
+ * Render a value by what it is, matching how the result grid renders it.
16
+ *
17
+ * The record view drifted from the table view: the grid renders booleans as
18
+ * ticks, right-aligns numbers and trims midnight off dates, while the record
19
+ * showed everything as a bare string. Two views of the same value should not
20
+ * disagree about what it is.
21
+ */
22
+ /** Thousands separators on a numeric string too big to survive Number(). */
23
+ function groupDigits(text) {
24
+ const neg = text.startsWith('-');
25
+ const digits = neg ? text.slice(1) : text;
26
+ const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
27
+ return neg ? `-${grouped}` : grouped;
28
+ }
29
+
30
+ export function valueNode(col, value) {
31
+ const type = (col?.type ?? '').toLowerCase();
32
+ const text = String(value);
33
+
34
+ /* Classified by the same planner the grid uses, on a row of one.
35
+
36
+ This file had its own rule, and it was the looser of the two: an
37
+ unanchored `^(active|enabled|urgent|billable)` with no check on the
38
+ value, so an integer column called `active_connections` was a boolean
39
+ here and a number in the grid — `active_connections = 3` rendered as
40
+ "✗ no" on the record page and as `3` two clicks away. The grid's rule
41
+ wants the name to match a flag *exactly* and every value to be 0 or 1,
42
+ which is the test that makes 0/1 mean true and false rather than being
43
+ two small numbers. */
44
+ const plan = planColumn(col, col?.name ?? '', [{ [col?.name ?? '']: value }]);
45
+
46
+ if (plan.kind === 'boolean') {
47
+ const on = value === true || value === 1 || value === '1' || value === 't';
48
+ return el('span', { class: `bool-value ${on ? 'yes' : 'no'}`, title: text },
49
+ on ? '✓ yes' : '✗ no');
50
+ }
51
+ if (plan.kind === 'number' && Number.isFinite(Number(value))) {
52
+ const n = Number(value);
53
+ /* A big id arrives as a string because it is past 2^53, and running it
54
+ back through Number() here rounds it — the same corruption the adapter
55
+ fix avoided, one layer later, on the page where the value is read most
56
+ carefully. Shown as it came, grouped by hand. `plan.identifier` already
57
+ passed the raw string through; this widens that to any overflow. */
58
+ const overflow = typeof value === 'string' && /^-?\d+$/.test(value)
59
+ && !Number.isSafeInteger(n);
60
+ const shown = plan.identifier || overflow
61
+ ? (overflow ? groupDigits(text) : text)
62
+ : n.toLocaleString(undefined, { maximumFractionDigits: 6 });
63
+ return el('span', { class: 'num-value', title: shown === text ? undefined : text, text: shown });
64
+ }
65
+ if (plan.kind === 'date' || /date|time/.test(type)) {
66
+ /* Postgres hands back an ISO wire format — 2025-03-14T09:30:00.000Z —
67
+ which is precise and unreadable. Shown as a date and a time, with the
68
+ original kept in the title because a timezone offset can matter and
69
+ should not be thrown away. */
70
+ const trimmed = text
71
+ .replace(/[ T]00:00:00(\.0+)?(Z|[+-]\d{2}:?\d{2})?$/, '')
72
+ .replace(/T/, ' ')
73
+ .replace(/:\d{2}\.\d+(Z|[+-]\d{2}:?\d{2})?$/, '')
74
+ .replace(/(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:?\d{2})$/, '$1');
75
+ return el('span', { class: 'date-value', title: trimmed === text ? undefined : text, text: trimmed });
76
+ }
77
+ /* Long text gets its own block, and its field spans every column rather
78
+ than wrapping to five lines inside one. The threshold came down when the
79
+ record went multi-column: a column is now roughly half as wide, so text
80
+ that used to fit on one line no longer does. */
81
+ if (text.length > 90) return el('div', { class: 'long-value prose', text });
82
+
83
+ /* Monospace is right for data you compare character by character — ids,
84
+ codes, timestamps, amounts — and wrong for names, which it makes wider
85
+ and harder to read for no benefit. A space is a good enough tell: a value
86
+ with one is prose ("Southerly Logistics", "272 Cuba St"), a value without
87
+ is a token ("on_hold", "SKU-1314", an email). */
88
+ return el('span', { class: /\s/.test(text.trim()) ? 'prose' : '', text });
89
+ }
90
+
91
+ /** How deep the walk may unfold in place before it has to navigate. */
92
+ const MAX_NEST = 3;
93
+
94
+ /** Fields a nested card shows before deferring to its own page. */
95
+ const NEST_FIELDS = 10;
96
+
97
+ /** Referenced rows already unfolded once, keyed like the peek cache is. */
98
+ const nestCache = new Map();
99
+
100
+ async function referencedRow(target, column, value) {
101
+ const id = `${state.activeConnection ?? ''} ${target.id} ${column} ${value}`;
102
+ if (nestCache.has(id)) return nestCache.get(id);
103
+ const data = await api('/api/query', {
104
+ table: target.id,
105
+ filter: { groups: [[{ column, op: '=', value }]] },
106
+ limit: 1,
107
+ offset: 0,
108
+ });
109
+ const row = data.rows?.[0] ?? null;
110
+ nestCache.set(id, row);
111
+ return row;
112
+ }
113
+
114
+ /**
115
+ * A foreign key value as the walk it is: the value, the table it points at,
116
+ * a hover that shows the row, a click that goes there — and a caret that
117
+ * unfolds the row *here*.
118
+ *
119
+ * The hover answers "who is 4?" and vanishes; the caret is for when the
120
+ * answer should stay put while you keep reading, and for when the answer has
121
+ * its own references worth one more step. Each unfolded card renders its
122
+ * values through the same functions the record does — booleans as words,
123
+ * references as chips with carets of their own — so the walk continues in
124
+ * place, three levels deep at most. Past the cap the chip still navigates;
125
+ * depth is a courtesy, not a wall.
126
+ *
127
+ * Deliberately not in the URL. An expansion is a look, not a place: the
128
+ * deep link already names any row exactly, and a link that encoded every
129
+ * open caret would break the moment a layout moved — the argument that
130
+ * settled list pagers applies unchanged.
131
+ */
132
+ export function refJump(references, value, depth = 0) {
133
+ const target = findTable(references.table);
134
+ const chip = peekable(el('button', {
135
+ class: 'ref-jump',
136
+ type: 'button',
137
+ title: `Open ${references.table} ${value}`,
138
+ onclick: () => {
139
+ if (!target) return;
140
+ /* In the view the walk is already in: a reference followed from a page
141
+ opens the referenced record as a page. */
142
+ void openRecord(target.id, { [references.column]: value }, `${references.table} ${value}`);
143
+ },
144
+ }, [
145
+ el('span', { class: 'ref-jump-value', text: String(value) }),
146
+ el('span', { class: 'ref-jump-table', text: references.table }),
147
+ ]), references, value);
148
+
149
+ if (!target || depth >= MAX_NEST) return chip;
150
+
151
+ const holder = el('span', { class: 'nest-holder' });
152
+ const caret = el('button', {
153
+ type: 'button',
154
+ class: 'nest-caret',
155
+ 'aria-expanded': 'false',
156
+ title: `Show this ${target.name} here`,
157
+ 'aria-label': `Show ${references.table} ${value} inline`,
158
+ text: '\u25b8',
159
+ onclick: async () => {
160
+ if (holder.childElementCount) {
161
+ holder.replaceChildren();
162
+ caret.textContent = '\u25b8';
163
+ caret.setAttribute('aria-expanded', 'false');
164
+ return;
165
+ }
166
+ caret.textContent = '\u25be';
167
+ caret.setAttribute('aria-expanded', 'true');
168
+ const row = await referencedRow(target, references.column, value);
169
+ /* The caret may have been closed while the row travelled. */
170
+ if (caret.getAttribute('aria-expanded') !== 'true') return;
171
+ holder.replaceChildren(row
172
+ ? nestCard(target, row, depth + 1)
173
+ : el('p', { class: 'nest-missing', text: 'That row is no longer there.' }));
174
+ },
175
+ });
176
+
177
+ return el('span', { class: 'nest-wrap' }, [chip, caret, holder]);
178
+ }
179
+
180
+ /**
181
+ * The referenced row, as a card: key and label first, then what fits, each
182
+ * value rendered by what it is — which is what makes the card walkable in
183
+ * turn rather than a dead end of strings.
184
+ */
185
+ function nestCard(table, row, depth) {
186
+ const key = primaryKey(table);
187
+ const label = labelColumn(table);
188
+ const ordered = [
189
+ ...key,
190
+ ...(label && !key.includes(label) ? [label] : []),
191
+ ...table.columns.map((c) => c.name).filter((n) => !key.includes(n) && n !== label),
192
+ ].slice(0, NEST_FIELDS);
193
+ const byName = new Map(table.columns.map((c) => [c.name, c]));
194
+
195
+ return el('div', { class: 'nest-card' }, [
196
+ el('div', { class: 'nest-head' }, [
197
+ el('span', { class: 'nest-table', text: table.name }),
198
+ el('span', { class: 'nest-label', text: rowLabel(table, row) }),
199
+ el('button', {
200
+ type: 'button', class: 'ghost nest-open', text: 'open \u2192',
201
+ onclick: () => {
202
+ const keyValues = Object.fromEntries(key.map((c) => [c, row[c]]));
203
+ void openRecord(table.id, keyValues, rowLabel(table, row));
204
+ },
205
+ }),
206
+ ]),
207
+ ...ordered.map((name) => {
208
+ const column = byName.get(name);
209
+ const cell = row[name];
210
+ return el('div', { class: 'nest-line' }, [
211
+ el('span', { class: 'nest-name', text: name }),
212
+ cell === null || cell === undefined
213
+ ? el('span', { class: 'nest-value null', text: 'null' })
214
+ : column?.references
215
+ ? el('span', { class: 'nest-value' }, refJump(column.references, cell, depth))
216
+ : el('span', { class: 'nest-value' }, valueNode(column, cell)),
217
+ ]);
218
+ }),
219
+ table.columns.length > NEST_FIELDS
220
+ ? el('p', { class: 'nest-more', text: `+ ${table.columns.length - NEST_FIELDS} more on the record` })
221
+ : null,
222
+ ].filter(Boolean));
223
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Saved filters, as named tabs on a table.
3
+ *
4
+ * A filter you retype every morning is a filter that should have a name. The
5
+ * saved view stores the *query text*, not a compiled filter, for two reasons:
6
+ * it stays readable and editable in the bar, and it keeps working when the
7
+ * schema changes underneath it — a stored filter referencing a dropped column
8
+ * would be a silent empty result, while stored text is at worst a visible
9
+ * error you can fix.
10
+ *
11
+ * Scoped per connection as well as per table, because `orders` on staging and
12
+ * `orders` on production are not the same table and their useful filters are
13
+ * rarely the same either.
14
+ */
15
+ import { el, go, loadJson, saveJson, state, tableView, toast } from './core.js';
16
+ import { promptFor } from './prompt.js';
17
+ import { activeClauses, withoutClause, withoutCondition } from './clauses.js';
18
+
19
+ const KEY = 'tablewalk.views.v1';
20
+
21
+ function allViews() {
22
+ return loadJson(KEY, {});
23
+ }
24
+
25
+ function viewsFor(connectionId, tableId) {
26
+ const all = allViews();
27
+ return all[connectionId]?.[tableId] ?? [];
28
+ }
29
+
30
+ function writeViews(connectionId, tableId, list) {
31
+ const all = allViews();
32
+ all[connectionId] = all[connectionId] ?? {};
33
+ all[connectionId][tableId] = list;
34
+ if (!saveJson(KEY, all)) {
35
+ toast('Could not save — this browser is not allowing local storage.', 'error');
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+
41
+ const connectionKey = () => state.activeConnection ?? 'default';
42
+
43
+ /**
44
+ * Every saved filter for this connection, flattened across tables.
45
+ *
46
+ * The landing page needs them together; the tab strip needs them per table.
47
+ * Both read the same store, so a filter saved on a table is immediately on
48
+ * the front page without anything having to be kept in step.
49
+ */
50
+ export function allSavedFilters() {
51
+ const byTable = allViews()[connectionKey()] ?? {};
52
+ return Object.entries(byTable).flatMap(([table, list]) =>
53
+ (list ?? []).map((view) => ({ ...view, table })),
54
+ );
55
+ }
56
+
57
+ /**
58
+ * The tab strip for a table view. Returns null when there is nothing to show
59
+ * and nothing has been saved yet, so an unused feature costs no screen space.
60
+ */
61
+ export function savedViewsBar(entry) {
62
+ const conn = connectionKey();
63
+ const views = viewsFor(conn, entry.table);
64
+ const bar = el('div', { class: 'views', role: 'tablist', 'aria-label': 'Saved filters' });
65
+
66
+ /* "All" is the unfiltered table, and is only meaningful once something else
67
+ exists to contrast it with. */
68
+ const isPlain = entry.query.trim() === entry.table;
69
+ if (views.length) {
70
+ bar.append(el('button', {
71
+ type: 'button',
72
+ class: 'view-tab',
73
+ role: 'tab',
74
+ 'aria-selected': String(isPlain),
75
+ text: 'All',
76
+ onclick: () => go(tableView(entry.table), 'replace'),
77
+ }));
78
+ }
79
+
80
+ for (const view of views) {
81
+ const active = view.query.trim() === entry.query.trim();
82
+ const tab = el('span', { class: `view-tab-wrap${active ? ' active' : ''}` }, [
83
+ el('button', {
84
+ type: 'button',
85
+ class: 'view-tab',
86
+ role: 'tab',
87
+ 'aria-selected': String(active),
88
+ title: view.query,
89
+ text: view.name,
90
+ onclick: () => go(tableView(entry.table, view.query), 'replace'),
91
+ ondblclick: () => void renameView(conn, entry, view),
92
+ }),
93
+ el('button', {
94
+ type: 'button',
95
+ class: 'view-remove',
96
+ title: `Remove "${view.name}"`,
97
+ 'aria-label': `Remove ${view.name}`,
98
+ text: '×',
99
+ onclick: (e) => {
100
+ e.stopPropagation();
101
+ removeView(conn, entry, view);
102
+ },
103
+ }),
104
+ ]);
105
+ bar.append(tab);
106
+ }
107
+
108
+ /* Saving is offered only when there is a filter worth saving. Naming the
109
+ bare table is a saved view that does nothing. */
110
+ const alreadySaved = views.some((v) => v.query.trim() === entry.query.trim());
111
+ if (!isPlain && !alreadySaved) {
112
+ bar.append(el('button', {
113
+ type: 'button',
114
+ class: 'view-save',
115
+ text: '+ Save this filter',
116
+ onclick: () => void saveCurrent(conn, entry),
117
+ }));
118
+ }
119
+
120
+ /* What is currently narrowing the result, each with a way off.
121
+
122
+ The query bar can write these but could not un-write them: having sorted
123
+ by clicking a header, going back to unsorted meant finding `sort
124
+ contract_id` in a line of text and deleting exactly it. */
125
+ for (const clause of activeClauses(entry.query)) {
126
+ bar.append(el('span', { class: `clause clause-${clause.key}` }, [
127
+ el('span', { class: 'clause-label', text: clause.label }),
128
+ el('span', { class: 'clause-text', title: clause.text, text: clause.text }),
129
+ el('button', {
130
+ type: 'button',
131
+ class: 'clause-remove',
132
+ title: `Remove ${clause.label} ${clause.text}`,
133
+ 'aria-label': `Remove ${clause.label} ${clause.text}`,
134
+ text: '×',
135
+ /* A filter chip removes its own condition; everything else removes
136
+ the whole clause, because a sort or a limit has only one of it. */
137
+ onclick: () => go(tableView(
138
+ entry.table,
139
+ clause.index === undefined
140
+ ? withoutClause(entry.query, clause.key)
141
+ : withoutCondition(entry.query, clause.index),
142
+ ), 'replace'),
143
+ }),
144
+ ]));
145
+ }
146
+
147
+ return bar.childElementCount ? bar : null;
148
+ }
149
+
150
+ async function saveCurrent(conn, entry) {
151
+ const trimmed = await promptFor({
152
+ title: 'Save this filter',
153
+ label: 'Name',
154
+ value: suggestName(entry, entry.table),
155
+ hint: entry.query,
156
+ placeholder: 'e.g. Open, high priority',
157
+ });
158
+ if (!trimmed) return;
159
+
160
+ const views = viewsFor(conn, entry.table);
161
+ // A repeated name replaces rather than duplicating: two tabs reading
162
+ // "Open" that do different things is worse than losing the older one.
163
+ const without = views.filter((v) => v.name !== trimmed);
164
+ without.push({ id: `${Date.now()}`, name: trimmed, query: entry.query });
165
+ if (writeViews(conn, entry.table, without)) {
166
+ toast(`Saved "${trimmed}".`, 'ok');
167
+ go({ ...entry }, 'replace');
168
+ }
169
+ }
170
+
171
+ async function renameView(conn, entry, view) {
172
+ const trimmed = await promptFor({
173
+ title: 'Rename filter',
174
+ label: 'Name',
175
+ value: view.name,
176
+ hint: view.query,
177
+ confirmLabel: 'Rename',
178
+ });
179
+ if (!trimmed) return;
180
+ const views = viewsFor(conn, entry.table).map((v) => (v.id === view.id ? { ...v, name: trimmed } : v));
181
+ if (writeViews(conn, entry.table, views)) go({ ...entry }, 'replace');
182
+ }
183
+
184
+ function removeView(conn, entry, view) {
185
+ const views = viewsFor(conn, entry.table).filter((v) => v.id !== view.id);
186
+ if (writeViews(conn, entry.table, views)) go({ ...entry }, 'replace');
187
+ }
188
+
189
+ /**
190
+ * A first guess at a name.
191
+ *
192
+ * `invoice total > 500 sort total desc` suggests "total > 500" — the part
193
+ * that distinguishes it. Stripping the table name and the trailing clauses
194
+ * leaves what the filter is actually about.
195
+ *
196
+ * When the filter was reached by walking, the trail says more than the filter
197
+ * does: `work_order supersedes_id = 5` means little on its own, while
198
+ * "WO-100005 · supersedes_id = 5" says which row it came from. The row you
199
+ * walked through is the context the filter is really about.
200
+ */
201
+ function suggestName(entry, table) {
202
+ const query = entry.query;
203
+ const previousRow = [...state.stack]
204
+ .slice(0, state.stack.indexOf(entry))
205
+ .reverse()
206
+ .find((e) => e.kind === 'row');
207
+ const withoutTable = query.trim().slice(table.length).trim();
208
+ const withoutClauses = withoutTable
209
+ .replace(/\s+(?:sort|order\s+by|limit|show|select)\s+.*$/i, '')
210
+ .replace(/^(?:filter|where)\s+/i, '')
211
+ .trim();
212
+ const filterPart = withoutClauses || withoutTable;
213
+ const candidate = previousRow?.label ? `${previousRow.label} · ${filterPart}` : filterPart;
214
+ return candidate.length > 48 ? `${candidate.slice(0, 47)}…` : candidate;
215
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Row virtualisation for the result grid.
3
+ *
4
+ * The database is not the bottleneck. On the 87k demo, forty-two thousand
5
+ * rows are fetched in 35ms and arrive in the browser in about 100ms; putting
6
+ * them in the DOM is what takes seconds and then makes every subsequent
7
+ * scroll and hover slow. So the fix belongs in rendering, not in fetching,
8
+ * and the 2,000-row cap this replaces was treating the symptom.
9
+ *
10
+ * The approach is the boring one, which is the point: measure a row, keep a
11
+ * window of rows either side of the viewport, and pad above and below with
12
+ * two spacer rows so the scrollbar is the size it would have been. No
13
+ * library, no absolute positioning, no transform — a `<tr>` with a tall
14
+ * `<td>` is enough, and it keeps the table a real table, so column widths,
15
+ * sticky headers, text selection and Find-in-page all keep working.
16
+ *
17
+ * Deliberately not animated and deliberately not smoothed. Scrolling is the
18
+ * most repeated interaction in the tool; anything that lags the content
19
+ * behind the scrollbar is felt every single time.
20
+ */
21
+
22
+ /** Rows kept beyond the viewport on each side, so a flick does not show gaps. */
23
+ const OVERSCAN = 12;
24
+
25
+ /** Below this many rows, windowing costs more than it saves. */
26
+ export const VIRTUAL_THRESHOLD = 300;
27
+
28
+ /**
29
+ * Take over a tbody, rendering only what is visible.
30
+ *
31
+ * @param {object} options
32
+ * @param {HTMLElement} options.scroller the element that actually scrolls
33
+ * @param {HTMLTableSectionElement} options.tbody
34
+ * @param {Array} options.rows every row, not just the visible ones
35
+ * @param {(row: any, index: number) => HTMLTableRowElement} options.renderRow
36
+ * @param {number} options.columns for the spacers' colspan
37
+ * @returns {() => void} a teardown function
38
+ */
39
+ export function virtualise({ scroller, tbody, rows, renderRow, columns }) {
40
+ /* Measured from a real rendered row rather than assumed. Row height
41
+ depends on font, zoom and content — a hardcoded guess drifts, and the
42
+ drift shows up as the scrollbar disagreeing with the content. */
43
+ let rowHeight = 0;
44
+ let first = -1;
45
+ let last = -1;
46
+ let frame = 0;
47
+ /** Guards the one repaint a height correction is allowed to cause. */
48
+ let correcting = false;
49
+
50
+ const topSpacer = spacerRow(columns);
51
+ const bottomSpacer = spacerRow(columns);
52
+
53
+ /**
54
+ * Height of a row, from rows actually rendered.
55
+ *
56
+ * Sampled from several rows rather than the first, because the first is not
57
+ * a fair sample: a row whose only reference column is null renders a bare
58
+ * `null` where every other row renders a chip with padding, and measuring
59
+ * that one row makes every offset in the table slightly short.
60
+ *
61
+ * Spread through the data rather than taken from the top, so a table whose
62
+ * first screenful is unrepresentative does not set the height for all of it.
63
+ */
64
+ function measure() {
65
+ const at = [0, Math.floor(rows.length / 3), Math.floor((rows.length * 2) / 3), rows.length - 1]
66
+ .filter((i, n, all) => i >= 0 && all.indexOf(i) === n);
67
+ const samples = at.map((i) => renderRow(rows[i], i));
68
+ tbody.replaceChildren(...samples);
69
+ rowHeight = Math.max(...samples.map((r) => r.getBoundingClientRect().height), 0) || 26;
70
+ }
71
+
72
+ function windowFor() {
73
+ const viewportTop = scroller.scrollTop;
74
+ const viewportHeight = scroller.clientHeight || 600;
75
+ /* The table does not start at the top of the scroller — there is a
76
+ breadcrumb, tabs and a header above it — so the offset is measured
77
+ rather than assumed to be zero. */
78
+ const tableTop = tbody.parentElement.offsetTop;
79
+ const relative = Math.max(0, viewportTop - tableTop);
80
+ const start = Math.max(0, Math.floor(relative / rowHeight) - OVERSCAN);
81
+ const visible = Math.ceil(viewportHeight / rowHeight) + OVERSCAN * 2;
82
+ return { start, end: Math.min(rows.length, start + visible) };
83
+ }
84
+
85
+ function paint() {
86
+ const { start, end } = windowFor();
87
+ // Nothing to do if the window has not moved: scroll fires far more often
88
+ // than the window actually changes.
89
+ if (start === first && end === last) return;
90
+ first = start;
91
+ last = end;
92
+
93
+ topSpacer.firstChild.style.height = `${start * rowHeight}px`;
94
+ bottomSpacer.firstChild.style.height = `${Math.max(0, rows.length - end) * rowHeight}px`;
95
+
96
+ const painted = [topSpacer];
97
+ for (let i = start; i < end; i++) painted.push(renderRow(rows[i], i));
98
+ painted.push(bottomSpacer);
99
+ tbody.replaceChildren(...painted);
100
+
101
+ correct(painted[1]);
102
+ }
103
+
104
+ /**
105
+ * Check the assumption against what was actually drawn, once.
106
+ *
107
+ * Every offset in this file is `index * rowHeight`, so a height that is
108
+ * wrong by a pixel is wrong by a pixel *per row* — at forty thousand rows
109
+ * that is a scrollbar which disagrees with its content by the height of a
110
+ * screenful, and the rows under the cursor are not the rows the scrollbar
111
+ * says.
112
+ *
113
+ * The measurement can be right when taken and wrong later: a webfont
114
+ * arriving after first paint changes every row, and so does a browser zoom
115
+ * that the ResizeObserver sees only after the fact. Rather than enumerate
116
+ * the causes, this compares the number against the thing it describes and
117
+ * corrects it — which covers the causes nobody thought of too.
118
+ *
119
+ * Once per paint, and only when the disagreement is real: a repaint that
120
+ * triggers another correction that triggers another repaint is a loop, and
121
+ * sub-pixel differences are noise from `getBoundingClientRect`.
122
+ */
123
+ function correct(sample) {
124
+ if (correcting || !sample || sample === bottomSpacer) return;
125
+ const actual = sample.getBoundingClientRect().height;
126
+ if (!actual || Math.abs(actual - rowHeight) < 0.5) return;
127
+ correcting = true;
128
+ rowHeight = actual;
129
+ first = -1;
130
+ last = -1;
131
+ paint();
132
+ correcting = false;
133
+ }
134
+
135
+ const onScroll = () => {
136
+ // One paint per frame at most. Scroll can fire many times per frame and
137
+ // rebuilding the window on each is how a virtual list ends up slower
138
+ // than the thing it replaced.
139
+ if (frame) return;
140
+ frame = requestAnimationFrame(() => {
141
+ frame = 0;
142
+ paint();
143
+ });
144
+ };
145
+
146
+ measure();
147
+ paint();
148
+ scroller.addEventListener('scroll', onScroll, { passive: true });
149
+
150
+ /* A resize changes how many rows fit, and a zoom changes how tall they are,
151
+ so both invalidate the measurement. */
152
+ const observer = new ResizeObserver(() => {
153
+ first = -1;
154
+ last = -1;
155
+ paint();
156
+ });
157
+ observer.observe(scroller);
158
+
159
+ return () => {
160
+ scroller.removeEventListener('scroll', onScroll);
161
+ observer.disconnect();
162
+ if (frame) cancelAnimationFrame(frame);
163
+ };
164
+ }
165
+
166
+ function spacerRow(columns) {
167
+ const cell = document.createElement('td');
168
+ cell.colSpan = columns;
169
+ cell.style.padding = '0';
170
+ cell.style.border = 'none';
171
+ const row = document.createElement('tr');
172
+ row.className = 'virtual-spacer';
173
+ row.setAttribute('aria-hidden', 'true');
174
+ row.append(cell);
175
+ return row;
176
+ }