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,524 @@
1
+ /* Export and query history.
2
+ Loaded after style.css and names no colour of its own: every value here is
3
+ a token from that file, so the two stay in step through a theme change. */
4
+
5
+ /* ---------- export ---------- */
6
+
7
+ .export {
8
+ /* Pushed to the far end of the result header: the stats on the left are
9
+ what you read, the action belongs where an action is looked for. */
10
+ margin-left: auto;
11
+ position: relative;
12
+ }
13
+
14
+ .export-summary {
15
+ list-style: none;
16
+ cursor: pointer;
17
+ padding: 4px 10px;
18
+ border: 1px solid var(--border-strong);
19
+ border-radius: var(--radius);
20
+ color: var(--text-dim);
21
+ font-size: 12px;
22
+ user-select: none;
23
+ }
24
+ .export-summary::-webkit-details-marker { display: none; }
25
+ .export-summary::after { content: " ▾"; color: var(--text-faint); }
26
+ .export[open] .export-summary { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
27
+ .export-summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
28
+
29
+ .export-menu {
30
+ position: absolute;
31
+ right: 0;
32
+ top: calc(100% + 4px);
33
+ z-index: 20;
34
+ min-width: 15rem;
35
+ padding: 6px;
36
+ background: var(--surface);
37
+ border: 1px solid var(--border-strong);
38
+ border-radius: var(--radius);
39
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.16);
40
+ }
41
+
42
+ .export-group { padding: 2px 0; }
43
+ .export-group + .export-group { border-top: 1px solid var(--border); margin-top: 4px; padding-top: 6px; }
44
+
45
+ .export-kind {
46
+ display: block;
47
+ padding: 2px 8px;
48
+ font: 600 11px var(--mono);
49
+ letter-spacing: 0.04em;
50
+ color: var(--text-faint);
51
+ }
52
+
53
+ .export-item {
54
+ display: block;
55
+ width: 100%;
56
+ text-align: left;
57
+ padding: 5px 8px;
58
+ border: 1px solid transparent;
59
+ border-radius: var(--radius);
60
+ background: transparent;
61
+ color: var(--text);
62
+ font-size: 12.5px;
63
+ }
64
+ .export-item:hover:not(:disabled) { background: var(--surface-2); }
65
+ .export-item:disabled { color: var(--text-dim); cursor: progress; }
66
+
67
+ .export-columns-item { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
68
+ .export-count { font: 11.5px var(--mono); color: var(--text-faint); }
69
+ /* No bottom border: the group below already draws a top one at this seam, and
70
+ the two together were a double hairline six pixels apart. The padding stays
71
+ — it is what gave the rule room, and the rule it now belongs to is the
72
+ next group's. */
73
+ .export-group:first-child { padding-bottom: 4px; margin-bottom: 2px; }
74
+
75
+ /* ---------- the column chooser ---------- */
76
+
77
+ dialog.cols { width: min(30rem, 92vw); }
78
+
79
+ .col-actions {
80
+ display: flex;
81
+ align-items: center;
82
+ gap: 6px;
83
+ margin-bottom: 8px;
84
+ }
85
+ .col-actions button { padding: 3px 10px; font-size: 12px; }
86
+ .col-count { margin-left: auto; font: 11.5px var(--mono); color: var(--text-faint); }
87
+
88
+ .col-list {
89
+ max-height: min(24rem, 48vh);
90
+ overflow-y: auto;
91
+ border: 1px solid var(--border);
92
+ border-radius: var(--radius);
93
+ }
94
+
95
+ .col-row {
96
+ display: flex;
97
+ align-items: baseline;
98
+ gap: 8px;
99
+ padding: 4px 10px;
100
+ cursor: pointer;
101
+ }
102
+ .col-row + .col-row { border-top: 1px solid var(--border); }
103
+ .col-row:hover { background: var(--surface-2); }
104
+ .col-row input { accent-color: var(--accent); align-self: center; }
105
+
106
+ .col-name { font: 12.5px var(--mono); color: var(--text); }
107
+
108
+ /* Type and provenance sit on the right, dim: they inform the choice without
109
+ competing with the name you are actually scanning for. */
110
+ .col-type { margin-left: auto; font: 11.5px var(--mono); color: var(--text-dim); }
111
+ /* "not shown" is information, not a warning: it says the export will re-run
112
+ the query to fetch that column, which is a fact rather than a problem. */
113
+ .col-extra {
114
+ font-size: 11px;
115
+ color: var(--text-faint);
116
+ border: 1px solid var(--border-strong);
117
+ border-radius: 999px;
118
+ padding: 0 6px;
119
+ }
120
+
121
+ .export-note {
122
+ margin: 6px 4px 2px;
123
+ font-size: 11.5px;
124
+ line-height: 1.45;
125
+ color: var(--warn);
126
+ }
127
+
128
+ /* ---------- query history ---------- */
129
+
130
+ /* The panel hangs off the query bar, so the bar is its positioning context.
131
+ style.css leaves it static; this is the one rule here that reaches back. */
132
+ .querybar { position: relative; }
133
+
134
+ .history-panel {
135
+ position: absolute;
136
+ top: calc(100% + 6px);
137
+ right: 0;
138
+ z-index: 30;
139
+ width: min(38rem, 90vw);
140
+ max-height: 60vh;
141
+ overflow-y: auto;
142
+ padding: 6px;
143
+ background: var(--surface);
144
+ border: 1px solid var(--border-strong);
145
+ border-radius: var(--radius);
146
+ box-shadow: 0 10px 28px rgb(0 0 0 / 0.22);
147
+ }
148
+ .history-panel[hidden] { display: none; }
149
+
150
+ .history-list { list-style: none; margin: 0; padding: 0; }
151
+
152
+ .history-item {
153
+ display: flex;
154
+ align-items: baseline;
155
+ gap: 10px;
156
+ width: 100%;
157
+ padding: 5px 8px;
158
+ border: 1px solid transparent;
159
+ border-radius: var(--radius);
160
+ background: transparent;
161
+ color: var(--text);
162
+ text-align: left;
163
+ }
164
+ .history-item:hover { background: var(--surface-2); }
165
+
166
+ .history-q {
167
+ flex: 1;
168
+ min-width: 0;
169
+ font: 12.5px var(--mono);
170
+ /* One line each: the list is scanned, and a wrapped four-line query would
171
+ push the rest of the history off the screen. */
172
+ overflow: hidden;
173
+ text-overflow: ellipsis;
174
+ white-space: nowrap;
175
+ }
176
+
177
+ .history-age { color: var(--text-faint); font-size: 11.5px; flex: none; }
178
+
179
+ .history-clear {
180
+ display: block;
181
+ width: 100%;
182
+ margin-top: 6px;
183
+ padding: 5px 8px;
184
+ border-top: 1px solid var(--border);
185
+ border-radius: 0;
186
+ background: transparent;
187
+ color: var(--text-dim);
188
+ font-size: 12px;
189
+ text-align: left;
190
+ }
191
+ .history-clear:hover { color: var(--danger); }
192
+
193
+ .history-empty { margin: 0; padding: 10px; color: var(--text-dim); font-size: 12.5px; }
194
+
195
+ /* ---------- schema export ---------- */
196
+
197
+ .schema-export .export-menu { min-width: 19rem; }
198
+
199
+ /* Not a warning, so not `--warn`: it states what is about to be exported.
200
+ Sitting above the formats because it qualifies all of them. */
201
+ .schema-export .export-scope {
202
+ color: var(--text-dim);
203
+ margin: 2px 4px 6px;
204
+ padding-bottom: 6px;
205
+ border-bottom: 1px solid var(--border);
206
+ }
207
+
208
+ /* Format name and what it is for, on one line — the name alone does not tell
209
+ anyone why they would pick Mermaid over SVG. */
210
+ .schema-export .export-item {
211
+ display: flex; align-items: baseline; justify-content: space-between; gap: 14px;
212
+ }
213
+ /* The description never wraps: two lines per format turns a five-item menu
214
+ into a paragraph, and the name is what is being chosen between. */
215
+ .schema-export .export-count { white-space: nowrap; }
216
+ .schema-export .export-item .export-kind { padding: 0; color: var(--text); }
217
+ .schema-export .export-item:hover .export-kind { color: var(--accent); }
218
+
219
+ /* ---------- the reference peek ---------- */
220
+
221
+ /* Positioned against the viewport rather than a container: the anchor can be
222
+ a cell inside a scrolling table, and a card that scrolls with it would be
223
+ half under the header by the time it was read. It is dismissed on scroll
224
+ instead, which is the honest answer to "the thing you were pointing at has
225
+ moved". */
226
+ .peek {
227
+ position: fixed; z-index: 90;
228
+ min-width: 15rem; max-width: 26rem;
229
+ padding: 8px 10px 6px;
230
+ background: var(--surface);
231
+ border: 1px solid var(--border-strong); border-radius: 8px;
232
+ box-shadow: 0 10px 34px rgb(0 0 0 / 0.18);
233
+ /* A glance, never a target. Letting the pointer reach it would mean the
234
+ card had to survive being hovered, and it is dismissed by the pointer
235
+ leaving the chip. */
236
+ pointer-events: none;
237
+ }
238
+ @media (prefers-reduced-motion: no-preference) {
239
+ .peek { transition: opacity var(--t-hover) var(--ease-out); @starting-style { opacity: 0; } }
240
+ }
241
+
242
+ .peek-head {
243
+ display: flex; align-items: baseline; gap: 8px;
244
+ padding-bottom: 6px; margin-bottom: 6px;
245
+ border-bottom: 1px solid var(--border);
246
+ }
247
+ .peek-table { font: 600 12px var(--mono); color: var(--text); }
248
+ .peek-key { font: 11px var(--mono); color: var(--text-faint); font-variant-numeric: tabular-nums; }
249
+
250
+ .peek-body { display: flex; flex-direction: column; gap: 3px; }
251
+ /* A fixed name column so the values line up: a card whose values start at a
252
+ different place on every line reads as a list of sentences rather than as a
253
+ record. */
254
+ .peek-line { display: grid; grid-template-columns: 7.5rem 1fr; gap: 10px; align-items: baseline; }
255
+ .peek-name { font: 10.5px var(--mono); color: var(--text-faint); }
256
+ .peek-value {
257
+ font: 12px var(--mono); color: var(--text);
258
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
259
+ }
260
+ .peek-value.null { color: var(--text-faint); font-style: italic; }
261
+
262
+ .peek-foot {
263
+ margin-top: 6px; padding-top: 5px;
264
+ border-top: 1px solid var(--border);
265
+ font: 10px var(--mono); text-transform: uppercase; letter-spacing: 0.06em;
266
+ color: var(--text-faint);
267
+ }
268
+
269
+ /* ---------- first run ----------
270
+
271
+ The screen when nothing is connected. Sized like a page of prose rather
272
+ than a dialog: it is the only thing on screen, it has one action, and
273
+ centring a 60ch column is what every other reading surface in this tool
274
+ already does.
275
+
276
+ The chrome is hidden rather than disabled. A query bar over no database is
277
+ a control that invites a keystroke and cannot answer it, and a greyed one
278
+ still says "this is the main thing here" on a screen where the main thing
279
+ is the button below. */
280
+ body.first-run .querybar,
281
+ body.first-run .sidebar,
282
+ body.first-run #write-toggle,
283
+ body.first-run #sql-toggle,
284
+ body.first-run #search-button,
285
+ body.first-run #more-button,
286
+ body.first-run .conn-picker {
287
+ display: none;
288
+ }
289
+
290
+ /* The label still names the state, and is still the way to the manager. */
291
+ body.first-run #db-label {
292
+ color: var(--text-dim);
293
+ }
294
+
295
+ .welcome {
296
+ max-width: 60ch;
297
+ margin: clamp(2rem, 9vh, 5.5rem) auto;
298
+ padding: 0 1.5rem 4rem;
299
+ }
300
+
301
+ .welcome h1 {
302
+ font-size: 1.55rem;
303
+ font-weight: 600;
304
+ letter-spacing: -0.01em;
305
+ margin: 0 0 0.75rem;
306
+ }
307
+
308
+ .welcome-lede {
309
+ color: var(--text-dim);
310
+ line-height: 1.6;
311
+ margin: 0 0 1.75rem;
312
+ }
313
+
314
+ .welcome-go {
315
+ font-size: 0.95rem;
316
+ padding: 0.6rem 1.15rem;
317
+ }
318
+
319
+ .welcome-assure {
320
+ margin: 1.25rem 0 0;
321
+ padding: 0.8rem 0.95rem;
322
+ border: 1px solid var(--border);
323
+ border-radius: var(--radius);
324
+ background: var(--surface-2);
325
+ color: var(--text-dim);
326
+ font-size: 0.86rem;
327
+ line-height: 1.55;
328
+ }
329
+
330
+ .welcome-assure strong { color: var(--text); font-weight: 600; }
331
+
332
+ .welcome-examples {
333
+ margin-top: 2.5rem;
334
+ border-top: 1px solid var(--border);
335
+ padding-top: 1.5rem;
336
+ }
337
+
338
+ .welcome-examples h2 {
339
+ font-size: 0.78rem;
340
+ font-weight: 600;
341
+ text-transform: uppercase;
342
+ letter-spacing: 0.06em;
343
+ color: var(--text-dim);
344
+ margin: 0 0 0.9rem;
345
+ }
346
+
347
+ .welcome-examples ul {
348
+ list-style: none;
349
+ margin: 0 0 1.4rem;
350
+ padding: 0;
351
+ display: grid;
352
+ gap: 0.45rem;
353
+ }
354
+
355
+ /* Label and target on one row, the labels aligned, so the three shapes read
356
+ as three of a kind rather than three sentences. */
357
+ .welcome-examples li {
358
+ display: grid;
359
+ grid-template-columns: 11rem 1fr;
360
+ gap: 0.75rem;
361
+ align-items: baseline;
362
+ font-size: 0.86rem;
363
+ }
364
+
365
+ .welcome-ex-label { color: var(--text-dim); }
366
+
367
+ .welcome-examples code,
368
+ .welcome-config {
369
+ font-family: var(--mono);
370
+ font-size: 0.82rem;
371
+ }
372
+
373
+ .welcome-examples .note { line-height: 1.6; margin: 0 0 1rem; }
374
+
375
+ .welcome-config {
376
+ margin: 0;
377
+ padding: 0.8rem 0.95rem;
378
+ border: 1px solid var(--border);
379
+ border-radius: var(--radius);
380
+ background: var(--surface-2);
381
+ overflow-x: auto;
382
+ line-height: 1.5;
383
+ }
384
+
385
+ @media (max-width: 33rem) {
386
+ .welcome-examples li { grid-template-columns: 1fr; gap: 0.15rem; }
387
+ }
388
+
389
+ /* ---------- the tour ----------
390
+
391
+ A scrim, a ring around the thing being talked about, and a card beside it.
392
+ The scrim is light: this is a tool someone is trying to read, and a
393
+ near-black overlay makes the interface behind it unreadable at exactly the
394
+ moment the card is telling them to look at it. */
395
+ /* The dim is painted by the ring's own outward shadow, not by this, so that
396
+ the one element being talked about is the one element at full strength. A
397
+ full-bleed layer with the ring on top of it tints the anchor as well —
398
+ which is the whole interface saying "look here" while making "here" the
399
+ hardest thing on screen to read.
400
+
401
+ This stays as the thing that catches a click outside. */
402
+ .tour-scrim {
403
+ position: fixed;
404
+ inset: 0;
405
+ z-index: 60;
406
+ }
407
+
408
+ /* The stop with no anchor has no hole to punch, so the scrim paints it. */
409
+ .tour-scrim.dim { background: var(--tour-veil); }
410
+
411
+ :root { --tour-veil: color-mix(in srgb, var(--bg) 62%, transparent); }
412
+
413
+ .tour-ring {
414
+ position: fixed;
415
+ z-index: 61;
416
+ border: 2px solid var(--accent);
417
+ border-radius: var(--radius);
418
+ /* Everything outside the ring, dimmed, in one shadow — which is what makes
419
+ the inside of it untouched. `100vmax` rather than a round number of
420
+ pixels so it still covers the corners of a very wide window. */
421
+ box-shadow: 0 0 0 100vmax var(--tour-veil);
422
+ pointer-events: none;
423
+ transition: top var(--t-popover) var(--ease-out), left var(--t-popover) var(--ease-out),
424
+ width var(--t-popover) var(--ease-out), height var(--t-popover) var(--ease-out);
425
+ }
426
+
427
+ .tour-card {
428
+ position: fixed;
429
+ z-index: 62;
430
+ width: min(23rem, calc(100vw - 2rem));
431
+ padding: 1rem 1.1rem 0.9rem;
432
+ background: var(--surface);
433
+ border: 1px solid var(--border-strong);
434
+ border-radius: var(--radius);
435
+ box-shadow: 0 10px 30px rgb(0 0 0 / 0.14);
436
+ }
437
+
438
+ .tour-step {
439
+ margin: 0 0 0.35rem;
440
+ font-size: 0.72rem;
441
+ letter-spacing: 0.06em;
442
+ text-transform: uppercase;
443
+ color: var(--text-dim);
444
+ }
445
+
446
+ .tour-title {
447
+ margin: 0 0 0.4rem;
448
+ font-size: 1rem;
449
+ font-weight: 600;
450
+ }
451
+
452
+ .tour-body {
453
+ margin: 0;
454
+ font-size: 0.87rem;
455
+ line-height: 1.55;
456
+ color: var(--text-dim);
457
+ }
458
+
459
+ .tour-actions {
460
+ display: flex;
461
+ gap: 0.5rem;
462
+ margin-top: 1rem;
463
+ align-items: center;
464
+ }
465
+
466
+ /* Skip sits away from the two that move through the tour, so "leave" is never
467
+ the button next to the one the hand is already on. */
468
+ .tour-skip { margin-right: auto; }
469
+
470
+ .tour-actions button { font-size: 0.83rem; padding: 0.35rem 0.8rem; }
471
+
472
+ @media (prefers-reduced-motion: reduce) {
473
+ .tour-ring { transition: none; }
474
+ }
475
+
476
+ /* The example query on the stop that explains the bar. Its own line, because
477
+ it is the thing to be typed rather than part of the sentence around it. */
478
+ .tour-code {
479
+ display: block;
480
+ margin: 0 0 0.55rem;
481
+ padding: 0.4rem 0.55rem;
482
+ background: var(--surface-2);
483
+ border: 1px solid var(--border);
484
+ border-radius: var(--radius);
485
+ font-family: var(--mono);
486
+ font-size: 0.79rem;
487
+ color: var(--text);
488
+ /* Wrapped rather than scrolled. A horizontal scrollbar inside a 23rem card
489
+ hides the end of the one line the reader is being asked to copy, and a
490
+ query reads perfectly well over two lines. */
491
+ white-space: pre-wrap;
492
+ word-break: break-word;
493
+ }
494
+
495
+ /* Connections that exist and would not open, on the screen you land on when
496
+ none of them did. The screen used to say "nothing connected" over a list of
497
+ things that were configured and broken, and offer adding another as the
498
+ only move — when the useful move is usually to fix or remove the one that
499
+ failed. */
500
+ .welcome-trouble {
501
+ margin: 1.25rem 0 0;
502
+ padding: 0.85rem 1rem;
503
+ border: 1px solid var(--border);
504
+ border-left: 3px solid var(--danger);
505
+ border-radius: var(--radius);
506
+ background: var(--surface-2);
507
+ font-size: 0.86rem;
508
+ }
509
+
510
+ .welcome-trouble-head { margin: 0 0 0.5rem; }
511
+ .welcome-trouble-head strong { color: var(--text); font-weight: 600; }
512
+
513
+ .welcome-trouble ul {
514
+ list-style: none;
515
+ margin: 0 0 0.8rem;
516
+ padding: 0;
517
+ display: grid;
518
+ gap: 0.4rem;
519
+ }
520
+
521
+ .welcome-trouble li { display: grid; gap: 0.1rem; }
522
+
523
+ .welcome-trouble-name { font-family: var(--mono); font-size: 0.8rem; color: var(--text); }
524
+ .welcome-trouble-why { color: var(--text-dim); line-height: 1.45; }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Which table mentions this?
3
+ *
4
+ * The question you have when a string arrives from somewhere else — an id in
5
+ * a stack trace, a reference from a support ticket, a name in a log — and you
6
+ * do not yet know which table it belongs to. Every other search in this tool
7
+ * starts by naming a table, which is precisely what you cannot do yet.
8
+ *
9
+ * The sweep behind it is the `find` tool the MCP server already offers, run
10
+ * through `/api/agent/call` — the same code path an agent takes, which is
11
+ * what makes the two sides agree about what a match is, what is capped and
12
+ * what was skipped. Anything else would be a second implementation of a
13
+ * bounded scan, and the second one is where the caps drift apart.
14
+ */
15
+ import { api, el, findTable, openRecord, trapFocus } from './core.js';
16
+
17
+ let panel = null;
18
+ /** Undoes the focus trap, and hands the keyboard back where it came from. */
19
+ let release = null;
20
+
21
+ export function openFindValue(initial = '') {
22
+ if (panel) return;
23
+ panel = el('div', {
24
+ class: 'conn-manager find-value',
25
+ role: 'dialog',
26
+ 'aria-modal': 'true',
27
+ 'aria-label': 'Find a value',
28
+ });
29
+ document.body.append(panel);
30
+ release = trapFocus(panel);
31
+ document.addEventListener('keydown', onKey, true);
32
+
33
+ const results = el('div', { class: 'find-results' });
34
+ const input = el('input', {
35
+ type: 'search',
36
+ class: 'find-input',
37
+ placeholder: 'A value — a name, an id, a reference…',
38
+ 'aria-label': 'Value to look for',
39
+ value: initial,
40
+ autocomplete: 'off',
41
+ spellcheck: 'false',
42
+ });
43
+
44
+ const run = async () => {
45
+ const value = input.value.trim();
46
+ if (!value) {
47
+ results.replaceChildren();
48
+ return;
49
+ }
50
+ results.replaceChildren(el('p', { class: 'loading', text: `Looking for “${value}”…` }));
51
+ let answer;
52
+ try {
53
+ answer = await api('/api/agent/call', { name: 'find', arguments: { value } });
54
+ } catch (err) {
55
+ results.replaceChildren(el('p', { class: 'note', text: err.message }));
56
+ return;
57
+ }
58
+ if (answer.isError) {
59
+ results.replaceChildren(el('p', { class: 'note', text: answer.content?.[0]?.text ?? 'That search failed.' }));
60
+ return;
61
+ }
62
+ results.replaceChildren(...report(answer.structuredContent ?? {}, value));
63
+ };
64
+
65
+ let timer = 0;
66
+ input.addEventListener('input', () => {
67
+ clearTimeout(timer);
68
+ /* Slower than a filter box on purpose: each keystroke is a scan of every
69
+ table, not a filter over rows already in hand. */
70
+ timer = setTimeout(() => void run(), 450);
71
+ });
72
+ input.addEventListener('keydown', (e) => {
73
+ if (e.key !== 'Enter') return;
74
+ e.preventDefault();
75
+ clearTimeout(timer);
76
+ void run();
77
+ });
78
+
79
+ panel.replaceChildren(el('div', { class: 'conn-inner' }, [
80
+ el('div', { class: 'conn-head' }, [
81
+ el('h2', { text: 'Find a value' }),
82
+ el('button', { type: 'button', class: 'ghost', text: '×', 'aria-label': 'Close', onclick: close }),
83
+ ]),
84
+ el('p', { class: 'find-note', text: 'Searches the text columns of every table, and uuid columns when the value is a whole uuid. Bounded: a few rows per table, and it says where it stopped.' }),
85
+ input,
86
+ results,
87
+ ]));
88
+ input.focus();
89
+ if (initial) void run();
90
+ }
91
+
92
+ function close() {
93
+ release?.();
94
+ release = null;
95
+ panel?.remove();
96
+ panel = null;
97
+ document.removeEventListener('keydown', onKey, true);
98
+ }
99
+
100
+ function onKey(e) {
101
+ if (e.key !== 'Escape' || !panel) return;
102
+ e.preventDefault();
103
+ close();
104
+ }
105
+
106
+ /**
107
+ * The answer, with everything the sweep is honest about kept.
108
+ *
109
+ * An empty result is only "not in this database" when nothing failed and
110
+ * nothing stopped it early, and the panel says which of those it was —
111
+ * dropping that would make the UI claim something the tool deliberately does
112
+ * not.
113
+ */
114
+ function report(answer, value) {
115
+ const matches = answer.matches ?? [];
116
+ const parts = [];
117
+
118
+ if (!matches.length) {
119
+ parts.push(el('p', { class: 'note', text: `Nothing in ${answer.searched ?? 0} tables holds “${value}”.` }));
120
+ }
121
+
122
+ const byTable = new Map();
123
+ for (const match of matches) {
124
+ const list = byTable.get(match.table) ?? [];
125
+ list.push(match);
126
+ byTable.set(match.table, list);
127
+ }
128
+
129
+ for (const [tableId, found] of byTable) {
130
+ const table = findTable(tableId);
131
+ parts.push(el('div', { class: 'find-group' }, [
132
+ el('h3', { class: 'find-group-head' }, [
133
+ el('span', { text: table?.name ?? tableId }),
134
+ el('span', { class: 'find-count', text: `${found.length}${answer.moreIn?.includes(tableId) ? '+' : ''}` }),
135
+ ]),
136
+ ...found.map((match) => el('button', {
137
+ type: 'button',
138
+ class: 'find-hit',
139
+ /* The key is what makes a match walkable; without one there is
140
+ nothing to open, so it reads rather than pretends to be a link. */
141
+ disabled: !match.key,
142
+ title: match.key ? `Open ${tableId}` : 'This table has no key to open a row by',
143
+ onclick: () => {
144
+ close();
145
+ void openRecord(tableId, match.key, Object.values(match.values ?? {})[0] ?? String(Object.values(match.key)[0]));
146
+ },
147
+ }, [
148
+ el('span', { class: 'find-hit-columns', text: (match.columns ?? []).join(', ') }),
149
+ el('span', { class: 'find-hit-value', text: Object.values(match.values ?? {}).join(' · ') }),
150
+ ])),
151
+ ]));
152
+ }
153
+
154
+ /* What the sweep could not do. Said whether or not anything was found: a
155
+ failed table is the difference between "not here" and "not looked". */
156
+ if (answer.failed?.length) {
157
+ parts.push(el('p', { class: 'note find-warn' }, [
158
+ el('strong', { text: `${answer.failed.length} table${answer.failed.length === 1 ? '' : 's'} could not be searched: ` }),
159
+ el('span', { text: answer.failed.map((f) => f.table).join(', ') }),
160
+ ]));
161
+ }
162
+ if (answer.stopped) {
163
+ parts.push(el('p', {
164
+ class: 'note find-warn',
165
+ text: `Stopped at the ${answer.stopped}${answer.unreached ? `, with ${answer.unreached} tables unread` : ''}. Narrow the value to see the rest.`,
166
+ }));
167
+ }
168
+ return parts;
169
+ }