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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- package/package.json +57 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short walk around the interface, once.
|
|
3
|
+
*
|
|
4
|
+
* The case for it is narrow and real. Almost everything here is discoverable
|
|
5
|
+
* by clicking, and four things are not: that the bar takes a language rather
|
|
6
|
+
* than SQL, that clicking a row is the whole point rather than a way to see
|
|
7
|
+
* its columns, that editing exists at all behind a switch that says "Read
|
|
8
|
+
* only", and that the thing in the corner is a database picker. Each is one
|
|
9
|
+
* sentence. A tour is worth six sentences and nothing more.
|
|
10
|
+
*
|
|
11
|
+
* What that rules out is most of what tours do. There is no progress bar, no
|
|
12
|
+
* confetti, nothing that has to be dismissed twice, and no stop that says
|
|
13
|
+
* "click here to continue" — the reader is never made to perform the
|
|
14
|
+
* interface to get out of the tour, because a tour that drives is a tour
|
|
15
|
+
* people learn to escape rather than read. Escape ends it, so does Skip, and
|
|
16
|
+
* ending it is remembered.
|
|
17
|
+
*
|
|
18
|
+
* Two things it does that a simpler version would not:
|
|
19
|
+
*
|
|
20
|
+
* - **A stop whose anchor is not on screen is dropped**, not shown pointing
|
|
21
|
+
* at nothing. The sidebar collapses, the toolbar sheds buttons at narrow
|
|
22
|
+
* widths, and a card captioning an empty rectangle is worse than one
|
|
23
|
+
* fewer stop.
|
|
24
|
+
*
|
|
25
|
+
* - **One stop has no anchor at all.** The idea that a row is a place you
|
|
26
|
+
* walk from is the reason this tool exists and it is not a control, so it
|
|
27
|
+
* is not attached to one. Pinning it to whichever element happened to be
|
|
28
|
+
* nearby would be inventing a location for an idea.
|
|
29
|
+
*
|
|
30
|
+
* It is remembered in `localStorage`, so it is per browser rather than per
|
|
31
|
+
* connection: it teaches the interface, and the interface does not change
|
|
32
|
+
* when the database does.
|
|
33
|
+
*/
|
|
34
|
+
import { $, el, trapFocus } from './core.js';
|
|
35
|
+
import { addMenuItem } from './menu.js';
|
|
36
|
+
|
|
37
|
+
const SEEN = 'tablewalk.tour.v1';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The stops, in the order the eye takes them: where you are, what is here,
|
|
41
|
+
* how to ask, what happens when you click, what it will not do, and where the
|
|
42
|
+
* rest is.
|
|
43
|
+
*/
|
|
44
|
+
const STOPS = [
|
|
45
|
+
{
|
|
46
|
+
/* Two spellings of one thing, and which is on screen depends on how many
|
|
47
|
+
connections there are: the picker when there is a choice to make, the
|
|
48
|
+
plain label when there is not. A stop that named only one of them was
|
|
49
|
+
silently dropped for everybody with a config file — which is to say for
|
|
50
|
+
everybody the tour is most useful to. */
|
|
51
|
+
anchor: ['#conn-picker', '#db-label'],
|
|
52
|
+
title: 'The database you are on',
|
|
53
|
+
body: 'Click it to switch, add another, or edit one. Everything else on the '
|
|
54
|
+
+ 'screen comes from whichever one is named here.',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
anchor: '.sidebar',
|
|
58
|
+
title: 'Every table, with its size',
|
|
59
|
+
body: 'The number is how many rows are in it. Views are marked. Click one to browse it.',
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
anchor: '#querybar',
|
|
63
|
+
title: 'Ask in words, not SQL',
|
|
64
|
+
/* Set as code rather than run into the sentence. A query in prose is a
|
|
65
|
+
string of ordinary words that happens to be a literal, and the reader
|
|
66
|
+
has to be told which part to type. */
|
|
67
|
+
code: 'customer active = true sort name limit 20',
|
|
68
|
+
body: 'Table first, then conditions, then sort and limit. Press ? for the full '
|
|
69
|
+
+ 'grammar, or SQL to see what it compiled to.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
/* No anchor: an idea, not a control. */
|
|
73
|
+
anchor: null,
|
|
74
|
+
title: 'A row is a place, not a record',
|
|
75
|
+
body: 'Click any row and you get every row elsewhere that points at it. The '
|
|
76
|
+
+ 'orders on a customer, the customer on an order. No joins to write, and '
|
|
77
|
+
+ 'a trail behind you that goes back the way you came.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
anchor: '#write-toggle',
|
|
81
|
+
title: 'Read only until you say otherwise',
|
|
82
|
+
/* Described by what it looks like, not by a label it does not carry: the
|
|
83
|
+
control is a padlock, and telling someone to look for the words "Read
|
|
84
|
+
only" sends them hunting for text that is not on the screen. */
|
|
85
|
+
body: 'A closed padlock means nothing gets written. Click it to turn editing on '
|
|
86
|
+
+ 'for this connection; the window goes red while it is. ⌘Z undoes an edit.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
anchor: '#search-button',
|
|
90
|
+
title: 'And the rest',
|
|
91
|
+
body: '⌘K jumps to any table, view or database. ··· has the diagram, the '
|
|
92
|
+
+ 'exports, saved views, and this tour again.',
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
let scrim = null;
|
|
97
|
+
let card = null;
|
|
98
|
+
let ring = null;
|
|
99
|
+
let at = 0;
|
|
100
|
+
/** Undoes the focus trap, and hands the keyboard back to what opened it. */
|
|
101
|
+
let release = null;
|
|
102
|
+
/** The stops that have somewhere to point today. Fixed when the tour opens. */
|
|
103
|
+
let stops = [];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The first of a stop's anchors that is actually on screen.
|
|
107
|
+
*
|
|
108
|
+
* `null` for a stop that deliberately has none, `undefined` for one whose
|
|
109
|
+
* anchors are all absent — the two mean different things and the caller acts
|
|
110
|
+
* differently on each, so they are not collapsed into one falsy answer.
|
|
111
|
+
*/
|
|
112
|
+
function anchorFor(anchor) {
|
|
113
|
+
if (!anchor) return null;
|
|
114
|
+
for (const selector of [anchor].flat()) {
|
|
115
|
+
const node = document.querySelector(selector);
|
|
116
|
+
if (!node) continue;
|
|
117
|
+
const box = node.getBoundingClientRect();
|
|
118
|
+
/* Zero-sized covers `display: none`, a collapsed sidebar, and a button
|
|
119
|
+
the toolbar has dropped at this width. */
|
|
120
|
+
if (box.width > 0 && box.height > 0) return node;
|
|
121
|
+
}
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function tourSeen() {
|
|
126
|
+
try {
|
|
127
|
+
return window.localStorage.getItem(SEEN) === 'done';
|
|
128
|
+
} catch {
|
|
129
|
+
/* Private browsing, or storage turned off. Treating that as "already
|
|
130
|
+
seen" is the right way to be wrong: a tour that cannot remember being
|
|
131
|
+
dismissed would open on every single load. */
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function remember() {
|
|
137
|
+
try {
|
|
138
|
+
window.localStorage.setItem(SEEN, 'done');
|
|
139
|
+
} catch { /* Nothing to do, and nothing worth saying about it. */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function endTour({ remembering = true } = {}) {
|
|
143
|
+
if (remembering) remember();
|
|
144
|
+
release?.();
|
|
145
|
+
release = null;
|
|
146
|
+
scrim?.remove();
|
|
147
|
+
card?.remove();
|
|
148
|
+
ring?.remove();
|
|
149
|
+
scrim = card = ring = null;
|
|
150
|
+
window.removeEventListener('resize', place);
|
|
151
|
+
window.removeEventListener('scroll', place, true);
|
|
152
|
+
document.removeEventListener('keydown', onKey, true);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Start it, or do nothing if there is nothing to show. */
|
|
156
|
+
export function startTour() {
|
|
157
|
+
if (card) return;
|
|
158
|
+
stops = STOPS.filter((s) => s.anchor === null || anchorFor(s.anchor) !== undefined);
|
|
159
|
+
/* Only the anchorless stop left means the interface it describes is not on
|
|
160
|
+
screen, and one card about walking rows is not a tour. */
|
|
161
|
+
if (stops.filter((s) => s.anchor).length < 2) return;
|
|
162
|
+
at = 0;
|
|
163
|
+
|
|
164
|
+
scrim = el('div', { class: 'tour-scrim' });
|
|
165
|
+
/* A click outside ends it rather than advancing. Advancing on a stray click
|
|
166
|
+
is how someone loses the one stop they were reading. */
|
|
167
|
+
scrim.addEventListener('click', () => endTour());
|
|
168
|
+
ring = el('div', { class: 'tour-ring', 'aria-hidden': 'true' });
|
|
169
|
+
card = el('div', {
|
|
170
|
+
class: 'tour-card',
|
|
171
|
+
role: 'dialog',
|
|
172
|
+
'aria-modal': 'true',
|
|
173
|
+
'aria-labelledby': 'tour-title',
|
|
174
|
+
});
|
|
175
|
+
document.body.append(scrim, ring, card);
|
|
176
|
+
/* The scrim already blocks the mouse; without this the keyboard walked
|
|
177
|
+
straight past it into the table underneath, which is the same page
|
|
178
|
+
claiming to be sealed and behaving otherwise. */
|
|
179
|
+
release = trapFocus(card);
|
|
180
|
+
document.addEventListener('keydown', onKey, true);
|
|
181
|
+
window.addEventListener('resize', place);
|
|
182
|
+
/* Capturing, because the thing that scrolls is a pane inside the page and
|
|
183
|
+
not the window — a card pinned to a row that has scrolled away is worse
|
|
184
|
+
than no card. */
|
|
185
|
+
window.addEventListener('scroll', place, true);
|
|
186
|
+
paint();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function onKey(e) {
|
|
190
|
+
if (e.key === 'Escape') { e.preventDefault(); endTour(); return; }
|
|
191
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); return; }
|
|
192
|
+
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function go(delta) {
|
|
196
|
+
const next = at + delta;
|
|
197
|
+
if (next < 0) return;
|
|
198
|
+
if (next >= stops.length) { endTour(); return; }
|
|
199
|
+
at = next;
|
|
200
|
+
paint();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function paint() {
|
|
204
|
+
const stop = stops[at];
|
|
205
|
+
const last = at === stops.length - 1;
|
|
206
|
+
|
|
207
|
+
const next = el('button', {
|
|
208
|
+
type: 'button',
|
|
209
|
+
class: 'tour-next',
|
|
210
|
+
text: last ? 'Done' : 'Next',
|
|
211
|
+
onclick: () => (last ? endTour() : go(1)),
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
card.replaceChildren(
|
|
215
|
+
el('p', { class: 'tour-step', text: `${at + 1} of ${stops.length}` }),
|
|
216
|
+
el('h2', { id: 'tour-title', class: 'tour-title', text: stop.title }),
|
|
217
|
+
...(stop.code ? [el('code', { class: 'tour-code', text: stop.code })] : []),
|
|
218
|
+
el('p', { class: 'tour-body', text: stop.body }),
|
|
219
|
+
el('div', { class: 'tour-actions' }, [
|
|
220
|
+
el('button', {
|
|
221
|
+
type: 'button',
|
|
222
|
+
class: 'ghost tour-skip',
|
|
223
|
+
/* "Skip" while there is more, "Done" is the other button. On the last
|
|
224
|
+
stop this would be a second way to say the same thing, so it goes. */
|
|
225
|
+
text: 'Skip',
|
|
226
|
+
hidden: last,
|
|
227
|
+
onclick: () => endTour(),
|
|
228
|
+
}),
|
|
229
|
+
el('button', {
|
|
230
|
+
type: 'button', class: 'ghost', text: 'Back', hidden: at === 0, onclick: () => go(-1),
|
|
231
|
+
}),
|
|
232
|
+
next,
|
|
233
|
+
]),
|
|
234
|
+
);
|
|
235
|
+
place();
|
|
236
|
+
next.focus();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Put the ring on the anchor and the card beside it. */
|
|
240
|
+
function place() {
|
|
241
|
+
if (!card) return;
|
|
242
|
+
const stop = stops[at];
|
|
243
|
+
const node = stop.anchor ? anchorFor(stop.anchor) : null;
|
|
244
|
+
|
|
245
|
+
if (!node) {
|
|
246
|
+
/* The anchorless stop, and the fallback if an anchor vanished mid-tour —
|
|
247
|
+
a resize that collapsed the sidebar, say. Centred, with no ring, which
|
|
248
|
+
is honest about pointing at nothing. With no ring there is no hole
|
|
249
|
+
punched in anything, so the scrim does the dimming itself. */
|
|
250
|
+
ring.hidden = true;
|
|
251
|
+
scrim.classList.add('dim');
|
|
252
|
+
card.style.top = '50%';
|
|
253
|
+
card.style.left = '50%';
|
|
254
|
+
card.style.transform = 'translate(-50%, -50%)';
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
card.style.transform = '';
|
|
259
|
+
scrim.classList.remove('dim');
|
|
260
|
+
const box = node.getBoundingClientRect();
|
|
261
|
+
const pad = 6;
|
|
262
|
+
ring.hidden = false;
|
|
263
|
+
ring.style.top = `${box.top - pad}px`;
|
|
264
|
+
ring.style.left = `${box.left - pad}px`;
|
|
265
|
+
ring.style.width = `${box.width + pad * 2}px`;
|
|
266
|
+
ring.style.height = `${box.height + pad * 2}px`;
|
|
267
|
+
|
|
268
|
+
const gap = 14;
|
|
269
|
+
const edge = 8;
|
|
270
|
+
const size = card.getBoundingClientRect();
|
|
271
|
+
const vw = window.innerWidth;
|
|
272
|
+
const vh = window.innerHeight;
|
|
273
|
+
|
|
274
|
+
/* Four sides, tried in order of preference, and the first that fits wins.
|
|
275
|
+
|
|
276
|
+
"Below, or else above" is the dropdown's rule and it is wrong here,
|
|
277
|
+
because a tour points at things a dropdown never does. The sidebar is as
|
|
278
|
+
tall as the window: nothing fits below it or above it, so the card fell
|
|
279
|
+
back to the top of the screen and sat on top of the table names it was
|
|
280
|
+
describing. Something that has to cover its subject to talk about it is
|
|
281
|
+
not pointing at anything.
|
|
282
|
+
|
|
283
|
+
Beside is what a tall anchor wants, and below is what a wide one wants,
|
|
284
|
+
so both are offered and the geometry decides. */
|
|
285
|
+
const sides = [
|
|
286
|
+
{ fits: vh - box.bottom - gap >= size.height + edge, top: box.bottom + gap, left: centreX() },
|
|
287
|
+
{ fits: vw - box.right - gap >= size.width + edge, top: centreY(), left: box.right + gap },
|
|
288
|
+
{ fits: box.top - gap >= size.height + edge, top: box.top - gap - size.height, left: centreX() },
|
|
289
|
+
{ fits: box.left - gap >= size.width + edge, top: centreY(), left: box.left - gap - size.width },
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
function centreX() { return box.left + box.width / 2 - size.width / 2; }
|
|
293
|
+
function centreY() { return box.top + box.height / 2 - size.height / 2; }
|
|
294
|
+
|
|
295
|
+
/* Nothing fits — a viewport smaller than the card plus its anchor. Centred
|
|
296
|
+
is the least bad answer: the card is readable and its buttons reachable,
|
|
297
|
+
which beats a card correctly positioned half off the screen. */
|
|
298
|
+
const chosen = sides.find((s) => s.fits) ?? { top: centreY(), left: centreX() };
|
|
299
|
+
|
|
300
|
+
const clamp = (value, max) => Math.min(Math.max(edge, value), Math.max(edge, max - edge));
|
|
301
|
+
card.style.top = `${clamp(chosen.top, vh - size.height)}px`;
|
|
302
|
+
card.style.left = `${clamp(chosen.left, vw - size.width)}px`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Offer it, and show it the first time.
|
|
307
|
+
*
|
|
308
|
+
* The menu item is registered whether or not the tour has been seen: the one
|
|
309
|
+
* thing worse than a tour nobody asked for is one that cannot be found again
|
|
310
|
+
* by somebody who now wants it.
|
|
311
|
+
*/
|
|
312
|
+
export function initTour() {
|
|
313
|
+
addMenuItem({
|
|
314
|
+
label: 'Take the tour',
|
|
315
|
+
detail: 'six things worth knowing',
|
|
316
|
+
onSelect: () => startTour(),
|
|
317
|
+
});
|
|
318
|
+
if (tourSeen()) return;
|
|
319
|
+
/* After the first paint, so the stops have something to measure. Two frames
|
|
320
|
+
rather than a timeout: the second one runs after the first has been
|
|
321
|
+
drawn, which is exactly the condition, rather than a guess in
|
|
322
|
+
milliseconds that is wrong on a slow machine. */
|
|
323
|
+
requestAnimationFrame(() => requestAnimationFrame(() => startTour()));
|
|
324
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Undo, for writes that have already landed.
|
|
3
|
+
*
|
|
4
|
+
* Write mode is the frightening part of this tool, and most of what makes it
|
|
5
|
+
* frightening is that an edit is a one-way door. Saying "Undo" beside the
|
|
6
|
+
* confirmation is what turns a decision into a correction.
|
|
7
|
+
*
|
|
8
|
+
* This used to keep its own stack: the write path knew what every column held
|
|
9
|
+
* before it changed it, and putting those values back was a one-line call. It
|
|
10
|
+
* worked, and it covered exactly one third of the writes that reach a
|
|
11
|
+
* database through this tool. Not an insert. Not a delete. And not a single
|
|
12
|
+
* thing an agent did over MCP, which is the half of the product where a write
|
|
13
|
+
* is least likely to be watched.
|
|
14
|
+
*
|
|
15
|
+
* So the record moved to the server — `server/journal.ts`, one journal per
|
|
16
|
+
* connection, written by every door — and this asks. What is kept here is the
|
|
17
|
+
* part that was always the browser's: the keystroke, the menu item, and
|
|
18
|
+
* saying what happened afterwards.
|
|
19
|
+
*
|
|
20
|
+
* The guarantees did not move, they widened. The journal re-reads every row
|
|
21
|
+
* before it writes anything and refuses the whole revert if one has changed
|
|
22
|
+
* since, because putting an old value back over a newer one is not an undo,
|
|
23
|
+
* it is a silent overwrite. It is in-session for the same reason this was: a
|
|
24
|
+
* stack that outlives the process invites undoing something from an hour ago,
|
|
25
|
+
* by which time the row has moved on.
|
|
26
|
+
*/
|
|
27
|
+
import { api, state, toast } from './core.js';
|
|
28
|
+
|
|
29
|
+
/* What the server last told us there was to undo. Held so the menu can name
|
|
30
|
+
it without a request per repaint — the menu's detail is a function and is
|
|
31
|
+
called on every open. Refreshed after anything that writes. */
|
|
32
|
+
let pending = null;
|
|
33
|
+
|
|
34
|
+
const forConnection = () => (pending && pending.connection === state.activeConnection ? pending : null);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ask the server what there is to undo.
|
|
38
|
+
*
|
|
39
|
+
* Failures are silent and leave the menu saying "nothing to undo", which is
|
|
40
|
+
* the safe direction to be wrong in: offering an undo that cannot happen is
|
|
41
|
+
* worse than not offering one that could.
|
|
42
|
+
*/
|
|
43
|
+
export async function refreshUndo() {
|
|
44
|
+
const connection = state.activeConnection;
|
|
45
|
+
if (!connection) return;
|
|
46
|
+
try {
|
|
47
|
+
const data = await api('/api/revert', {});
|
|
48
|
+
pending = { connection, next: data.next ?? null, writes: data.writes ?? [] };
|
|
49
|
+
} catch {
|
|
50
|
+
pending = { connection, next: null, writes: [] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const canUndo = () => Boolean(forConnection()?.next);
|
|
55
|
+
|
|
56
|
+
/** What the menu item says it would undo, or null. */
|
|
57
|
+
export function undoDescription() {
|
|
58
|
+
return forConnection()?.next?.summary ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Undo the last write on this connection.
|
|
63
|
+
*
|
|
64
|
+
* The decision about *which* write, and whether it is still safe to undo, is
|
|
65
|
+
* the journal's — this reports what it decided.
|
|
66
|
+
*/
|
|
67
|
+
export async function undoLast() {
|
|
68
|
+
if (!state.activeConnection) return;
|
|
69
|
+
let data;
|
|
70
|
+
try {
|
|
71
|
+
data = await api('/api/revert', { confirm: true });
|
|
72
|
+
} catch (err) {
|
|
73
|
+
toast(err.message, 'error');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
await refreshUndo();
|
|
77
|
+
|
|
78
|
+
if (data.reverted) {
|
|
79
|
+
toast(data.undone?.summary ? `Undone: ${data.undone.summary}.` : 'Undone.', 'ok');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
/* The journal's refusals are sentences meant to be read — "…has changed
|
|
83
|
+
since, so undoing would overwrite that" — so they are shown as they are
|
|
84
|
+
rather than summarised into "could not undo". */
|
|
85
|
+
const first = data.errors?.[0]?.message ?? data.errors?.[0];
|
|
86
|
+
toast(first ?? 'Nothing to undo.', first ? 'error' : 'info');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bind ⌘Z.
|
|
91
|
+
*
|
|
92
|
+
* Stands aside for text fields, where it means "undo my typing" and taking it
|
|
93
|
+
* over would be worse than not offering it — the staged-edit inputs are
|
|
94
|
+
* exactly where someone is most likely to press it for the other reason.
|
|
95
|
+
*/
|
|
96
|
+
export function initUndo() {
|
|
97
|
+
document.addEventListener('keydown', (e) => {
|
|
98
|
+
if (e.key !== 'z' || !(e.metaKey || e.ctrlKey) || e.shiftKey) return;
|
|
99
|
+
const tag = document.activeElement?.tagName;
|
|
100
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable) return;
|
|
101
|
+
if (!canUndo()) return;
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
void undoLast();
|
|
104
|
+
}, true);
|
|
105
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deep links: the stateful half.
|
|
3
|
+
*
|
|
4
|
+
* Reads and writes `window.location`, and decides when. The serialisation
|
|
5
|
+
* itself is in deeplink.js, which touches neither the DOM nor app state.
|
|
6
|
+
*/
|
|
7
|
+
import { api, keyFilter, labelPath, primaryKey, render, renderCrumbs, rowLabel, state } from './core.js';
|
|
8
|
+
import { pageToken } from './page.js';
|
|
9
|
+
import { connectionFromHash, fromHash, hashFor } from './deeplink.js';
|
|
10
|
+
import { looksLikeBreakdown } from './breakdown.js';
|
|
11
|
+
|
|
12
|
+
/** Rebuilding the URL while reacting to one would loop. */
|
|
13
|
+
let applying = false;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Nothing writes the address bar until boot says the incoming link has been
|
|
17
|
+
* read.
|
|
18
|
+
*
|
|
19
|
+
* Boot renders once before applying the link — it has to, because the empty
|
|
20
|
+
* state is what a plain visit should see — and that render called `syncHash`,
|
|
21
|
+
* which wrote *the state it had* over the link that had not been applied yet.
|
|
22
|
+
* Usually the link was re-applied a moment later and the bar corrected
|
|
23
|
+
* itself; sometimes the reader was left on the wrong connection, and always
|
|
24
|
+
* there was a window in which the address bar said something nobody asked
|
|
25
|
+
* for. Opening an agent-supplied link in a fresh tab is exactly the case this
|
|
26
|
+
* tool exists to serve, so it is exactly the case that must not race.
|
|
27
|
+
*/
|
|
28
|
+
let settled = false;
|
|
29
|
+
export function allowSync() {
|
|
30
|
+
settled = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export { connectionFromHash, fromHash, hashFor };
|
|
34
|
+
|
|
35
|
+
export function toHash() {
|
|
36
|
+
/* The page half of a link is written by name where the name identifies it
|
|
37
|
+
— see `pageToken`. Supplied here because deeplink.js imports nothing. */
|
|
38
|
+
return hashFor(state.activeConnection, state.stack, { pageToken });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Fetch readable names for every row in a restored trail.
|
|
43
|
+
*
|
|
44
|
+
* Only the last entry in a stack is rendered, so on a fresh load the earlier
|
|
45
|
+
* crumbs would keep their raw keys — a shared link reading
|
|
46
|
+
* `employee › 2 › invoice sold_by = 2` where the sender saw
|
|
47
|
+
* `employee › Rina Petrova › …`. The trail is the thing being shared, so it
|
|
48
|
+
* should arrive legible.
|
|
49
|
+
*
|
|
50
|
+
* One small query per row, in parallel, and only on restore. Failures are
|
|
51
|
+
* ignored: a crumb showing its key is worse than one showing a name, and far
|
|
52
|
+
* better than a page that fails to load because a label could not be read.
|
|
53
|
+
*/
|
|
54
|
+
export async function resolveLabels(stack, findTableById) {
|
|
55
|
+
const rows = stack.filter((entry) => entry.kind === 'row');
|
|
56
|
+
if (!rows.length) return;
|
|
57
|
+
await Promise.allSettled(rows.map(async (entry) => {
|
|
58
|
+
const table = findTableById(entry.table);
|
|
59
|
+
if (!table) return;
|
|
60
|
+
/* As a view rather than a plain query when the name lives a hop away —
|
|
61
|
+
`party_id.display_name` — because a plain row cannot carry a joined
|
|
62
|
+
column, and a restored trail through such a table read as keys. */
|
|
63
|
+
const path = labelPath(table);
|
|
64
|
+
const result = path && path.includes('.')
|
|
65
|
+
? await api('/api/view/run', {
|
|
66
|
+
view: {
|
|
67
|
+
id: `crumb:${entry.table}`,
|
|
68
|
+
name: table.name,
|
|
69
|
+
base: entry.table,
|
|
70
|
+
columns: [...new Set([...primaryKey(table), path])].map((p) => ({ path: p })),
|
|
71
|
+
filter: { groups: [Object.entries(entry.key).map(([p, value]) => ({ path: p, op: '=', value }))] },
|
|
72
|
+
limit: 1,
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
: await api('/api/query', {
|
|
76
|
+
table: entry.table,
|
|
77
|
+
filter: keyFilter(entry.key),
|
|
78
|
+
limit: 1,
|
|
79
|
+
offset: 0,
|
|
80
|
+
});
|
|
81
|
+
const row = result.rows[0];
|
|
82
|
+
if (!row) return;
|
|
83
|
+
const readable = rowLabel(table, row);
|
|
84
|
+
if (readable) entry.label = readable;
|
|
85
|
+
}));
|
|
86
|
+
renderCrumbs();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write the current state into the address bar.
|
|
91
|
+
*
|
|
92
|
+
* Whether it adds a history entry depends on how you got here. A step in the
|
|
93
|
+
* walk pushes, so the browser's Back button undoes it; a refinement of the
|
|
94
|
+
* view you are already on — a sort toggle, a tab, a re-run of the same query
|
|
95
|
+
* — replaces, so Back does not spend three presses undoing sorts.
|
|
96
|
+
*
|
|
97
|
+
* This started as replaceState for everything, on the reasoning that the
|
|
98
|
+
* trail is already a back stack with its own affordance. That was wrong in a
|
|
99
|
+
* way only using it revealed: with no history entries at all, pressing Back
|
|
100
|
+
* leaves tablewalk entirely and takes the whole trail with it. Avoiding a
|
|
101
|
+
* little history noise is not worth destroying someone's work.
|
|
102
|
+
*/
|
|
103
|
+
export function syncHash() {
|
|
104
|
+
if (applying || !settled) return;
|
|
105
|
+
const next = toHash();
|
|
106
|
+
/* Compared against the path, and only the path. The address may carry a
|
|
107
|
+
`?` — the app is opened with one in development to defeat the module
|
|
108
|
+
cache — and comparing the whole address would find a difference on every
|
|
109
|
+
render and rewrite the bar forever. */
|
|
110
|
+
if (next === window.location.pathname) return;
|
|
111
|
+
if (state.navMode === 'push') window.history.pushState(null, '', next);
|
|
112
|
+
else window.history.replaceState(null, '', next);
|
|
113
|
+
// Anything that renders without going through go() is a refinement.
|
|
114
|
+
state.navMode = 'replace';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply a hash to the app. Used on load and when someone edits the address
|
|
119
|
+
* bar or uses the browser's Back button.
|
|
120
|
+
*/
|
|
121
|
+
export function applyHash(hash, { findTableById, selectConnection }) {
|
|
122
|
+
/* The connection is read first and separately. Parsing the trail needs the
|
|
123
|
+
schema it was written against — a link into another database names tables
|
|
124
|
+
the current one has never heard of, so every row segment fails to resolve
|
|
125
|
+
and is silently dropped. Switch first, parse second. */
|
|
126
|
+
const target = connectionFromHash(hash);
|
|
127
|
+
if (target && state.activeConnection && target !== state.activeConnection) {
|
|
128
|
+
// Handed the raw hash, not a parsed stack: it can only be parsed once
|
|
129
|
+
// the new schema is loaded.
|
|
130
|
+
void selectConnection(target, hash);
|
|
131
|
+
/* Not `true`. The switch is asynchronous and renders the target itself
|
|
132
|
+
when it lands; a caller that renders in the meantime paints the
|
|
133
|
+
*outgoing* connection — which on a cold tab is whatever another tab left
|
|
134
|
+
active on the server — into the address bar for a frame. That frame is a
|
|
135
|
+
`/other-db` URL that can be copied or bookmarked, and it is exactly what
|
|
136
|
+
the cold-link guarantee forbids. The sentinel tells the caller the
|
|
137
|
+
render is not theirs to do. */
|
|
138
|
+
return 'switching';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parsed = fromHash(hash, findTableById, { isBreakdown: looksLikeBreakdown });
|
|
142
|
+
if (!parsed) return false;
|
|
143
|
+
applying = true;
|
|
144
|
+
try {
|
|
145
|
+
state.stack = parsed.stack;
|
|
146
|
+
state.page = 0;
|
|
147
|
+
// Restoring from the address bar must not itself add an entry, or Back
|
|
148
|
+
// would need two presses to get past the state it just restored.
|
|
149
|
+
state.navMode = 'replace';
|
|
150
|
+
render();
|
|
151
|
+
void resolveLabels(parsed.stack, findTableById);
|
|
152
|
+
return true;
|
|
153
|
+
} finally {
|
|
154
|
+
applying = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Suppress hash writes while a caller rebuilds state from one. */
|
|
159
|
+
export function whileApplying(fn) {
|
|
160
|
+
applying = true;
|
|
161
|
+
try {
|
|
162
|
+
return fn();
|
|
163
|
+
} finally {
|
|
164
|
+
applying = false;
|
|
165
|
+
}
|
|
166
|
+
}
|