@tsplc/debug 0.2.16 → 0.2.18

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.
@@ -1,8 +1,24 @@
1
- import { LiveValue, type View, empty, fill, h, pathLink, sourceLink, wrappingPath } from "./dom.ts";
1
+ import {
2
+ LiveValue,
3
+ type View,
4
+ deviceHref,
5
+ empty,
6
+ fill,
7
+ h,
8
+ pathLink,
9
+ sourceLink,
10
+ variableHref,
11
+ wrappingPath,
12
+ } from "./dom.ts";
2
13
  import { typeLabel } from "./format.ts";
3
14
  import type { Store, Topic } from "./store.ts";
4
15
  import { typeOf } from "../paths.ts";
5
- import { type TreeNode, type TreeRow, ancestorPaths, parentPath } from "./tree.ts";
16
+ import {
17
+ type TreeNode,
18
+ type TreeRow,
19
+ ancestorPaths,
20
+ parentPath,
21
+ } from "./tree.ts";
6
22
 
7
23
  const ROW_HEIGHT = 26;
8
24
  const OVERSCAN = 10;
@@ -16,454 +32,564 @@ let query = "";
16
32
  let activeGroup: string | undefined;
17
33
 
18
34
  const highlight = (path: string, needle: string) => {
19
- const at = path.toLowerCase().indexOf(needle.toLowerCase());
20
- return h(
21
- "span",
22
- {},
23
- path.slice(0, at),
24
- h("mark", {}, path.slice(at, at + needle.length)),
25
- path.slice(at + needle.length),
26
- );
35
+ const at = path.toLowerCase().indexOf(needle.toLowerCase());
36
+ return h(
37
+ "span",
38
+ {},
39
+ path.slice(0, at),
40
+ h("mark", {}, path.slice(at, at + needle.length)),
41
+ path.slice(at + needle.length),
42
+ );
43
+ };
44
+
45
+ /** How a node's value is known at runtime. */
46
+ const runtimeNote = (node: TreeNode) => {
47
+ if (node.constant) {
48
+ return "folded by the compiler: no storage";
49
+ }
50
+ return node.readable ? "read live" : "not readable in this build";
27
51
  };
28
52
 
