@tsplc/debug 0.0.0-stage → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/tree.ts ADDED
@@ -0,0 +1,156 @@
1
+ import type { DebugConstant, DebugManifest, SourceRange } from "../manifest.ts";
2
+ import { joinPath, pathSegments, typeOf } from "../paths.ts";
3
+
4
+ /** One row of the instance tree: a root, a record field, an array element or a folded constant. */
5
+ export interface TreeNode {
6
+ path: string;
7
+ /** The last segment: a field name, `[3]`, or the whole path for a top-level node. */
8
+ name: string;
9
+ type: string;
10
+ declaration?: SourceRange;
11
+ /** Whether the target adapter can read it at runtime. */
12
+ readable: boolean;
13
+ generated: boolean;
14
+ constant?: DebugConstant;
15
+ }
16
+
17
+ export interface TreeRow {
18
+ node: TreeNode;
19
+ depth: number;
20
+ }
21
+
22
+ export const parentPath = (path: string) =>
23
+ path.slice(0, Math.max(path.lastIndexOf("."), path.lastIndexOf("[")));
24
+
25
+ /** Every proper prefix of `path` at a `.` or `[` boundary, shortest first. */
26
+ export const ancestorPaths = (path: string) =>
27
+ [...path.matchAll(/[.[]/g)].map((match) => path.slice(0, match.index));
28
+
29
+ /** The manifest's instances as a lazily expanded tree, addressed by TypeScript path. */
30
+ export class ManifestTree {
31
+ readonly top: TreeNode[];
32
+ private readonly constantsByParent = new Map<string, DebugConstant[]>();
33
+ private readonly children = new Map<string, TreeNode[]>();
34
+
35
+ constructor(readonly manifest: DebugManifest) {
36
+ const roots = manifest.roots.map((root) => ({
37
+ path: root.path,
38
+ name: root.path,
39
+ type: root.type,
40
+ declaration: root.declaration,
41
+ readable: root.readable,
42
+ generated: root.generated === true,
43
+ }));
44
+ for (const constant of manifest.constants) {
45
+ const parent = parentPath(constant.path);
46
+ this.constantsByParent.set(parent, [...(this.constantsByParent.get(parent) ?? []), constant]);
47
+ }
48
+ const orphans = manifest.constants.filter(
49
+ (constant) => !this.resolveVariable(roots, parentPath(constant.path)),
50
+ );
51
+ this.top = [...roots, ...orphans.map((constant) => this.constantNode(constant, constant.path))];
52
+ }
53
+
54
+ expandable(node: TreeNode) {
55
+ if (node.constant) return false;
56
+ const kind = typeOf(this.manifest, node.type).kind;
57
+ return kind === "record" || kind === "array";
58
+ }
59
+
60
+ childrenOf(node: TreeNode): TreeNode[] {
61
+ const cached = this.children.get(node.path);
62
+ if (cached) return cached;
63
+ const type = typeOf(this.manifest, node.type);
64
+ const variables: TreeNode[] =
65
+ type.kind === "record"
66
+ ? type.fields.map((field) => ({
67
+ path: `${node.path}.${field.name}`,
68
+ name: field.name,
69
+ type: field.type,
70
+ declaration: field.declaration,
71
+ readable: node.readable,
72
+ generated: node.generated || field.generated === true,
73
+ }))
74
+ : type.kind === "array"
75
+ ? Array.from({ length: type.length }, (_, offset) => ({
76
+ path: `${node.path}[${type.lower + offset}]`,
77
+ name: `[${type.lower + offset}]`,
78
+ type: type.element,
79
+ declaration: node.declaration,
80
+ readable: node.readable,
81
+ generated: node.generated,
82
+ }))
83
+ : [];
84
+ const constants = (this.constantsByParent.get(node.path) ?? []).map((constant) =>
85
+ this.constantNode(constant, constant.path.slice(node.path.length + 1)),
86
+ );
87
+ const children = [...variables, ...constants];
88
+ this.children.set(node.path, children);
89
+ return children;
90
+ }
91
+
92
+ /** The rows shown for an expansion state, depth first. */
93
+ rows(expanded: ReadonlySet<string>, showGenerated: boolean): TreeRow[] {
94
+ const rows: TreeRow[] = [];
95
+ const visit = (nodes: TreeNode[], depth: number) => {
96
+ for (const node of nodes) {
97
+ if (node.generated && !showGenerated) continue;
98
+ rows.push({ node, depth });
99
+ if (expanded.has(node.path) && this.expandable(node))
100
+ visit(this.childrenOf(node), depth + 1);
101
+ }
102
+ };
103
+ visit(this.top, 0);
104
+ return rows;
105
+ }
106
+
107
+ /** Nodes whose path contains `query` (case-insensitive), in tree order, at most `limit`. */
108
+ search(query: string, showGenerated: boolean, limit: number) {
109
+ const needle = query.toLowerCase();
110
+ const found: TreeNode[] = [];
111
+ let total = 0;
112
+ const visit = (nodes: TreeNode[]) => {
113
+ for (const node of nodes) {
114
+ if (node.generated && !showGenerated) continue;
115
+ if (node.path.toLowerCase().includes(needle) && total++ < limit) found.push(node);
116
+ if (this.expandable(node)) visit(this.childrenOf(node));
117
+ }
118
+ };
119
+ visit(this.top);
120
+ return { nodes: found, total };
121
+ }
122
+
123
+ resolve(path: string): TreeNode | undefined {
124
+ const constant = this.manifest.constants.find((entry) => entry.path === path);
125
+ if (constant) return this.constantNode(constant, path);
126
+ return this.resolveVariable(this.top, path);
127
+ }
128
+
129
+ private resolveVariable(top: TreeNode[], path: string): TreeNode | undefined {
130
+ let node = top.find(
131
+ (root) =>
132
+ path === root.path ||
133
+ (path.startsWith(root.path) && /^[.[]/.test(path.slice(root.path.length))),
134
+ );
135
+ while (node && node.path !== path) {
136
+ const rest = path.slice(node.path.length);
137
+ const [segment] = pathSegments(rest);
138
+ if (!segment) return undefined;
139
+ const childPath = joinPath(node.path, segment);
140
+ node = this.childrenOf(node).find((child) => child.path === childPath);
141
+ }
142
+ return node;
143
+ }
144
+
145
+ private constantNode(constant: DebugConstant, name: string): TreeNode {
146
+ return {
147
+ path: constant.path,
148
+ name,
149
+ type: constant.type,
150
+ declaration: constant.declaration,
151
+ readable: false,
152
+ generated: false,
153
+ constant,
154
+ };
155
+ }
156
+ }
@@ -0,0 +1,469 @@
1
+ import { LiveValue, type View, empty, fill, h, pathLink, sourceLink, wrappingPath } from "./dom.ts";
2
+ import { typeLabel } from "./format.ts";
3
+ import type { Store, Topic } from "./store.ts";
4
+ import { typeOf } from "../paths.ts";
5
+ import { type TreeNode, type TreeRow, ancestorPaths, parentPath } from "./tree.ts";
6
+
7
+ const ROW_HEIGHT = 26;
8
+ const OVERSCAN = 10;
9
+ const SEARCH_LIMIT = 5000;
10
+ const LIVE_TOPICS: Topic[] = ["samples", "clock", "session", "stream"];
11
+
12
+ // Kept across visits, so leaving the page and coming back keeps the tree as it was.
13
+ const expanded = new Set<string>();
14
+ let showGenerated = false;
15
+ let query = "";
16
+ let activeGroup: string | undefined;
17
+
18
+ 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
+ );
27
+ };
28
+
29
+ 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;
39
+
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
+ );
85
+
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);
90
+
91
+ const subscribe = () =>
92
+ store.show({
93
+ watch: [...rowValues, ...groupValues, ...(detailValue ? [detailValue] : [])]
94
+ .filter((value) => value.subscribed)
95
+ .map((value) => value.node.path),
96
+ });
97
+
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
+ };
109
+
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
+ };
160
+
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
+ };
177
+
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
+ };
193
+
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
+ };
200
+
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
+ };
207
+
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
+ };
290
+
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
+ };
376
+
377
+ const select = (path: string) => {
378
+ selected = path;
379
+ history.replaceState(null, "", `#/variables?path=${encodeURIComponent(path)}`);
380
+ renderWindow(true);
381
+ renderDetail();
382
+ subscribe();
383
+ };
384
+
385
+ const move = (index: number) => {
386
+ const row = rows[index];
387
+ if (!row) return;
388
+ select(row.node.path);
389
+ scrollToIndex(index);
390
+ };
391
+
392
+ const toggle = (node: TreeNode) => {
393
+ if (expanded.has(node.path)) expanded.delete(node.path);
394
+ else expanded.add(node.path);
395
+ recompute();
396
+ };
397
+
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);
433
+
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
+ });
447
+
448
+ renderGroups();
449
+ renderDetail();
450
+ requestAnimationFrame(() => (selected && !query ? reveal(selected) : recompute()));
451
+
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
+ };
469
+ };
package/src/watch.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { isDeepStrictEqual } from "node:util";
5
+ import { z } from "zod";
6
+ import type { WatchGroups } from "./api.ts";
7
+
8
+ export const watchGroupsSchema = z.strictObject({
9
+ groups: z.record(z.string().min(1), z.array(z.string().min(1))),
10
+ });
11
+
12
+ /**
13
+ * Watch groups: the config's `debug.watch`, then groups saved in `file`. Saving keeps only the
14
+ * groups that differ from the config, so the config stays their source.
15
+ */
16
+ export const loadWatchGroups = async (file: string, configured: Record<string, string[]>) => {
17
+ let saved = existsSync(file)
18
+ ? watchGroupsSchema.parse(JSON.parse(await readFile(file, "utf8"))).groups
19
+ : {};
20
+ const groups = (): WatchGroups => ({ groups: { ...configured, ...saved } });
21
+ return {
22
+ groups,
23
+ save: async ({ groups: next }: WatchGroups) => {
24
+ saved = Object.fromEntries(
25
+ Object.entries(next).filter(([name, paths]) => !isDeepStrictEqual(configured[name], paths)),
26
+ );
27
+ await mkdir(dirname(file), { recursive: true });
28
+ await writeFile(file, JSON.stringify({ groups: saved }, null, 2));
29
+ return groups();
30
+ },
31
+ };
32
+ };
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.