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,95 @@
1
+ /**
2
+ * Pages you pinned, and pages you were just on.
3
+ *
4
+ * A page is the most expensive thing anyone builds in tablewalk — sections
5
+ * chosen, columns picked, an order decided — and until now the only way back
6
+ * to one was to remember its name and type it into ⌘K. Everything else the
7
+ * tool remembers for you (the trail, the last root of each page, saved
8
+ * filters) it remembers because retyping it is the tax on coming back
9
+ * tomorrow, and this is the same tax on the most expensive thing.
10
+ *
11
+ * Two lists, because they answer two different questions. Pinned is "the
12
+ * pages I work in", stated once. Recent is "where was I", which nobody
13
+ * states — and a recent entry carries the record as well as the page, since
14
+ * being sent back to "choose a customer" is not being sent back to where you
15
+ * were.
16
+ *
17
+ * Per connection, like every other memory here: the same page name means a
18
+ * different thing on staging and on production, and the last customer you
19
+ * looked at on one is not a record on the other.
20
+ */
21
+ import { loadJson, saveJson, state } from './core.js';
22
+
23
+ const PINNED_KEY = 'tablewalk.pages.pinned.v1';
24
+ const VISITS_KEY = 'tablewalk.pages.visits.v1';
25
+
26
+ /** How many positions are worth keeping. Enough for a day's work, not a log. */
27
+ const VISITS_MAX = 12;
28
+
29
+ const conn = () => state.activeConnection ?? 'default';
30
+ const read = (key) => loadJson(key, {})[conn()] ?? [];
31
+
32
+ function write(key, value) {
33
+ const all = loadJson(key, {});
34
+ all[conn()] = value;
35
+ saveJson(key, all);
36
+ }
37
+
38
+ /* ---------- pinned ---------- */
39
+
40
+ /**
41
+ * The pinned pages, each carrying its own name and base table.
42
+ *
43
+ * Stored rather than looked up, because the list of pages this connection
44
+ * *has* does not include the suggested one — the page every table has and
45
+ * nobody built — and pinning "the customer page" is exactly what someone
46
+ * does before they have built anything. A pin that could only name pages
47
+ * from a file would be a pin that silently dropped the commonest case.
48
+ */
49
+ export function pinnedPages() {
50
+ return read(PINNED_KEY).filter((p) => p && p.id);
51
+ }
52
+
53
+ export function isPinned(pageId) {
54
+ return pinnedPages().some((p) => p.id === pageId);
55
+ }
56
+
57
+ /** Pin or unpin, and answer what it is now. */
58
+ export function togglePinned(page) {
59
+ const pinned = pinnedPages();
60
+ const next = pinned.some((p) => p.id === page.id)
61
+ ? pinned.filter((p) => p.id !== page.id)
62
+ : [...pinned, { id: page.id, name: page.name, base: page.base }];
63
+ write(PINNED_KEY, next);
64
+ return next.some((p) => p.id === page.id);
65
+ }
66
+
67
+ /* ---------- where you were ---------- */
68
+
69
+ /**
70
+ * Record a position: this page, and the record it was about.
71
+ *
72
+ * Keyed by page *and* record, so opening three customers leaves three
73
+ * entries rather than one that keeps changing — "the last three customers I
74
+ * looked at" is the useful list, and a single slot per page throws away two
75
+ * of them. A page with no root chosen still counts: getting back to the
76
+ * chooser is worth one line.
77
+ */
78
+ export function rememberVisit({ pageId, name, base, key, label }) {
79
+ if (!pageId) return;
80
+ const stamp = JSON.stringify(key ?? null);
81
+ const visits = read(VISITS_KEY)
82
+ .filter((v) => !(v.pageId === pageId && JSON.stringify(v.key ?? null) === stamp));
83
+ visits.unshift({ pageId, name, base, key, label });
84
+ write(VISITS_KEY, visits.slice(0, VISITS_MAX));
85
+ }
86
+
87
+ export function recentVisits() {
88
+ return read(VISITS_KEY);
89
+ }
90
+
91
+ /** Forget a page entirely — it was deleted, or renamed out of existence. */
92
+ export function forgetPage(pageId) {
93
+ write(PINNED_KEY, pinnedPages().filter((p) => p.id !== pageId));
94
+ write(VISITS_KEY, recentVisits().filter((v) => v.pageId !== pageId));
95
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Cmd+K: go anywhere by typing its name.
3
+ *
4
+ * The sidebar is a fine way to browse eleven tables and a poor way to reach
5
+ * one of sixty-six. Past a screenful, finding a table means scrolling a list
6
+ * you already know the answer in — you know it is called `purchase_order_line`
7
+ * and the interface still makes you look for it.
8
+ *
9
+ * So this is the keyboard route to everything nameable: tables, views, saved
10
+ * filters, and the connections themselves. One list, ranked, because having
11
+ * to know which *kind* of thing you are looking for before you can search for
12
+ * it defeats the purpose.
13
+ *
14
+ * Matching is subsequence-based rather than substring: `pol` finds
15
+ * `purchase_order_line`, which is how anyone who knows the schema would type
16
+ * it. Exact and prefix matches still win, so typing a full name never ranks
17
+ * it below something that merely contains the same letters.
18
+ */
19
+ import { $, el, findTable, go, state, tableView, svgEl,
20
+ } from './core.js';
21
+ import { allSavedFilters } from './views.js';
22
+ import { historyFor } from './history.js';
23
+ import { detailOf, menuItems, whenMenuChanges } from './menu.js';
24
+ import { savedStatements } from './sqlsaved.js';
25
+ import { openSql } from './sql.js';
26
+ import { selectConnection } from './connections.js';
27
+
28
+ /**
29
+ * Score `text` against a typed query. Higher is better; 0 means no match.
30
+ *
31
+ * The tiers matter more than the numbers: an exact name beats a prefix beats
32
+ * a substring beats a scattered subsequence. Within a tier, shorter names win
33
+ * — `site` should outrank `site_visit_note` for the query "site", because the
34
+ * shorter one is what someone typing four letters usually meant.
35
+ */
36
+ export function score(text, query) {
37
+ if (!query) return 1;
38
+ const haystack = text.toLowerCase();
39
+ const needle = query.toLowerCase();
40
+
41
+ if (haystack === needle) return 1000;
42
+ if (haystack.startsWith(needle)) return 800 - haystack.length;
43
+ const at = haystack.indexOf(needle);
44
+ if (at >= 0) return 600 - at - haystack.length / 10;
45
+
46
+ /* Subsequence: every character of the query, in order, anywhere.
47
+
48
+ Two things are rewarded, and between them they are what make `pol` mean
49
+ `purchase_order_line` rather than `employee_skill`:
50
+
51
+ - a match at the start of a word — the beginning of the name, or the
52
+ letter after an underscore — because initials are how people
53
+ abbreviate a snake_case name in their head;
54
+ - runs of adjacent characters, so `porder` prefers `purchase_order`
55
+ over a name where those letters merely occur in that order.
56
+
57
+ Length is a small penalty rather than a large one: it breaks ties between
58
+ equally good matches without letting a long exact match lose to a short
59
+ accidental one. */
60
+ let i = 0;
61
+ let run = 0;
62
+ let points = 0;
63
+ for (let at = 0; at < haystack.length; at += 1) {
64
+ const char = haystack[at];
65
+ if (char !== needle[i]) { run = 0; continue; }
66
+ const boundary = at === 0 || /[^a-z0-9]/.test(haystack[at - 1]);
67
+ run += 1;
68
+ points += run + (boundary ? 12 : 0);
69
+ i += 1;
70
+ if (i === needle.length) break;
71
+ }
72
+ return i === needle.length ? 100 + points - haystack.length / 20 : 0;
73
+ }
74
+
75
+ /** Everything the palette can take you to, before filtering. */
76
+ function entries() {
77
+ const items = [];
78
+
79
+ for (const table of state.schema?.tables ?? []) {
80
+ items.push({
81
+ kind: table.isView ? 'view' : 'table',
82
+ label: table.name,
83
+ // The id only adds anything when it differs — a qualified name like
84
+ // `app.invoice`. Repeating the name doubles its length for no gain and
85
+ // lets a match run across the join between the two copies.
86
+ search: table.id === table.name ? table.name : `${table.name} ${table.id}`,
87
+ detail: `${table.columns.length} column${table.columns.length === 1 ? '' : 's'}`,
88
+ run: () => go(tableView(table.id), 'reset'),
89
+ });
90
+ }
91
+
92
+ for (const filter of allSavedFilters()) {
93
+ // A saved filter is only reachable if its table still is.
94
+ if (!findTable(filter.table)) continue;
95
+ items.push({
96
+ kind: 'filter',
97
+ label: filter.name,
98
+ search: `${filter.name} ${filter.table} ${filter.query}`,
99
+ detail: filter.table,
100
+ run: () => go(tableView(filter.table, filter.query), 'reset'),
101
+ });
102
+ }
103
+
104
+ /* Recent queries, alongside everything else.
105
+
106
+ A query you ran ten minutes ago is a place you were, in exactly the sense
107
+ a table is — and searching for it by the words you typed is how you would
108
+ look for it anyway. Bounded to the most recent handful so the list stays
109
+ a shortlist rather than becoming a log. */
110
+ for (const entry of historyFor().slice(0, 12)) {
111
+ const table = entry.q.split(/\s+/)[0];
112
+ if (!findTable(table) && !state.schema?.tables.some((t) => t.name === table)) continue;
113
+ items.push({
114
+ kind: 'recent',
115
+ label: entry.q,
116
+ search: entry.q,
117
+ run: () => go(tableView(findTable(table) ? table : state.schema.tables.find((t) => t.name === table).id, entry.q), 'reset'),
118
+ });
119
+ }
120
+
121
+ /* Saved SQL, alongside everything else. A statement someone committed to
122
+ tablewalk.json is a place you go, in the same sense a table is. */
123
+ for (const statement of savedStatements()) {
124
+ items.push({
125
+ kind: 'sql',
126
+ label: statement.name,
127
+ search: `${statement.name} ${statement.description ?? ''} ${statement.sql}`,
128
+ detail: statement.source === 'config' ? 'from tablewalk.json' : 'saved SQL',
129
+ run: () => openSql(statement.sql),
130
+ });
131
+ }
132
+
133
+ /* The overflow menu's actions, searchable. Registered in one place and
134
+ offered in two, so nothing can be in the menu and missing from here. */
135
+ for (const action of menuItems()) {
136
+ items.push({
137
+ /* A page is a page, not an "action". The registry groups what it holds,
138
+ and the group is the closest thing to a kind that a registered
139
+ command has — a row labelled ACTION beside three labelled TABLE told
140
+ the reader nothing about what they were about to open. */
141
+ kind: action.group === 'Pages' ? 'page' : 'action',
142
+ label: action.label.replace(/…$/, ''),
143
+ search: `${action.label} ${detailOf(action) ?? ''}`.trim(),
144
+ detail: detailOf(action),
145
+ run: () => action.onSelect(),
146
+ });
147
+ }
148
+
149
+ for (const connection of state.connections ?? []) {
150
+ if (connection.id === state.activeConnection) continue;
151
+ items.push({
152
+ kind: 'database',
153
+ label: connection.name,
154
+ search: `${connection.name} ${connection.dialect}`,
155
+ detail: connection.detail ?? connection.dialect,
156
+ run: () => selectConnection(connection.id),
157
+ });
158
+ }
159
+
160
+ return items;
161
+ }
162
+
163
+ let open = false;
164
+
165
+ export function openPalette() {
166
+ if (open) return;
167
+ open = true;
168
+
169
+ const input = el('input', {
170
+ type: 'text',
171
+ class: 'palette-input',
172
+ placeholder: 'Go to a table, view, saved filter or database…',
173
+ 'aria-label': 'Search',
174
+ autocomplete: 'off',
175
+ spellcheck: 'false',
176
+ });
177
+ const list = el('div', { class: 'palette-list', role: 'listbox' });
178
+ const box = el('div', { class: 'palette' }, [input, list]);
179
+ const backdrop = el('div', { class: 'palette-backdrop' }, box);
180
+
181
+ let all = entries();
182
+ let shown = [];
183
+ let active = 0;
184
+
185
+ /* Commands can arrive while the palette is open — the pages group is
186
+ registered asynchronously on load — and a list snapshotted at open would
187
+ answer "nothing matches" about a page the menu already shows. */
188
+ const stopWatching = whenMenuChanges(() => {
189
+ all = entries();
190
+ paint();
191
+ });
192
+
193
+ const paint = () => {
194
+ const query = input.value.trim();
195
+ shown = all
196
+ .map((item) => ({ item, points: score(item.search, query) }))
197
+ .filter(({ points }) => points > 0)
198
+ .sort((a, b) => b.points - a.points)
199
+ // Bounded because a palette is a shortlist. Anything past twenty is
200
+ // reached by typing another character, not by scrolling.
201
+ .slice(0, 20)
202
+ .map(({ item }) => item);
203
+ active = 0;
204
+
205
+ if (!shown.length) {
206
+ list.replaceChildren(el('p', { class: 'palette-empty', text: query ? `Nothing matches "${query}".` : 'Nothing to go to.' }));
207
+ return;
208
+ }
209
+ list.replaceChildren(...shown.map((item, i) => el('div', {
210
+ class: `palette-item${i === active ? ' active' : ''}`,
211
+ role: 'option',
212
+ 'aria-selected': String(i === active),
213
+ onclick: () => choose(i),
214
+ onmousemove: () => setActive(i),
215
+ }, [
216
+ el('span', { class: `palette-kind kind-${item.kind}`, text: item.kind }),
217
+ el('span', { class: 'palette-label', text: item.label }),
218
+ item.detail ? el('span', { class: 'palette-detail', text: item.detail }) : null,
219
+ ])));
220
+ };
221
+
222
+ const setActive = (index) => {
223
+ if (!shown.length) return;
224
+ active = (index + shown.length) % shown.length;
225
+ [...list.children].forEach((node, i) => {
226
+ node.classList.toggle('active', i === active);
227
+ node.setAttribute('aria-selected', String(i === active));
228
+ });
229
+ list.children[active]?.scrollIntoView({ block: 'nearest' });
230
+ };
231
+
232
+ const close = () => {
233
+ open = false;
234
+ stopWatching();
235
+ backdrop.remove();
236
+ document.removeEventListener('keydown', onKey, true);
237
+ };
238
+
239
+ const choose = (index) => {
240
+ const item = shown[index];
241
+ if (!item) return;
242
+ close();
243
+ item.run();
244
+ };
245
+
246
+ function onKey(e) {
247
+ switch (e.key) {
248
+ case 'Escape':
249
+ /* Stopped as well as prevented. The composer and the page listen for
250
+ Escape at the document, so one press closed the palette *and* the
251
+ surface underneath it — which is never what a single Escape means.
252
+ A modal takes the key it handles. */
253
+ e.preventDefault();
254
+ e.stopPropagation();
255
+ close();
256
+ return;
257
+ case 'ArrowDown': e.preventDefault(); setActive(active + 1); return;
258
+ case 'ArrowUp': e.preventDefault(); setActive(active - 1); return;
259
+ case 'Enter': e.preventDefault(); choose(active); return;
260
+ default:
261
+ }
262
+ }
263
+
264
+ input.addEventListener('input', paint);
265
+ backdrop.addEventListener('pointerdown', (e) => { if (e.target === backdrop) close(); });
266
+ document.addEventListener('keydown', onKey, true);
267
+
268
+ paint();
269
+ document.body.append(backdrop);
270
+ input.focus();
271
+ }
272
+
273
+ /**
274
+ * Bind Cmd+K / Ctrl+K.
275
+ *
276
+ * Bound on the document with capture so it works from anywhere including the
277
+ * query bar, which is where the hands usually are.
278
+ */
279
+ export function initPalette() {
280
+ document.addEventListener('keydown', (e) => {
281
+ if (e.key !== 'k' || !(e.metaKey || e.ctrlKey)) return;
282
+ e.preventDefault();
283
+ openPalette();
284
+ }, true);
285
+ }
286
+
287
+ /* ---------- the sidebar toggle ---------- */
288
+
289
+ const SIDEBAR_KEY = 'tablewalk.sidebar-hidden';
290
+
291
+ /**
292
+ * Hide the table list.
293
+ *
294
+ * Worth having once the palette exists: with Cmd+K reaching every table by
295
+ * name, the sidebar stops being the only way in and becomes 232 pixels that
296
+ * a wide record could use instead. The state is remembered, because a
297
+ * preference about screen space that resets on reload is not a preference.
298
+ */
299
+ export function initSidebarToggle() {
300
+ const hidden = window.localStorage?.getItem(SIDEBAR_KEY) === '1';
301
+
302
+ const remember = (next) => {
303
+ try {
304
+ window.localStorage?.setItem(SIDEBAR_KEY, next ? '1' : '0');
305
+ } catch {
306
+ /* Private browsing. The toggle still works for this session. */
307
+ }
308
+ };
309
+
310
+ /* Two buttons, because a control that hides its own container cannot also
311
+ be the way to bring it back. The collapse button lives in the sidebar,
312
+ next to the filter field and directly above the list it hides; the reveal
313
+ button appears at the left edge where the sidebar was, which is where the
314
+ hand goes looking for it. */
315
+ /* Drawn rather than typed, for the same reason the home crumb is: `«` and
316
+ `»` are guillemets, which every platform sets at a different weight and
317
+ several set as punctuation too small to be a target. And they were
318
+ `--text-faint` — a mid-grey at half alpha, which is a token for a rule
319
+ between things, not for a control somebody is meant to find. */
320
+ const chevrons = (facing) => svgEl('svg', {
321
+ viewBox: '0 0 16 16', width: '15', height: '15', 'aria-hidden': 'true',
322
+ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6',
323
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
324
+ }, facing === 'left'
325
+ ? [svgEl('path', { d: 'M9.5 4L5.5 8l4 4' }), svgEl('path', { d: 'M13 4L9 8l4 4' })]
326
+ : [svgEl('path', { d: 'M6.5 4l4 4-4 4' }), svgEl('path', { d: 'M3 4l4 4-4 4' })]);
327
+
328
+ const collapse = el('button', {
329
+ type: 'button',
330
+ class: 'sidebar-collapse',
331
+ 'aria-label': 'Hide the table list',
332
+ 'aria-controls': 'table-list',
333
+ title: 'Hide the table list (⌘B)',
334
+ onclick: () => apply(true),
335
+ }, chevrons('left'));
336
+
337
+ const reveal = el('button', {
338
+ type: 'button',
339
+ class: 'sidebar-reveal',
340
+ 'aria-label': 'Show the table list',
341
+ 'aria-controls': 'table-list',
342
+ title: 'Show the table list (⌘B)',
343
+ onclick: () => apply(false),
344
+ }, chevrons('right'));
345
+
346
+ function apply(next) {
347
+ document.body.classList.toggle('sidebar-hidden', next);
348
+ remember(next);
349
+ collapse.setAttribute('aria-expanded', String(!next));
350
+ reveal.setAttribute('aria-expanded', String(!next));
351
+ // Focus follows the control that survives, so the keyboard is never left
352
+ // on a button that has just been hidden.
353
+ if (document.activeElement === collapse || document.activeElement === reveal) {
354
+ (next ? reveal : collapse).focus({ preventScroll: true });
355
+ }
356
+ }
357
+
358
+ /* Sharing the filter field's row rather than taking one of its own: the
359
+ sidebar's vertical space is the list, and a button on a line by itself
360
+ would cost a table's worth of it. */
361
+ const filter = $('table-filter');
362
+ const row = el('div', { class: 'sidebar-filter-row' });
363
+ filter.replaceWith(row);
364
+ row.append(filter, collapse);
365
+ document.querySelector('.layout')?.prepend(reveal);
366
+
367
+ apply(hidden);
368
+
369
+ document.addEventListener('keydown', (e) => {
370
+ if (e.key !== 'b' || !(e.metaKey || e.ctrlKey)) return;
371
+ e.preventDefault();
372
+ apply(!document.body.classList.contains('sidebar-hidden'));
373
+ }, true);
374
+ }