29
53
  export const variablesView = (store: Store, params: URLSearchParams): View => {
30
- const { tree, manifest, paths } = store;
31
- let selected = params.get("path") ?? undefined;
32
- let rows: TreeRow[] = [];
33
- let windowStart = -1;
34
- let windowEnd = -1;
35
- let rowValues: LiveValue[] = [];
36
- let groupValues: LiveValue[] = [];
37
- let detailValue: LiveValue | undefined;
38
- let searchTimer: ReturnType<typeof setTimeout> | undefined;
54
+ const { tree, manifest, paths } = store;
55
+ let selected = params.get("path") ?? undefined;
56
+ let rows: TreeRow[] = [];
57
+ let windowStart = -1;
58
+ let windowEnd = -1;
59
+ let rowValues: LiveValue[] = [];
60
+ let groupValues: LiveValue[] = [];
61
+ let detailValue: LiveValue | undefined;
62
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
63
+
64
+ const search = h("input", {
65
+ type: "search",
66
+ class: "search",
67
+ placeholder: "Search TypeScript paths, e.g. binTrackers[3].bin",
68
+ value: query,
69
+ spellcheck: "false",
70
+ });
71
+ const generatedToggle = h("input", {
72
+ type: "checkbox",
73
+ checked: showGenerated,
74
+ });
75
+ const count = h("span", { class: "count" });
76
+ const windowElement = h("div", { class: "vwindow" });
77
+ const spacer = h("div", { class: "vspacer" }, windowElement);
78
+ const list = h("div", { class: "vlist", tabindex: 0, role: "tree" }, spacer);
79
+ const detail = h("section", { class: "panel detail" });
80
+ const groups = h("section", { class: "panel groups" });
81
+ const element = h(
82
+ "div",
83
+ { class: "page variables" },
84
+ h(
85
+ "div",
86
+ { class: "toolbar" },
87
+ search,
88
+ h("label", { class: "check" }, generatedToggle, "show generated"),
89
+ count,
90
+ ),
91
+ h(
92
+ "div",
93
+ { class: "split" },
94
+ h(
95
+ "div",
96
+ { class: "listwrap" },
97
+ h(
98
+ "div",
99
+ { class: "row head" },
100
+ h("span", {}, "Path"),
101
+ h("span", {}, "Value"),
102
+ h("span", {}, "Quality · read"),
103
+ h("span", {}, "Type"),
104
+ h("span", { title: "In the active watch group" }, "Watch"),
105
+ h("span", {}, "Declared"),
106
+ ),
107
+ list,
108
+ ),
109
+ h("aside", { class: "side" }, detail, groups),
110
+ ),
111
+ );
39
112
 
40
- const search = h("input", {
41
- type: "search",
42
- class: "search",
43
- placeholder: "Search TypeScript paths, e.g. binTrackers[3].bin",
44
- value: query,
45
- spellcheck: "false",
46
- });
47
- const generatedToggle = h("input", { type: "checkbox", checked: showGenerated });
48
- const count = h("span", { class: "count" });
49
- const windowElement = h("div", { class: "vwindow" });
50
- const spacer = h("div", { class: "vspacer" }, windowElement);
51
- const list = h("div", { class: "vlist", tabindex: 0, role: "tree" }, spacer);
52
- const detail = h("section", { class: "panel detail" });
53
- const groups = h("section", { class: "panel groups" });
54
- const element = h(
55
- "div",
56
- { class: "page variables" },
57
- h(
58
- "div",
59
- { class: "toolbar" },
60
- search,
61
- h("label", { class: "check" }, generatedToggle, "show generated"),
62
- count,
63
- ),
64
- h(
65
- "div",
66
- { class: "split" },
67
- h(
68
- "div",
69
- { class: "listwrap" },
70
- h(
71
- "div",
72
- { class: "row head" },
73
- h("span", {}, "Path"),
74
- h("span", {}, "Value"),
75
- h("span", {}, "Quality · read"),
76
- h("span", {}, "Type"),
77
- h("span", { title: "In the active watch group" }, "Watch"),
78
- h("span", {}, "Declared"),
79
- ),
80
- list,
81
- ),
82
- h("aside", { class: "side" }, detail, groups),
83
- ),
84
- );
113
+ const watched = (path: string) =>
114
+ Object.values(store.watch.groups).some((paths) => paths.includes(path));
115
+ const inActiveGroup = (path: string) =>
116
+ activeGroup !== undefined &&
117
+ (store.watch.groups[activeGroup] ?? []).includes(path);
85
118
 
86
- const watched = (path: string) =>
87
- Object.values(store.watch.groups).some((paths) => paths.includes(path));
88
- const inActiveGroup = (path: string) =>
89
- activeGroup !== undefined && (store.watch.groups[activeGroup] ?? []).includes(path);
119
+ /** ` on` for a path in the active group, ` elsewhere` for one only in another. */
120
+ const watchMark = (path: string) => {
121
+ if (inActiveGroup(path)) {
122
+ return " on";
123
+ }
124
+ return watched(path) ? " elsewhere" : "";
125
+ };
90
126
 
91
- const subscribe = () =>
92
- store.show({
93
- watch: [...rowValues, ...groupValues, ...(detailValue ? [detailValue] : [])]
94
- .filter((value) => value.subscribed)
95
- .map((value) => value.node.path),
96
- });
127
+ const subscribe = () =>
128
+ store.show({
129
+ watch: [
130
+ ...rowValues,
131
+ ...groupValues,
132
+ ...(detailValue ? [detailValue] : []),
133
+ ]
134
+ .filter((value) => value.subscribed)
135
+ .map((value) => value.node.path),
136
+ });
97
137
 
98
- const toggleWatch = (path: string) => {
99
- const group = activeGroup ?? "Watch";
100
- activeGroup = group;
101
- const members = store.watch.groups[group] ?? [];
102
- void store.saveWatch({
103
- ...store.watch.groups,
104
- [group]: members.includes(path)
105
- ? members.filter((member) => member !== path)
106
- : [...members, path],
107
- });
108
- };
138
+ const toggleWatch = (path: string) => {
139
+ const group = activeGroup ?? "Watch";
140
+ activeGroup = group;
141
+ const members = store.watch.groups[group] ?? [];
142
+ void store.saveWatch({
143
+ ...store.watch.groups,
144
+ [group]: members.includes(path)
145
+ ? members.filter((member) => member !== path)
146
+ : [...members, path],
147
+ });
148
+ };
109
149
 
110
- const renderRow = ({ node, depth }: TreeRow, index: number) => {
111
- const expandable = tree.expandable(node);
112
- const value = expandable ? undefined : new LiveValue(store, node);
113
- if (value) rowValues.push(value);
114
- const type = typeOf(manifest, node.type);
115
- const open = expanded.has(node.path);
116
- const inGroup = inActiveGroup(node.path);
117
- return h(
118
- "div",
119
- {
120
- class: `row${node.path === selected ? " selected" : ""}${node.generated ? " generated" : ""}`,
121
- "data-index": index,
122
- role: "treeitem",
123
- "aria-level": depth + 1,
124
- "aria-expanded": expandable && !query ? String(open) : undefined,
125
- },
126
- h(
127
- "span",
128
- { class: "name", style: `padding-left:${depth * 16 + 4}px`, title: node.path },
129
- expandable && !query
130
- ? h(
131
- "button",
132
- { class: "twisty", "data-action": "toggle", tabindex: -1 },
133
- open ? "▾" : "▸",
134
- )
135
- : h("span", { class: "twisty" }),
136
- query ? highlight(node.path, query) : node.name,
137
- node.generated ? h("span", { class: "tag" }, "generated") : undefined,
138
- ),
139
- value?.value ??
140
- h("span", { class: "value s-group" }, type.kind === "array" ? `[${type.length}]` : "{…}"),
141
- value?.meta ?? h("span"),
142
- h("span", { class: "type" }, typeLabel(manifest, node.type)),
143
- value?.subscribed
144
- ? h(
145
- "button",
146
- {
147
- class: `rec${inGroup ? " on" : watched(node.path) ? " elsewhere" : ""}`,
148
- "data-action": "watch",
149
- tabindex: -1,
150
- title: inGroup
151
- ? `Watched in “${activeGroup}”: click to remove`
152
- : `Watch: add to “${activeGroup ?? "Watch"}”`,
153
- },
154
- "●",
155
- )
156
- : h("span"),
157
- node.declaration ? sourceLink(store, node.declaration) : h("span"),
158
- );
159
- };
150
+ const renderRow = ({ node, depth }: TreeRow, index: number) => {
151
+ const expandable = tree.expandable(node);
152
+ const value = expandable ? undefined : new LiveValue(store, node);
153
+ if (value) {
154
+ rowValues.push(value);
155
+ }
156
+ const type = typeOf(manifest, node.type);
157
+ const open = expanded.has(node.path);
158
+ const inGroup = inActiveGroup(node.path);
159
+ return h(
160
+ "div",
161
+ {
162
+ class: `row${node.path === selected ? " selected" : ""}${node.generated ? " generated" : ""}`,
163
+ "data-index": index,
164
+ role: "treeitem",
165
+ "aria-level": depth + 1,
166
+ "aria-expanded": expandable && !query ? String(open) : undefined,
167
+ },
168
+ h(
169
+ "span",
170
+ {
171
+ class: "name",
172
+ style: `padding-left:${depth * 16 + 4}px`,
173
+ title: node.path,
174
+ },
175
+ expandable && !query
176
+ ? h(
177
+ "button",
178
+ { class: "twisty", "data-action": "toggle", tabindex: -1 },
179
+ open ? "▾" : "▸",
180
+ )
181
+ : h("span", { class: "twisty" }),
182
+ query ? highlight(node.path, query) : node.name,
183
+ node.generated ? h("span", { class: "tag" }, "generated") : undefined,
184
+ ),
185
+ value?.value ??
186
+ h(
187
+ "span",
188
+ { class: "value s-group" },
189
+ type.kind === "array" ? `[${type.length}]` : "{…}",
190
+ ),
191
+ value?.meta ?? h("span"),
192
+ h("span", { class: "type" }, typeLabel(manifest, node.type)),
193
+ value?.subscribed
194
+ ? h(
195
+ "button",
196
+ {
197
+ class: `rec${watchMark(node.path)}`,
198
+ "data-action": "watch",
199
+ tabindex: -1,
200
+ title: inGroup
201
+ ? `Watched in “${activeGroup}”: click to remove`
202
+ : `Watch: add to “${activeGroup ?? "Watch"}”`,
203
+ },
204
+ "●",
205
+ )
206
+ : h("span"),
207
+ node.declaration ? sourceLink(store, node.declaration) : h("span"),
208
+ );
209
+ };
160
210
 
161
- const renderWindow = (force: boolean) => {
162
- const start = Math.max(0, Math.floor(list.scrollTop / ROW_HEIGHT) - OVERSCAN);
163
- const end = Math.min(
164
- rows.length,
165
- Math.ceil((list.scrollTop + list.clientHeight) / ROW_HEIGHT) + OVERSCAN,
166
- );
167
- if (!force && start === windowStart && end === windowEnd) return;
168
- windowStart = start;
169
- windowEnd = end;
170
- rowValues = [];
171
- windowElement.style.transform = `translateY(${start * ROW_HEIGHT}px)`;
172
- windowElement.replaceChildren(
173
- ...rows.slice(start, end).map((row, offset) => renderRow(row, start + offset)),
174
- );
175
- subscribe();
176
- };
211
+ const renderWindow = (force: boolean) => {
212
+ const start = Math.max(
213
+ 0,
214
+ Math.floor(list.scrollTop / ROW_HEIGHT) - OVERSCAN,
215
+ );
216
+ const end = Math.min(
217
+ rows.length,
218
+ Math.ceil((list.scrollTop + list.clientHeight) / ROW_HEIGHT) + OVERSCAN,
219
+ );
220
+ if (!force && start === windowStart && end === windowEnd) {
221
+ return;
222
+ }
223
+ windowStart = start;
224
+ windowEnd = end;
225
+ rowValues = [];
226
+ windowElement.style.transform = `translateY(${start * ROW_HEIGHT}px)`;
227
+ windowElement.replaceChildren(
228
+ ...rows
229
+ .slice(start, end)
230
+ .map((row, offset) => renderRow(row, start + offset)),
231
+ );
232
+ subscribe();
233
+ };
177
234
 
178
- const recompute = () => {
179
- if (query) {
180
- const result = tree.search(query, showGenerated, SEARCH_LIMIT);
181
- rows = result.nodes.map((node) => ({ node, depth: 0 }));
182
- count.textContent =
183
- result.total > rows.length
184
- ? `first ${rows.length} of ${result.total} matches`
185
- : `${result.total} matches`;
186
- } else {
187
- rows = tree.rows(expanded, showGenerated);
188
- count.textContent = `${rows.length} rows`;
189
- }
190
- spacer.style.height = `${rows.length * ROW_HEIGHT}px`;
191
- renderWindow(true);
192
- };
235
+ const recompute = () => {
236
+ if (query) {
237
+ const result = tree.search(query, showGenerated, SEARCH_LIMIT);
238
+ rows = result.nodes.map((node) => ({ node, depth: 0 }));
239
+ count.textContent =
240
+ result.total > rows.length
241
+ ? `first ${rows.length} of ${result.total} matches`
242
+ : `${result.total} matches`;
243
+ } else {
244
+ rows = tree.rows(expanded, showGenerated);
245
+ count.textContent = `${rows.length} rows`;
246
+ }
247
+ spacer.style.height = `${rows.length * ROW_HEIGHT}px`;
248
+ renderWindow(true);
249
+ };
193
250
 
194
- const scrollToIndex = (index: number) => {
195
- const top = index * ROW_HEIGHT;
196
- if (top < list.scrollTop) list.scrollTop = top;
197
- else if (top + ROW_HEIGHT > list.scrollTop + list.clientHeight)
198
- list.scrollTop = top + ROW_HEIGHT - list.clientHeight;
199
- };
251
+ const scrollToIndex = (index: number) => {
252
+ const top = index * ROW_HEIGHT;
253
+ if (top < list.scrollTop) {
254
+ list.scrollTop = top;
255
+ } else if (top + ROW_HEIGHT > list.scrollTop + list.clientHeight) {
256
+ list.scrollTop = top + ROW_HEIGHT - list.clientHeight;
257
+ }
258
+ };
200
259
 
201
- const reveal = (path: string) => {
202
- for (const ancestor of ancestorPaths(path)) expanded.add(ancestor);
203
- recompute();
204
- const index = rows.findIndex((row) => row.node.path === path);
205
- if (index >= 0) list.scrollTop = Math.max(0, index * ROW_HEIGHT - list.clientHeight / 2);
206
- };
260
+ const reveal = (path: string) => {
261
+ for (const ancestor of ancestorPaths(path)) {
262
+ expanded.add(ancestor);
263
+ }
264
+ recompute();
265
+ const index = rows.findIndex((row) => row.node.path === path);
266
+ if (index >= 0) {
267
+ list.scrollTop = Math.max(0, index * ROW_HEIGHT - list.clientHeight / 2);
268
+ }
269
+ };
207
270
 
208
- const renderDetail = () => {
209
- detailValue = undefined;
210
- if (!selected)
211
- return detail.replaceChildren(
212
- h("h2", {}, "Selection"),
213
- empty("Select a row for its declaration and value."),
214
- );
215
- const node = tree.resolve(selected);
216
- if (!node)
217
- return detail.replaceChildren(
218
- h("h2", {}, "Selection"),
219
- empty(`${selected} is not in this build's manifest.`),
220
- );
221
- const leaf = !tree.expandable(node);
222
- if (leaf) detailValue = new LiveValue(store, node);
223
- const io = manifest.io.filter((entry) => entry.path === node.path);
224
- const sites = manifest.events.filter((site) => site.path === node.path);
225
- fill(
226
- detail,
227
- h("h2", {}, "Selection"),
228
- h("div", { class: "detail-path mono" }, node.path),
229
- detailValue && h("div", { class: "detail-value" }, detailValue.value, detailValue.meta),
230
- h(
231
- "dl",
232
- {},
233
- h("dt", {}, "Type"),
234
- h("dd", { class: "mono" }, typeLabel(manifest, node.type)),
235
- h("dt", {}, "Declared"),
236
- h("dd", {}, node.declaration ? sourceLink(store, node.declaration) : "not known"),
237
- h("dt", {}, "Runtime"),
238
- h(
239
- "dd",
240
- {},
241
- node.constant
242
- ? "folded by the compiler: no storage"
243
- : node.readable
244
- ? "read live"
245
- : "not readable in this build",
246
- ),
247
- ...io.flatMap((entry) => [
248
- h("dt", {}, entry.direction === "input" ? "Input" : "Output"),
249
- h(
250
- "dd",
251
- {},
252
- entry.device
253
- ? h("a", { href: `#/hardware/${encodeURIComponent(entry.device)}` }, entry.device)
254
- : "no device",
255
- entry.channel ? ` · ${entry.channel}` : "",
256
- entry.address ? ` · ${entry.address}` : "",
257
- entry.inverted ? " · inverted" : "",
258
- ),
259
- ]),
260
- ),
261
- sites.length > 0 &&
262
- h(
263
- "div",
264
- { class: "sites" },
265
- h("h3", {}, "Traced writes"),
266
- ...sites.map((site) =>
267
- h(
268
- "div",
269
- { class: "site" },
270
- h("code", {}, site.text),
271
- " ",
272
- sourceLink(store, site.range),
273
- ),
274
- ),
275
- ),
276
- query &&
277
- h(
278
- "button",
279
- {
280
- onclick: () => {
281
- query = "";
282
- search.value = "";
283
- reveal(node.path);
284
- },
285
- },
286
- "Show in tree",
287
- ),
288
- );
289
- };
271
+ const renderDetail = () => {
272
+ detailValue = undefined;
273
+ if (!selected) {
274
+ return detail.replaceChildren(
275
+ h("h2", {}, "Selection"),
276
+ empty("Select a row for its declaration and value."),
277
+ );
278
+ }
279
+ const node = tree.resolve(selected);
280
+ if (!node) {
281
+ return detail.replaceChildren(
282
+ h("h2", {}, "Selection"),
283
+ empty(`${selected} is not in this build's manifest.`),
284
+ );
285
+ }
286
+ const leaf = !tree.expandable(node);
287
+ if (leaf) {
288
+ detailValue = new LiveValue(store, node);
289
+ }
290
+ const io = manifest.io.filter((entry) => entry.path === node.path);
291
+ const sites = manifest.events.filter((site) => site.path === node.path);
292
+ fill(
293
+ detail,
294
+ h("h2", {}, "Selection"),
295
+ h("div", { class: "detail-path mono" }, node.path),
296
+ detailValue &&
297
+ h(
298
+ "div",
299
+ { class: "detail-value" },
300
+ detailValue.value,
301
+ detailValue.meta,
302
+ ),
303
+ h(
304
+ "dl",
305
+ {},
306
+ h("dt", {}, "Type"),
307
+ h("dd", { class: "mono" }, typeLabel(manifest, node.type)),
308
+ h("dt", {}, "Declared"),
309
+ h(
310
+ "dd",
311
+ {},
312
+ node.declaration ? sourceLink(store, node.declaration) : "not known",
313
+ ),
314
+ h("dt", {}, "Runtime"),
315
+ h("dd", {}, runtimeNote(node)),
316
+ ...io.flatMap((entry) => [
317
+ h("dt", {}, entry.direction === "input" ? "Input" : "Output"),
318
+ h(
319
+ "dd",
320
+ {},
321
+ entry.device
322
+ ? h("a", { href: deviceHref(entry.device) }, entry.device)
323
+ : "no device",
324
+ entry.channel ? ` · ${entry.channel}` : "",
325
+ entry.address ? ` · ${entry.address}` : "",
326
+ entry.inverted ? " · inverted" : "",
327
+ ),
328
+ ]),
329
+ ),
330
+ sites.length > 0 &&
331
+ h(
332
+ "div",
333
+ { class: "sites" },
334
+ h("h3", {}, "Traced writes"),
335
+ ...sites.map((site) =>
336
+ h(
337
+ "div",
338
+ { class: "site" },
339
+ h("code", {}, site.text),
340
+ " ",
341
+ sourceLink(store, site.range),
342
+ ),
343
+ ),
344
+ ),
345
+ query &&
346
+ h(
347
+ "button",
348
+ {
349
+ onclick: () => {
350
+ query = "";
351
+ search.value = "";
352
+ reveal(node.path);
353
+ },
354
+ },
355
+ "Show in tree",
356
+ ),
357
+ );
358
+ };
290
359
 
291
- const renderGroups = () => {
292
- groupValues = [];
293
- const names = Object.keys(store.watch.groups);
294
- if (activeGroup === undefined || !names.includes(activeGroup)) activeGroup = names[0];
295
- const nameInput = h("input", { placeholder: "New group", size: 12 });
296
- const members = activeGroup === undefined ? [] : (store.watch.groups[activeGroup] ?? []);
297
- groups.replaceChildren(
298
- h("h2", {}, "Watch groups"),
299
- h(
300
- "p",
301
- { class: "hint" },
302
- "Paths in a group are read live while this page shows them; nothing is recorded.",
303
- ),
304
- h(
305
- "div",
306
- { class: "tabs" },
307
- ...Object.entries(store.watch.groups).map(([name, paths]) =>
308
- h(
309
- "button",
310
- {
311
- class: `tab${name === activeGroup ? " on" : ""}`,
312
- onclick: () => {
313
- activeGroup = name;
314
- renderGroups();
315
- renderWindow(true);
316
- },
317
- },
318
- name,
319
- h("span", { class: "n" }, String(paths.length)),
320
- ),
321
- ),
322
- h(
323
- "form",
324
- {
325
- onsubmit: (event: Event) => {
326
- event.preventDefault();
327
- const name = nameInput.value.trim();
328
- if (!name || name in store.watch.groups) return;
329
- activeGroup = name;
330
- void store.saveWatch({ ...store.watch.groups, [name]: [] });
331
- },
332
- },
333
- nameInput,
334
- ),
335
- ),
336
- ...(members.length
337
- ? members.flatMap((member) => {
338
- const remove = h(
339
- "button",
340
- { class: "remove", title: "Remove from group", onclick: () => toggleWatch(member) },
341
- "×",
342
- );
343
- const row = (path: string, removable: boolean) => {
344
- const node = tree.resolve(path);
345
- const value = node && new LiveValue(store, node);
346
- if (value) groupValues.push(value);
347
- return h(
348
- "div",
349
- { class: "member" },
350
- pathLink(path, wrappingPath(path)),
351
- value
352
- ? value.value
353
- : h("span", { class: "value s-unavailable" }, "not in this build"),
354
- value?.meta,
355
- removable ? remove : "",
356
- );
357
- };
358
- if (!member.includes("*")) return [row(member, true)];
359
- // A pattern from the config or a saved group: one row per path it matches.
360
- const matched = paths.expand([member]);
361
- return [
362
- h(
363
- "div",
364
- { class: "member" },
365
- h("span", { class: "mono" }, wrappingPath(member)),
366
- h("span", { class: "dim" }, `${matched.length} paths`),
367
- remove,
368
- ),
369
- ...matched.map((path) => row(path, false)),
370
- ];
371
- })
372
- : [empty(activeGroup ? "Empty: use ● on a row to add it." : "No groups yet.")]),
373
- );
374
- subscribe();
375
- };
360
+ const renderGroups = () => {
361
+ groupValues = [];
362
+ const names = Object.keys(store.watch.groups);
363
+ if (activeGroup === undefined || !names.includes(activeGroup)) {
364
+ activeGroup = names[0];
365
+ }
366
+ const nameInput = h("input", { placeholder: "New group", size: 12 });
367
+ const members =
368
+ activeGroup === undefined ? [] : (store.watch.groups[activeGroup] ?? []);
369
+ groups.replaceChildren(
370
+ h("h2", {}, "Watch groups"),
371
+ h(
372
+ "p",
373
+ { class: "hint" },
374
+ "Paths in a group are read live while this page shows them; nothing is recorded.",
375
+ ),
376
+ h(
377
+ "div",
378
+ { class: "tabs" },
379
+ ...Object.entries(store.watch.groups).map(([name, paths]) =>
380
+ h(
381
+ "button",
382
+ {
383
+ class: `tab${name === activeGroup ? " on" : ""}`,
384
+ onclick: () => {
385
+ activeGroup = name;
386
+ renderGroups();
387
+ renderWindow(true);
388
+ },
389
+ },
390
+ name,
391
+ h("span", { class: "n" }, String(paths.length)),
392
+ ),
393
+ ),
394
+ h(
395
+ "form",
396
+ {
397
+ onsubmit: (event: Event) => {
398
+ event.preventDefault();
399
+ const name = nameInput.value.trim();
400
+ if (!name || name in store.watch.groups) {
401
+ return;
402
+ }
403
+ activeGroup = name;
404
+ void store.saveWatch({ ...store.watch.groups, [name]: [] });
405
+ },
406
+ },
407
+ nameInput,
408
+ ),
409
+ ),
410
+ ...(members.length
411
+ ? members.flatMap((member) => {
412
+ const remove = h(
413
+ "button",
414
+ {
415
+ class: "remove",
416
+ title: "Remove from group",
417
+ onclick: () => toggleWatch(member),
418
+ },
419
+ "×",
420
+ );
421
+ const row = (path: string, removable: boolean) => {
422
+ const node = tree.resolve(path);
423
+ const value = node && new LiveValue(store, node);
424
+ if (value) {
425
+ groupValues.push(value);
426
+ }
427
+ return h(
428
+ "div",
429
+ { class: "member" },
430
+ pathLink(path, wrappingPath(path)),
431
+ value
432
+ ? value.value
433
+ : h(
434
+ "span",
435
+ { class: "value s-unavailable" },
436
+ "not in this build",
437
+ ),
438
+ value?.meta,
439
+ removable ? remove : "",
440
+ );
441
+ };
442
+ if (!member.includes("*")) {
443
+ return [row(member, true)];
444
+ }
445
+ // A pattern from the config or a saved group: one row per path it matches.
446
+ const matched = paths.expand([member]);
447
+ return [
448
+ h(
449
+ "div",
450
+ { class: "member" },
451
+ h("span", { class: "mono" }, wrappingPath(member)),
452
+ h("span", { class: "dim" }, `${matched.length} paths`),
453
+ remove,
454
+ ),
455
+ ...matched.map((path) => row(path, false)),
456
+ ];
457
+ })
458
+ : [
459
+ empty(
460
+ activeGroup
461
+ ? "Empty: use ● on a row to add it."
462
+ : "No groups yet.",
463
+ ),
464
+ ]),
465
+ );
466
+ subscribe();
467
+ };
376
468
 
377
- const select = (path: string) => {
378
- selected = path;
379
- history.replaceState(null, "", `#/variables?path=${encodeURIComponent(path)}`);
380
- renderWindow(true);
381
- renderDetail();
382
- subscribe();
383
- };
469
+ const select = (path: string) => {
470
+ selected = path;
471
+ history.replaceState(null, "", variableHref(path));
472
+ renderWindow(true);
473
+ renderDetail();
474
+ subscribe();
475
+ };
384
476
 
385
- const move = (index: number) => {
386
- const row = rows[index];
387
- if (!row) return;
388
- select(row.node.path);
389
- scrollToIndex(index);
390
- };
477
+ const move = (index: number) => {
478
+ const row = rows[index];
479
+ if (!row) {
480
+ return;
481
+ }
482
+ select(row.node.path);
483
+ scrollToIndex(index);
484
+ };
391
485
 
392
- const toggle = (node: TreeNode) => {
393
- if (expanded.has(node.path)) expanded.delete(node.path);
394
- else expanded.add(node.path);
395
- recompute();
396
- };
486
+ const toggle = (node: TreeNode) => {
487
+ if (expanded.has(node.path)) {
488
+ expanded.delete(node.path);
489
+ } else {
490
+ expanded.add(node.path);
491
+ }
492
+ recompute();
493
+ };
397
494
 
398
- list.addEventListener("click", (event) => {
399
- if (!(event.target instanceof Element) || event.target.closest("a")) return;
400
- const rowElement = event.target.closest<HTMLElement>(".row");
401
- const row = rows[Number(rowElement?.dataset.index)];
402
- if (!row) return;
403
- const action = event.target.closest<HTMLElement>("[data-action]")?.dataset.action;
404
- if (action === "toggle") toggle(row.node);
405
- else if (action === "watch") toggleWatch(row.node.path);
406
- else select(row.node.path);
407
- });
408
- list.addEventListener("keydown", (event) => {
409
- const index = rows.findIndex((row) => row.node.path === selected);
410
- const node = rows[index]?.node;
411
- switch (event.key) {
412
- case "ArrowDown":
413
- move(index + 1);
414
- break;
415
- case "ArrowUp":
416
- move(Math.max(0, index - 1));
417
- break;
418
- case "ArrowRight":
419
- if (node && !query && tree.expandable(node) && !expanded.has(node.path)) toggle(node);
420
- break;
421
- case "ArrowLeft":
422
- if (node && !query && expanded.has(node.path)) toggle(node);
423
- else if (node) move(rows.findIndex((row) => row.node.path === parentPath(node.path)));
424
- break;
425
- default:
426
- return;
427
- }
428
- event.preventDefault();
429
- });
430
- list.addEventListener("scroll", () => renderWindow(false), { passive: true });
431
- const resize = new ResizeObserver(() => renderWindow(false));
432
- resize.observe(list);
495
+ list.addEventListener("click", (event) => {
496
+ if (!(event.target instanceof Element) || event.target.closest("a")) {
497
+ return;
498
+ }
499
+ const rowElement = event.target.closest<HTMLElement>(".row");
500
+ const row = rows[Number(rowElement?.dataset.index)];
501
+ if (!row) {
502
+ return;
503
+ }
504
+ const action =
505
+ event.target.closest<HTMLElement>("[data-action]")?.dataset.action;
506
+ if (action === "toggle") {
507
+ toggle(row.node);
508
+ } else if (action === "watch") {
509
+ toggleWatch(row.node.path);
510
+ } else {
511
+ select(row.node.path);
512
+ }
513
+ });
514
+ list.addEventListener("keydown", (event) => {
515
+ const index = rows.findIndex((row) => row.node.path === selected);
516
+ const node = rows[index]?.node;
517
+ switch (event.key) {
518
+ case "ArrowDown":
519
+ move(index + 1);
520
+ break;
521
+ case "ArrowUp":
522
+ move(Math.max(0, index - 1));
523
+ break;
524
+ case "ArrowRight":
525
+ if (
526
+ node &&
527
+ !query &&
528
+ tree.expandable(node) &&
529
+ !expanded.has(node.path)
530
+ ) {
531
+ toggle(node);
532
+ }
533
+ break;
534
+ case "ArrowLeft":
535
+ if (node && !query && expanded.has(node.path)) {
536
+ toggle(node);
537
+ } else if (node) {
538
+ move(
539
+ rows.findIndex((row) => row.node.path === parentPath(node.path)),
540
+ );
541
+ }
542
+ break;
543
+ default:
544
+ return;
545
+ }
546
+ event.preventDefault();
547
+ });
548
+ list.addEventListener("scroll", () => renderWindow(false), { passive: true });
549
+ const resize = new ResizeObserver(() => renderWindow(false));
550
+ resize.observe(list);
433
551
 
434
- search.addEventListener("input", () => {
435
- clearTimeout(searchTimer);
436
- searchTimer = setTimeout(() => {
437
- query = search.value.trim();
438
- list.scrollTop = 0;
439
- recompute();
440
- renderDetail();
441
- }, 150);
442
- });
443
- generatedToggle.addEventListener("change", () => {
444
- showGenerated = generatedToggle.checked;
445
- recompute();
446
- });
552
+ search.addEventListener("input", () => {
553
+ clearTimeout(searchTimer);
554
+ searchTimer = setTimeout(() => {
555
+ query = search.value.trim();
556
+ list.scrollTop = 0;
557
+ recompute();
558
+ renderDetail();
559
+ }, 150);
560
+ });
561
+ generatedToggle.addEventListener("change", () => {
562
+ showGenerated = generatedToggle.checked;
563
+ recompute();
564
+ });
447
565
 
448
- renderGroups();
449
- renderDetail();
450
- requestAnimationFrame(() => (selected && !query ? reveal(selected) : recompute()));
566
+ renderGroups();
567
+ renderDetail();
568
+ requestAnimationFrame(() =>
569
+ selected && !query ? reveal(selected) : recompute(),
570
+ );
451
571
 
452
- return {
453
- element,
454
- update(topics) {
455
- if (topics.has("watch")) {
456
- renderGroups();
457
- renderWindow(true);
458
- renderDetail();
459
- }
460
- if (LIVE_TOPICS.some((topic) => topics.has(topic)))
461
- for (const value of [...rowValues, ...groupValues, ...(detailValue ? [detailValue] : [])])
462
- value.update();
463
- },
464
- dispose() {
465
- resize.disconnect();
466
- clearTimeout(searchTimer);
467
- },
468
- };
572
+ return {
573
+ element,
574
+ update(topics) {
575
+ if (topics.has("watch")) {
576
+ renderGroups();
577
+ renderWindow(true);
578
+ renderDetail();
579
+ }
580
+ if (LIVE_TOPICS.some((topic) => topics.has(topic))) {
581
+ for (const value of [
582
+ ...rowValues,
583
+ ...groupValues,
584
+ ...(detailValue ? [detailValue] : []),
585
+ ]) {
586
+ value.update();
587
+ }
588
+ }
589
+ },
590
+ dispose() {
591
+ resize.disconnect();
592
+ clearTimeout(searchTimer);
593
+ },
594
+ };
469
595
  };