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