@zerotal/devtools 1.6.3 → 1.7.0

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 (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Every style the panel has, as one string injected into its shadow root.
3
+ *
4
+ * Two palettes, one set of rules. The tokens are declared twice — once on `#wrap`
5
+ * and once on `#wrap.light` — and a class decides which applies, rather than a
6
+ * media query deciding it in CSS. That keeps the "follow the system" case and the
7
+ * explicit override from fighting each other over specificity: the panel resolves
8
+ * the three-way choice (auto / light / dark) in one place in JavaScript and the
9
+ * stylesheet only ever sees the answer.
10
+ */
11
+
12
+ /** The one place a colour is named. Every rule below reads these through `var()`. */
13
+ const TOKENS = `
14
+ #wrap {
15
+ --bg: #1a1b26; --surf: #24283b; --card: #2f3452; --bdr: #3b4261;
16
+ --text: #c0caf5; --muted: #565f89; --purple: #7aa2f7;
17
+ --green: #9ece6a; --yellow: #e0af68; --red: #f7768e;
18
+ --cyan: #7dcfff; --orange: #ff9e64;
19
+ --childbg: rgba(0,0,0,.15);
20
+ }
21
+ #wrap.light {
22
+ --bg: #f4f4f8; --surf: #e8e9f0; --card: #dcdee8; --bdr: #c0c4d4;
23
+ --text: #343b58; --muted: #6b7192; --purple: #34548a;
24
+ --green: #33635c; --yellow: #8f5e15; --red: #c64343;
25
+ --cyan: #0f4b6e; --orange: #965027;
26
+ --childbg: rgba(0,0,0,.05);
27
+ }`;
28
+
29
+ export const CSS = `<style>
30
+ :host { all: initial; }
31
+ * { box-sizing: border-box; margin: 0; padding: 0; }
32
+ ${TOKENS}
33
+ #wrap {
34
+ font: 12px/1.5 'JetBrains Mono','Fira Code','SF Mono',ui-monospace,monospace;
35
+ color: var(--text);
36
+ }
37
+ #wrap:focus { outline: none; }
38
+ #wrap:focus-visible { outline: none; }
39
+ /* ── utility colours ──────────────────────────────────────────────────────── */
40
+ .green { color: var(--green); }
41
+ .yellow { color: var(--yellow); }
42
+ .red { color: var(--red); }
43
+ .cyan { color: var(--cyan); }
44
+ .dim { color: var(--muted); }
45
+ /* ── bar ──────────────────────────────────────────────────────────────────── */
46
+ #bar {
47
+ height: 32px; background: var(--bg); border-top: 1px solid var(--bdr);
48
+ display: flex; align-items: center; gap: 5px; padding: 0 8px;
49
+ cursor: pointer; user-select: none; overflow: hidden; flex-shrink: 0;
50
+ }
51
+ .logo { color: var(--purple); font-weight: 700; font-size: 13px; flex-shrink: 0; }
52
+ .dot { font-size: 8px; }
53
+ .dot.ok { color: var(--green); }
54
+ .dot.err { color: var(--red); animation: blink 1s step-start infinite; }
55
+ @keyframes blink { 50% { opacity: 0.25; } }
56
+ .bdiv { color: var(--bdr); flex-shrink: 0; }
57
+ .sp { flex: 1; }
58
+ .meth { font-weight: 700; font-size: 11px; flex-shrink: 0; }
59
+ .meth.get { color: var(--green); }
60
+ .meth.post { color: var(--cyan); }
61
+ .meth.put, .meth.patch { color: var(--yellow); }
62
+ .meth.delete { color: var(--red); }
63
+ .bpath { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
64
+ .sc { font-weight: 700; font-size: 11px; flex-shrink: 0; }
65
+ .sc.ok { color: var(--green); }
66
+ .sc.redir { color: var(--cyan); }
67
+ .sc.cli { color: var(--yellow); }
68
+ .sc.srv { color: var(--red); }
69
+ .chip {
70
+ font-size: 10px; padding: 1px 5px; border-radius: 999px;
71
+ background: var(--card); border: 1px solid var(--bdr); white-space: nowrap; flex-shrink: 0;
72
+ }
73
+ .chip.warn { border-color: var(--yellow); color: var(--yellow); }
74
+ .chip.ok { border-color: var(--green); color: var(--green); }
75
+ .ibtn {
76
+ height: 22px; padding: 0 6px;
77
+ background: var(--card); border: 1px solid var(--bdr); color: var(--text);
78
+ cursor: pointer; border-radius: 4px; font-size: 11px; font-family: inherit;
79
+ display: flex; align-items: center; gap: 3px; flex-shrink: 0;
80
+ }
81
+ .ibtn:hover { background: var(--surf); border-color: var(--purple); }
82
+ .ibtn.live-on { border-color: var(--green); color: var(--green); }
83
+ /* New traces arrived while pinned — an offer, never a jump. */
84
+ .ibtn.pending { border-color: var(--purple); color: var(--purple); animation: pulse 2s ease-in-out infinite; }
85
+ @keyframes pulse { 50% { opacity: 0.55; } }
86
+ /* ── panel ────────────────────────────────────────────────────────────────── */
87
+ #panel {
88
+ height: 380px; background: var(--bg); border-top: 1px solid var(--bdr);
89
+ display: flex; flex-direction: column; position: relative;
90
+ }
91
+ /* Drag strip. Sits above the tab row and overhangs upward so it is grabbable
92
+ without stealing clicks from the host page's last few pixels. */
93
+ #grip {
94
+ position: absolute; top: -3px; left: 0; right: 0; height: 7px;
95
+ cursor: ns-resize; z-index: 2;
96
+ }
97
+ #grip:hover, #grip.dragging { background: var(--purple); opacity: .5; }
98
+ #tabs {
99
+ display: flex; gap: 2px; padding: 4px 8px 0;
100
+ background: var(--surf); border-bottom: 1px solid var(--bdr);
101
+ flex-shrink: 0; overflow-x: auto;
102
+ }
103
+ .tab {
104
+ height: 28px; padding: 0 10px; background: transparent; border: none;
105
+ border-bottom: 2px solid transparent; color: var(--muted); cursor: pointer;
106
+ font: inherit; font-size: 11px; white-space: nowrap;
107
+ display: flex; align-items: center; gap: 4px;
108
+ }
109
+ .tab:hover { color: var(--text); }
110
+ .tab.active { color: var(--text); border-bottom-color: var(--purple); }
111
+ .tbdg {
112
+ font-size: 10px; padding: 0 4px; border-radius: 999px;
113
+ background: var(--card); min-width: 16px; text-align: center;
114
+ }
115
+ .tbdg.warn { background: transparent; color: var(--yellow); }
116
+ .ldot { font-size: 8px; color: var(--green); animation: blink 1.5s step-start infinite; }
117
+ #content { flex: 1; overflow-y: auto; overflow-x: hidden; position: relative; }
118
+ /* ── content shared ───────────────────────────────────────────────────────── */
119
+ .empty { color: var(--muted); text-align: center; padding: 40px 20px; }
120
+ .sec { padding: 10px 12px; border-bottom: 1px solid var(--bdr); }
121
+ .sec:last-child { border-bottom: none; }
122
+ .stitle {
123
+ font-size: 10px; text-transform: uppercase; letter-spacing: .6px;
124
+ color: var(--muted); margin-bottom: 8px;
125
+ display: flex; align-items: center; gap: 6px;
126
+ }
127
+ .stats { display: flex; flex-wrap: wrap; gap: 1px; background: var(--bdr); border-bottom: 1px solid var(--bdr); }
128
+ .stat { flex: 1; min-width: 90px; padding: 8px 12px; background: var(--bg); }
129
+ .slbl { font-size: 10px; color: var(--muted); margin-bottom: 2px; }
130
+ .sval { font-weight: 700; font-size: 13px; }
131
+ .rcard { padding: 8px 12px; border-bottom: 1px solid var(--bdr); background: var(--surf); font-size: 12px; }
132
+ /* ── copy buttons ─────────────────────────────────────────────────────────── */
133
+ .cpy {
134
+ background: transparent; border: 1px solid transparent; color: var(--muted);
135
+ font: inherit; font-size: 10px; line-height: 1; padding: 1px 4px;
136
+ border-radius: 3px; cursor: pointer; flex-shrink: 0; opacity: 0;
137
+ transition: opacity .1s;
138
+ }
139
+ .cpy:hover { color: var(--purple); border-color: var(--bdr); }
140
+ .cpy.done { color: var(--green); opacity: 1; }
141
+ .qrow:hover .cpy, .lrow:hover .cpy, .sec:hover .cpy, .tleaf:hover .cpy,
142
+ .tbranch > summary:hover .cpy, .crow:hover .cpy, .cpy:focus { opacity: 1; }
143
+ /* ── queries ──────────────────────────────────────────────────────────────── */
144
+ .qrow { padding: 8px 12px; border-bottom: 1px solid var(--bdr); }
145
+ .qrow:last-child { border-bottom: none; }
146
+ .qmeta { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
147
+ .qdur { font-weight: 700; font-size: 11px; min-width: 44px; }
148
+ .qbar { flex: 1; height: 4px; background: var(--card); border-radius: 2px; max-width: 120px; }
149
+ .qfill { height: 100%; background: var(--purple); border-radius: 2px; }
150
+ .qsql { font-size: 11px; white-space: pre-wrap; word-break: break-all; }
151
+ .qbind { font-size: 10px; color: var(--muted); margin-top: 3px; }
152
+ .bind { color: var(--orange); }
153
+ .wrow { padding: 8px 12px; border-bottom: 1px solid var(--bdr); border-left: 3px solid var(--yellow); }
154
+ .whead { color: var(--yellow); margin-bottom: 4px; font-size: 12px; }
155
+ .wfix { font-size: 10px; color: var(--muted); margin-top: 5px; }
156
+ .wfix code {
157
+ font: inherit; color: var(--cyan); background: var(--card);
158
+ padding: 1px 4px; border-radius: 3px;
159
+ }
160
+ /* ── exception banner ─────────────────────────────────────────────────────── */
161
+ .exc {
162
+ padding: 9px 12px; background: var(--card); border-left: 3px solid var(--red);
163
+ border-bottom: 1px solid var(--bdr);
164
+ }
165
+ .exhead { color: var(--red); font-weight: 700; font-size: 11px; margin-bottom: 3px; display: flex; align-items: center; gap: 6px; }
166
+ .exmsg { font-size: 11px; white-space: pre-wrap; word-break: break-word; }
167
+ /* ── logs ─────────────────────────────────────────────────────────────────── */
168
+ .lrow { display: flex; gap: 6px; padding: 4px 12px; border-bottom: 1px solid var(--bdr); }
169
+ .ltime { color: var(--muted); min-width: 54px; font-size: 10px; }
170
+ .llvl { min-width: 38px; font-weight: 700; font-size: 11px; }
171
+ .llvl.log, .llvl.debug, .llvl.info { color: var(--cyan); }
172
+ .llvl.warn { color: var(--yellow); }
173
+ .llvl.error { color: var(--red); }
174
+ .lmsg { font-size: 11px; white-space: pre-wrap; word-break: break-all; flex: 1; }
175
+ /* ── request kv table ─────────────────────────────────────────────────────── */
176
+ .kv { width: 100%; border-collapse: collapse; font-size: 11px; }
177
+ .kv td { padding: 4px 12px; border-bottom: 1px solid var(--bdr); vertical-align: top; }
178
+ .kv td:first-child { color: var(--muted); min-width: 160px; font-size: 10px; }
179
+ /* ── cards (mail / jobs) ──────────────────────────────────────────────────── */
180
+ .card { padding: 9px 12px; border-bottom: 1px solid var(--bdr); }
181
+ /* ── mail preview ─────────────────────────────────────────────────────────── */
182
+ .mprev { margin-top: 7px; }
183
+ .mprev > summary {
184
+ cursor: pointer; font-size: 10px; color: var(--muted);
185
+ list-style: none; user-select: none; width: fit-content;
186
+ }
187
+ .mprev > summary::-webkit-details-marker { display: none; }
188
+ .mprev > summary::before { content: '▸ '; }
189
+ .mprev[open] > summary::before { content: '▾ '; }
190
+ .mprev > summary:hover { color: var(--purple); }
191
+ .mframe {
192
+ width: 100%; height: 320px; margin-top: 6px; border: 1px solid var(--bdr);
193
+ border-radius: 4px; background: #fff;
194
+ }
195
+ /* ── all-requests list ────────────────────────────────────────────────────── */
196
+ /* Fixed height: the virtualiser positions rows arithmetically above ~200 of
197
+ them, and a row that can grow would put every offset out. */
198
+ .hrow {
199
+ display: flex; align-items: center; gap: 6px;
200
+ padding: 0 12px; height: 26px; border-bottom: 1px solid var(--bdr);
201
+ cursor: pointer; font-size: 11px; overflow: hidden;
202
+ }
203
+ .hrow:hover { background: var(--surf); }
204
+ .hrow.cur { background: var(--card); }
205
+ .hrow.on { box-shadow: inset 2px 0 0 var(--purple); }
206
+ .hrow.err { border-left: 3px solid var(--red); padding-left: 9px; }
207
+ .hrow.child { padding-left: 26px; background: var(--childbg); }
208
+ .hpath { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
209
+ .hexc { color: var(--red); font-size: 10px; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
210
+ .gtog {
211
+ font-size: 10px; padding: 0 5px; border-radius: 999px; cursor: pointer;
212
+ background: var(--card); border: 1px solid var(--purple); color: var(--purple);
213
+ font-family: inherit; flex-shrink: 0;
214
+ }
215
+ .gtog:hover { background: var(--surf); }
216
+ /* Windowed rendering: two spacers stand in for the rows that are not drawn. */
217
+ .vpad { flex-shrink: 0; }
218
+ /* ── filter bar ───────────────────────────────────────────────────────────── */
219
+ .fbar {
220
+ display: flex; gap: 6px; align-items: center; padding: 6px 12px;
221
+ border-bottom: 1px solid var(--bdr); background: var(--surf);
222
+ position: sticky; top: 0; z-index: 1;
223
+ }
224
+ .finput {
225
+ flex: 1; min-width: 90px; height: 24px; padding: 0 8px; font: inherit; font-size: 11px;
226
+ background: var(--bg); border: 1px solid var(--bdr); border-radius: 4px; color: var(--text);
227
+ }
228
+ .finput:focus { outline: none; border-color: var(--purple); }
229
+ .finput::placeholder { color: var(--muted); }
230
+ .facets {
231
+ display: flex; gap: 4px; align-items: center; flex-wrap: wrap;
232
+ padding: 5px 12px; border-bottom: 1px solid var(--bdr); background: var(--surf);
233
+ }
234
+ .fchip {
235
+ font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 999px; cursor: pointer;
236
+ background: transparent; border: 1px solid var(--bdr); color: var(--muted);
237
+ }
238
+ .fchip:hover { color: var(--text); border-color: var(--muted); }
239
+ .fchip.on { background: var(--card); color: var(--purple); border-color: var(--purple); }
240
+ .fchip.on.warn { color: var(--yellow); border-color: var(--yellow); }
241
+ .fsep { width: 1px; height: 12px; background: var(--bdr); margin: 0 3px; }
242
+ /* ── timeline waterfall ───────────────────────────────────────────────────── */
243
+ .trow { display: flex; align-items: center; gap: 8px; padding: 3px 12px; font-size: 11px; }
244
+ .trow:hover { background: var(--surf); }
245
+ .tlbl { min-width: 78px; font-size: 10px; text-align: right; flex-shrink: 0; }
246
+ .ttrack { flex: 1; height: 12px; position: relative; background: var(--card); border-radius: 2px; }
247
+ .tmark {
248
+ position: absolute; top: 0; height: 12px; min-width: 3px; border-radius: 2px;
249
+ background: var(--purple);
250
+ }
251
+ .tmark.query { background: var(--purple); }
252
+ .tmark.cache { background: var(--cyan); }
253
+ .tmark.mail { background: var(--green); }
254
+ .tmark.job { background: var(--orange); }
255
+ .tmark.log { background: var(--muted); }
256
+ .tmark.warn { background: var(--red); }
257
+ .tmark.chan { background: var(--yellow); }
258
+ .ttxt { flex: 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; }
259
+ .tkey { display: flex; gap: 10px; flex-wrap: wrap; padding: 6px 12px; font-size: 10px; color: var(--muted); }
260
+ .tkey i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 3px; vertical-align: middle; font-style: normal; }
261
+ /* ── generic channel rows ─────────────────────────────────────────────────── */
262
+ .crow { padding: 7px 12px; border-bottom: 1px solid var(--bdr); }
263
+ .crow.warn { border-left: 3px solid var(--yellow); }
264
+ .chead { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
265
+ .cttl { font-size: 11px; word-break: break-word; }
266
+ .cmeta { font-size: 10px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; }
267
+ /* Badge accents. Assigned by hashing the badge's own text, so repeated values
268
+ stay the same colour and devtools needs no vocabulary to tell them apart. */
269
+ .chip.a0 { border-color: var(--purple); color: var(--purple); }
270
+ .chip.a1 { border-color: var(--cyan); color: var(--cyan); }
271
+ .chip.a2 { border-color: var(--green); color: var(--green); }
272
+ .chip.a3 { border-color: var(--orange); color: var(--orange); }
273
+ .chip.a4 { border-color: var(--yellow); color: var(--yellow); }
274
+ .chip.a5 { border-color: var(--red); color: var(--red); }
275
+ .chip.flag { font-size: 9px; padding: 0 4px; text-transform: uppercase; letter-spacing: .4px; }
276
+ /* ── channel: tree ────────────────────────────────────────────────────────── */
277
+ .tnode { border-bottom: 1px solid var(--bdr); }
278
+ .tbranch > summary {
279
+ padding: 5px 12px; cursor: pointer; list-style: none; user-select: none;
280
+ display: flex; align-items: center; gap: 6px; font-size: 11px;
281
+ }
282
+ .tbranch > summary::-webkit-details-marker { display: none; }
283
+ .tbranch > summary::before { content: '▸'; color: var(--muted); font-size: 9px; }
284
+ .tbranch[open] > summary::before { content: '▾'; }
285
+ .tbranch > summary:hover { background: var(--surf); }
286
+ .tleaf { padding: 5px 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11px; }
287
+ .tleaf:hover { background: var(--surf); }
288
+ .tname { font-weight: 600; }
289
+ .tattr { font-size: 10px; color: var(--muted); }
290
+ .tkids { border-left: 1px solid var(--bdr); margin-left: 17px; }
291
+ /* ── channel: table ───────────────────────────────────────────────────────── */
292
+ .ctbl { width: 100%; border-collapse: collapse; font-size: 11px; }
293
+ .ctbl th {
294
+ text-align: left; padding: 5px 12px; font-size: 10px; font-weight: 400;
295
+ text-transform: uppercase; letter-spacing: .5px; color: var(--muted);
296
+ background: var(--surf); border-bottom: 1px solid var(--bdr);
297
+ position: sticky; top: 0;
298
+ }
299
+ .ctbl td { padding: 5px 12px; border-bottom: 1px solid var(--bdr); vertical-align: top; }
300
+ .ctbl tr.warn td:first-child { box-shadow: inset 3px 0 0 var(--yellow); }
301
+ /* ── channel: grouped ─────────────────────────────────────────────────────── */
302
+ .cgrp > summary {
303
+ padding: 6px 12px; cursor: pointer; list-style: none; user-select: none;
304
+ background: var(--surf); border-bottom: 1px solid var(--bdr);
305
+ display: flex; align-items: center; gap: 8px; font-size: 11px;
306
+ }
307
+ .cgrp > summary::-webkit-details-marker { display: none; }
308
+ .cgrp > summary::before { content: '▸'; color: var(--muted); font-size: 9px; }
309
+ .cgrp[open] > summary::before { content: '▾'; }
310
+ /* ── section switcher ─────────────────────────────────────────────────────── */
311
+ .sects { display: flex; gap: 1px; align-self: center; flex-shrink: 0; margin-bottom: 4px; }
312
+ .sect {
313
+ font: inherit; font-size: 10px; padding: 3px 9px; cursor: pointer;
314
+ background: var(--bg); border: 1px solid var(--bdr); color: var(--muted);
315
+ text-transform: uppercase; letter-spacing: .5px;
316
+ }
317
+ .sect:first-child { border-radius: 4px 0 0 4px; }
318
+ .sect:last-child { border-radius: 0 4px 4px 0; }
319
+ .sect:hover { color: var(--text); }
320
+ .sect.on { background: var(--card); color: var(--purple); border-color: var(--purple); }
321
+ .tabdiv { width: 1px; background: var(--bdr); align-self: stretch; margin: 4px 6px 0; flex-shrink: 0; }
322
+ a.link { color: var(--cyan); text-decoration: none; }
323
+ a.link:hover { text-decoration: underline; }
324
+ /* ── source locations ─────────────────────────────────────────────────────── */
325
+ .src {
326
+ font-size: 10px; color: var(--muted); flex-shrink: 0; white-space: nowrap;
327
+ max-width: 220px; overflow: hidden; text-overflow: ellipsis;
328
+ }
329
+ a.src.link { color: var(--cyan); text-decoration: none; }
330
+ a.src.link:hover { text-decoration: underline; }
331
+ /* ── exception frames ─────────────────────────────────────────────────────── */
332
+ .frame {
333
+ display: flex; align-items: baseline; gap: 8px; justify-content: space-between;
334
+ padding: 4px 12px; border-bottom: 1px solid var(--bdr); font-size: 11px;
335
+ }
336
+ .frame:hover { background: var(--surf); }
337
+ /* Framework frames are context, not the answer — dimmed so your own code stands
338
+ out of a forty-frame trace without being hidden from it. */
339
+ .frame.vendor { opacity: .5; }
340
+ .frame .src { max-width: 60%; }
341
+ .fnname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
342
+ /* ── chip rows (session keys) ─────────────────────────────────────────────── */
343
+ .chips { display: flex; gap: 4px; flex-wrap: wrap; }
344
+ /* ── keyboard help ────────────────────────────────────────────────────────── */
345
+ .keys { padding: 6px 12px; font-size: 10px; color: var(--muted); display: flex; gap: 12px; flex-wrap: wrap; border-top: 1px solid var(--bdr); }
346
+ .keys kbd {
347
+ font: inherit; background: var(--card); border: 1px solid var(--bdr);
348
+ border-radius: 3px; padding: 0 3px; color: var(--text);
349
+ }
350
+ /* ── standalone dashboard ─────────────────────────────────────────────────── */
351
+ #wrap.standalone { display: flex; flex-direction: column; height: 100vh; background: var(--bg); }
352
+ #wrap.standalone #panel { flex: 1; height: auto !important; border-top: none; }
353
+ #wrap.standalone #grip { display: none; }
354
+ #wrap.standalone #bar { border-top: none; border-bottom: 1px solid var(--bdr); cursor: default; order: -1; }
355
+ </style>`;
356
+
357
+ /** What the user asked for, which is not the same as which palette is showing. */
358
+ export type ThemeChoice = "auto" | "dark" | "light";
359
+
360
+ /**
361
+ * Resolve the three-way choice to the one class the stylesheet understands.
362
+ *
363
+ * `auto` asks the browser, and the panel re-asks whenever the system flips, so a
364
+ * machine that switches at sunset does not leave a dark panel on a light page.
365
+ */
366
+ export function isLightTheme(choice: ThemeChoice): boolean {
367
+ if (choice === "light") return true;
368
+ if (choice === "dark") return false;
369
+ try {
370
+ return window.matchMedia("(prefers-color-scheme: light)").matches;
371
+ } catch {
372
+ // No matchMedia (a very old browser, a test harness) — dark is the default
373
+ // the panel has always had.
374
+ return false;
375
+ }
376
+ }
377
+
378
+ /** The next choice in the toggle's cycle, and the icon that stands for it. */
379
+ export const THEME_CYCLE: Record<ThemeChoice, ThemeChoice> = {
380
+ auto: "dark",
381
+ dark: "light",
382
+ light: "auto",
383
+ };
384
+
385
+ export const THEME_ICON: Record<ThemeChoice, string> = {
386
+ auto: "◐",
387
+ dark: "●",
388
+ light: "○",
389
+ };
@@ -4,6 +4,6 @@
4
4
  * injected into dev HTML responses by the core dev injector — so apps get the
5
5
  * floating panel with no `DevTools.start()` call in their own bundle.
6
6
  */
7
- import { DevTools } from "./client.ts";
7
+ import { DevTools } from "./client/index.ts";
8
8
 
9
9
  DevTools.start();
package/src/config.ts CHANGED
@@ -1,7 +1,35 @@
1
1
  import { deepMerge } from "@zerotal/core";
2
2
  import type { RedactionOptions } from "./redaction.ts";
3
+ import type { EditorName } from "./editor.ts";
4
+
5
+ /**
6
+ * Decides whether a request may reach the inspector outside a development
7
+ * process. Returning anything but `true` is a refusal.
8
+ */
9
+ export type DevtoolsGate = (request: Request) => boolean | Promise<boolean>;
3
10
 
4
11
  export interface DevtoolsConfigShape {
12
+ /**
13
+ * Whether the inspector runs at all.
14
+ *
15
+ * `null` (the default) follows the same dev-surface gate as the stack-trace
16
+ * error page: on under `zt dev`, off in a deployed process. `true` or `false`
17
+ * decides explicitly — and `true` outside development requires a {@link gate},
18
+ * because the absence of one is a refusal rather than a default allow.
19
+ */
20
+ enabled: boolean | null;
21
+ /**
22
+ * Who may read the inspector outside a development process.
23
+ *
24
+ * Never consulted on a development machine: a gate that can lock you out of
25
+ * your own laptop is a gate that gets switched off, and then nothing is gated.
26
+ * One function answers for every endpoint — the stream, the trace JSON, the
27
+ * dashboard, and the panel bundle are the same secret.
28
+ *
29
+ * @example
30
+ * gate: (request) => request.headers.get("X-Debug-Key") === Bun.env["DEBUG_KEY"],
31
+ */
32
+ gate: DevtoolsGate | null;
5
33
  /**
6
34
  * How many request traces to keep in memory and reload on start.
7
35
  * Default: `100`.
@@ -19,21 +47,67 @@ export interface DevtoolsConfigShape {
19
47
  */
20
48
  pruneHours: number;
21
49
  /**
22
- * Whether query bindings are masked before a trace leaves the process.
50
+ * Whether sensitive values are masked before a trace leaves the process.
23
51
  *
24
52
  * On by default: a trace is streamed to the browser *and* written to disk for
25
- * a day, and bindings are the request's real values — the password on a
53
+ * a day, and what it carries is the request's real values — the password on a
26
54
  * registration, a reset token, every customer email a listing selects by.
27
55
  * Turn it off only when you are debugging the values themselves.
28
56
  */
29
57
  redact: RedactionOptions;
58
+ /**
59
+ * Which editor `file:line` links open.
60
+ *
61
+ * Every location the panel shows becomes a link: a query's call site, a log
62
+ * line's, a stack frame, a prop's render source. Going from "this query is
63
+ * slow" to the line that ran it is the most frequent move in a debugging
64
+ * session, and it was two manual searches.
65
+ *
66
+ * Default: `vscode`. Set to `null` to render locations as plain text.
67
+ */
68
+ editor: EditorName | null;
69
+ /**
70
+ * Rewrite a captured path before it becomes a link — for editing on a machine
71
+ * that is not the one running the code.
72
+ *
73
+ * Keys are path prefixes as the *server* sees them; values are what your editor
74
+ * should open instead. A container reporting `/app/src/Foo.ts` maps home with
75
+ * `{ "/app": "/Users/you/project" }`.
76
+ */
77
+ editorPathMap: Record<string, string>;
78
+ /**
79
+ * Capture the application call site for each query and log line.
80
+ *
81
+ * "Which of my 40 queries is slow" was answerable; "where do I go to fix it"
82
+ * was not. One stack walk per recorded event, filtered to application frames —
83
+ * measured at roughly two microseconds regardless of stack depth, so about
84
+ * 0.08ms on a request running forty queries.
85
+ *
86
+ * Default: on. It only ever runs when the inspector itself is running.
87
+ */
88
+ captureSource: boolean;
89
+ /**
90
+ * Extra request headers to record, on top of the built-in safe list.
91
+ *
92
+ * The allowlist exists because a trace is persisted: `cookie` and
93
+ * `authorization` are the request's credentials. This opens up the ones you
94
+ * are actually debugging — matched case-insensitively, and `"*"` records every
95
+ * header except those `redact` masks.
96
+ */
97
+ headers: string[];
30
98
  }
31
99
 
32
100
  const defaults: DevtoolsConfigShape = {
101
+ enabled: null,
102
+ gate: null,
33
103
  capacity: 100,
34
104
  dbPath: Bun.env["ZT_DEVTOOLS_DB"] ?? ".zerotal/devtools.sqlite",
35
105
  pruneHours: Number(Bun.env["ZT_DEVTOOLS_PRUNE_HOURS"] ?? 24),
36
106
  redact: { enabled: true, allow: [], deny: [] },
107
+ editor: "vscode",
108
+ editorPathMap: {},
109
+ captureSource: true,
110
+ headers: [],
37
111
  };
38
112
 
39
113
  /**
@@ -45,6 +119,7 @@ const defaults: DevtoolsConfigShape = {
45
119
  *
46
120
  * export default DevtoolsConfig({
47
121
  * capacity: 250,
122
+ * editor: 'cursor',
48
123
  * redact: { allow: ['email', 'slug'] },
49
124
  * });
50
125
  */
@@ -8,6 +8,6 @@
8
8
  * shadowed and never gained the plugin tabs the panel had. Sharing the renderer
9
9
  * means a tab added anywhere shows up in both.
10
10
  */
11
- import { DevTools } from "./client.ts";
11
+ import { DevTools } from "./client/index.ts";
12
12
 
13
13
  DevTools.start({ mode: "standalone" });
package/src/editor.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `file:line` → a URL your editor opens.
3
+ *
4
+ * A repo-wide search for any editor URL scheme used to return nothing: no stack
5
+ * frame, query, log line, or prop in any Zerotal surface was clickable to source.
6
+ * Going from "this query is slow" to the line that ran it is the most frequent
7
+ * move in a debugging session, and it was two manual searches — copy the path,
8
+ * find the file, find the line.
9
+ *
10
+ * The schemes are the editors' own and are stable; what is *not* stable is where
11
+ * the file lives relative to the person reading the panel, which is what
12
+ * {@link mapEditorPath} is for.
13
+ */
14
+
15
+ /** A place in the source. The one shape every capture in the panel produces. */
16
+ export interface SourceLocation {
17
+ /** Absolute path as the server saw it. */
18
+ file: string;
19
+ line: number;
20
+ column?: number;
21
+ /** The function the frame was in, when the runtime named one. */
22
+ function?: string;
23
+ }
24
+
25
+ /** Editors that register a URL scheme for opening a file at a line. */
26
+ export type EditorName = "vscode" | "vscode-insiders" | "cursor" | "windsurf" | "zed" | "webstorm";
27
+
28
+ /**
29
+ * How each editor spells "open this file here".
30
+ *
31
+ * Two families: the VS Code line takes a query string, JetBrains takes the same
32
+ * shape under a different host, and Zed puts the position in the path. Written
33
+ * out rather than templated because there are six of them and a template that
34
+ * covers all six is harder to read than the six.
35
+ */
36
+ const SCHEMES: Record<EditorName, (file: string, line: number, column: number) => string> = {
37
+ vscode: (f, l, c) => `vscode://file/${f}:${l}:${c}`,
38
+ "vscode-insiders": (f, l, c) => `vscode-insiders://file/${f}:${l}:${c}`,
39
+ cursor: (f, l, c) => `cursor://file/${f}:${l}:${c}`,
40
+ windsurf: (f, l, c) => `windsurf://file/${f}:${l}:${c}`,
41
+ zed: (f, l, c) => `zed://file/${f}:${l}:${c}`,
42
+ webstorm: (f, l) => `webstorm://open?file=${encodeURIComponent(f)}&line=${l}`,
43
+ };
44
+
45
+ /** Every editor this understands, for a config error worth reading. */
46
+ export const EDITORS = Object.keys(SCHEMES) as EditorName[];
47
+
48
+ /**
49
+ * Rewrite a server path to where the reader's editor can find it.
50
+ *
51
+ * The process recording a trace is often not the machine reading it — a
52
+ * container reports `/app/src/Foo.ts` for a file that is `~/project/src/Foo.ts`
53
+ * on the laptop with the editor. Longest prefix wins, so a specific mapping can
54
+ * sit inside a general one.
55
+ *
56
+ * @param file - The path as captured.
57
+ * @param map - Prefix → replacement, from the app's `editorPathMap`.
58
+ */
59
+ export function mapEditorPath(file: string, map: Record<string, string>): string {
60
+ let bestPrefix = "";
61
+ let bestReplacement = "";
62
+ for (const [prefix, replacement] of Object.entries(map)) {
63
+ if (file.startsWith(prefix) && prefix.length > bestPrefix.length) {
64
+ bestPrefix = prefix;
65
+ bestReplacement = replacement;
66
+ }
67
+ }
68
+ return bestPrefix ? bestReplacement + file.slice(bestPrefix.length) : file;
69
+ }
70
+
71
+ /**
72
+ * The URL that opens `location` in `editor`, or null when there is nothing to
73
+ * link to.
74
+ *
75
+ * Null rather than a broken link for the two cases that mean "do not link":
76
+ * `editor: null` in the config, and a location with no file. The panel renders
77
+ * the location as plain text then, which is still worth showing.
78
+ *
79
+ * @param location - Where to go.
80
+ * @param editor - The configured editor, or null to disable linking.
81
+ * @param map - Path rewrites for editing on a different machine.
82
+ */
83
+ export function editorUrl(
84
+ location: SourceLocation | null | undefined,
85
+ editor: EditorName | null,
86
+ map: Record<string, string> = {},
87
+ ): string | null {
88
+ if (!editor || !location?.file) return null;
89
+ const scheme = SCHEMES[editor];
90
+ if (!scheme) return null;
91
+ // Backslashes are legal in a Windows path and illegal in a URL path segment;
92
+ // every one of these editors accepts the forward-slash form on Windows.
93
+ const file = mapEditorPath(location.file, map).replace(/\\/g, "/");
94
+ return scheme(file, Math.max(1, location.line || 1), Math.max(1, location.column ?? 1));
95
+ }
96
+
97
+ /**
98
+ * A location as the panel labels it: the last two path segments and the line.
99
+ *
100
+ * Not the whole path — an absolute path from a monorepo is sixty characters of
101
+ * which the last twenty are the part you read.
102
+ */
103
+ export function shortLocation(location: SourceLocation): string {
104
+ const parts = location.file.replace(/\\/g, "/").split("/");
105
+ const tail = parts.slice(-2).join("/");
106
+ return `${tail}:${location.line}`;
107
+ }