@crewhaus/hangar-ui 0.5.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 (51) hide show
  1. package/README.md +309 -0
  2. package/assets/hangar.css +1726 -0
  3. package/assets/index.html +21 -0
  4. package/assets/js/api.js +368 -0
  5. package/assets/js/app.js +734 -0
  6. package/assets/js/dom.js +228 -0
  7. package/assets/js/keys.js +159 -0
  8. package/assets/js/markdown.js +131 -0
  9. package/assets/js/notify.js +152 -0
  10. package/assets/js/omnibox.js +307 -0
  11. package/assets/js/router.js +280 -0
  12. package/assets/js/routes.js +793 -0
  13. package/assets/js/shapes.js +36 -0
  14. package/assets/js/supervision.js +1334 -0
  15. package/assets/js/util.js +426 -0
  16. package/assets/js/views/activity.js +140 -0
  17. package/assets/js/views/approvals.js +155 -0
  18. package/assets/js/views/channels.js +455 -0
  19. package/assets/js/views/control.js +253 -0
  20. package/assets/js/views/costs.js +103 -0
  21. package/assets/js/views/creds.js +610 -0
  22. package/assets/js/views/data.js +456 -0
  23. package/assets/js/views/deploy.js +106 -0
  24. package/assets/js/views/evals-lab.js +1029 -0
  25. package/assets/js/views/evals.js +342 -0
  26. package/assets/js/views/feedback.js +555 -0
  27. package/assets/js/views/health.js +176 -0
  28. package/assets/js/views/inbox.js +103 -0
  29. package/assets/js/views/inspect.js +751 -0
  30. package/assets/js/views/jobs.js +110 -0
  31. package/assets/js/views/library.js +605 -0
  32. package/assets/js/views/memory-fabric.js +2026 -0
  33. package/assets/js/views/memory.js +228 -0
  34. package/assets/js/views/onboarding.js +253 -0
  35. package/assets/js/views/overview.js +196 -0
  36. package/assets/js/views/panes.js +132 -0
  37. package/assets/js/views/proc.js +280 -0
  38. package/assets/js/views/review.js +164 -0
  39. package/assets/js/views/runs.js +452 -0
  40. package/assets/js/views/runtime.js +450 -0
  41. package/assets/js/views/schedulers.js +133 -0
  42. package/assets/js/views/security.js +925 -0
  43. package/assets/js/views/sessions.js +265 -0
  44. package/assets/js/views/settings.js +489 -0
  45. package/assets/js/views/spec-edit.js +1289 -0
  46. package/assets/js/views/spec.js +66 -0
  47. package/assets/js/views/thredz.js +1853 -0
  48. package/assets/js/views/token.js +49 -0
  49. package/dist/index.d.ts +15 -0
  50. package/dist/index.js +145 -0
  51. package/package.json +36 -0
@@ -0,0 +1,605 @@
1
+ /**
2
+ * Library — the fleet table. Dense, sortable, filterable by stored groups
3
+ * and computed smart groups. All writes on this screen are registry CRUD
4
+ * (the only write surface in the read-only alpha): add/scan, group
5
+ * create/assign, tags/pin/notes, missing-dir relocate/remove.
6
+ */
7
+
8
+ import { api } from "../api.js";
9
+ import { asOf, clear, copyBtn, dot, el, emptyState, skeleton, toast } from "../dom.js";
10
+ import { hrefHarness } from "../router.js";
11
+ import { shapeAccent, shapeLabel } from "../shapes.js";
12
+ import { procStatePill } from "../supervision.js";
13
+ import {
14
+ deriveSmartGroups,
15
+ dirTail,
16
+ evalHealth,
17
+ fmtCount,
18
+ fmtRelativeTime,
19
+ fmtUsd,
20
+ normalizeRows,
21
+ oldestCachedAt,
22
+ rollupLine,
23
+ sortRows,
24
+ } from "../util.js";
25
+ import { renderOnboarding, shouldOnboard } from "./onboarding.js";
26
+
27
+ const state = {
28
+ sortKey: "name",
29
+ sortDir: "asc",
30
+ filter: { kind: "all" }, // {kind:"all"} | {kind:"group",name} | {kind:"smart",id}
31
+ query: "",
32
+ };
33
+
34
+ /**
35
+ * Run one registry write; on failure surface a toast (a write must NEVER
36
+ * vanish silently) and skip the reload so the form/state stays visible.
37
+ */
38
+ async function tryWrite(label, fn, onOk) {
39
+ try {
40
+ await fn();
41
+ } catch (err) {
42
+ toast(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
43
+ return;
44
+ }
45
+ onOk();
46
+ }
47
+
48
+ const COLUMNS = [
49
+ { key: "name", label: "Name", get: (r) => r.specName || dirTail(r.dir, 1) },
50
+ { key: "shape", label: "Shape", get: (r) => r.target },
51
+ { key: "model", label: "Model", get: (r) => r.model },
52
+ { key: "process", label: "Process", get: (r) => r.supervision, sortable: true },
53
+ { key: "eval", label: "Last eval", get: (r) => r.lastEval?.passRate ?? null },
54
+ { key: "sessions", label: "Sessions", get: (r) => r.sessions },
55
+ { key: "spend", label: "Spend 7d", get: (r) => r.spend7dUsd },
56
+ { key: "caps", label: "Capabilities", get: () => null, sortable: false },
57
+ { key: "groups", label: "Groups", get: () => null, sortable: false },
58
+ { key: "edit", label: "", get: () => null, sortable: false },
59
+ ];
60
+
61
+ export async function renderLibrary(root) {
62
+ clear(root).appendChild(skeleton(6));
63
+ // First paint from the plain (cache-only) feed — instant, stale-labeled
64
+ // via each row's cachedAt — then a background ?hydrate=1 refetch replaces
65
+ // the rows with freshly computed rollups.
66
+ const [feedRes, groupsRes] = await Promise.allSettled([api.harnesses(), api.groups()]);
67
+ if (feedRes.status === "rejected") throw feedRes.reason;
68
+ const rows = normalizeRows(feedRes.value);
69
+ // HM-12: an empty Library on a machine with no scan roots is FIRST BOOT,
70
+ // not an empty list — hand over to onboarding rather than showing a table
71
+ // with no rows and a verb nobody has run yet. A machine that HAS a root
72
+ // configured keeps the empty state below, because "the scan found nothing"
73
+ // is a different fact and its own verb answers it.
74
+ if (rows.length === 0) {
75
+ const onboardingRes = await Promise.allSettled([api.onboarding()]);
76
+ const view = onboardingRes[0].status === "fulfilled" ? onboardingRes[0].value : null;
77
+ if (shouldOnboard(view, rows.length)) {
78
+ clear(root);
79
+ await renderOnboarding(root, { reload: () => renderLibrary(root) });
80
+ return;
81
+ }
82
+ }
83
+ const groups = normalizeGroups(groupsRes.status === "fulfilled" ? groupsRes.value : null, rows);
84
+ draw(root, rows, groups, { hydrating: true });
85
+ hydrateInBackground(root, groups);
86
+ }
87
+
88
+ async function hydrateInBackground(root, groups) {
89
+ let feed;
90
+ try {
91
+ feed = await api.harnesses(true);
92
+ } catch {
93
+ return; // the cold paint stands; the next reload retries
94
+ }
95
+ if (!root.isConnected) return; // navigated away meanwhile
96
+ draw(root, normalizeRows(feed), groups, { hydrating: false });
97
+ }
98
+
99
+ function normalizeGroups(payload, rows) {
100
+ const list = Array.isArray(payload)
101
+ ? payload
102
+ : payload && Array.isArray(payload.groups)
103
+ ? payload.groups
104
+ : [];
105
+ const named = new Map();
106
+ for (const g of list) {
107
+ if (g && typeof g.name === "string" && g.name !== "") {
108
+ named.set(g.name, {
109
+ name: g.name,
110
+ order: typeof g.order === "number" ? g.order : named.size + 1,
111
+ color: typeof g.color === "string" ? g.color : "",
112
+ });
113
+ }
114
+ }
115
+ // Groups referenced on rows but absent from the stored list still render.
116
+ for (const r of rows) {
117
+ for (const name of r.groups) {
118
+ if (!named.has(name)) named.set(name, { name, order: named.size + 1, color: "" });
119
+ }
120
+ }
121
+ return [...named.values()].sort((a, b) => a.order - b.order);
122
+ }
123
+
124
+ function applyFilter(rows, nowMs) {
125
+ let out = rows;
126
+ if (state.filter.kind === "group") {
127
+ const name = state.filter.name;
128
+ out = out.filter((r) => r.groups.includes(name));
129
+ } else if (state.filter.kind === "smart") {
130
+ const smart = deriveSmartGroups(rows, nowMs).find((g) => g.id === state.filter.id);
131
+ out = smart ? smart.rows : [];
132
+ }
133
+ if (state.query !== "") {
134
+ const q = state.query.toLowerCase();
135
+ out = out.filter(
136
+ (r) =>
137
+ r.specName.toLowerCase().includes(q) ||
138
+ r.dir.toLowerCase().includes(q) ||
139
+ r.tags.some((t) => t.toLowerCase().includes(q)),
140
+ );
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function draw(root, rows, groups, opts = {}) {
146
+ const nowMs = Date.now();
147
+ const redraw = () => draw(root, rows, groups, opts);
148
+ const reload = () => renderLibrary(root);
149
+ clear(root);
150
+
151
+ const layout = el("div", { class: "lib" });
152
+ layout.appendChild(renderRail(rows, groups, nowMs, redraw, reload));
153
+
154
+ const main = el("section", { class: "lib-main" });
155
+ main.appendChild(renderToolbar(reload));
156
+ const cachedAt = oldestCachedAt(rows);
157
+ main.appendChild(
158
+ el("div", { class: "rollup" }, [
159
+ el("span", { text: rollupLine(rows) }),
160
+ cachedAt ? asOf(cachedAt) : null,
161
+ opts.hydrating === true ? el("span", { class: "muted", text: "· refreshing…" }) : null,
162
+ ]),
163
+ );
164
+
165
+ for (const row of rows.filter((r) => r.missingSince !== null)) {
166
+ main.appendChild(missingCard(row, reload));
167
+ }
168
+
169
+ const visible = applyFilter(
170
+ rows.filter((r) => r.missingSince === null),
171
+ nowMs,
172
+ );
173
+ if (rows.length === 0) {
174
+ main.appendChild(
175
+ emptyState(
176
+ "No harnesses registered yet",
177
+ "crewhaus harness add <dir> (or Scan a root above)",
178
+ ),
179
+ );
180
+ } else if (visible.length === 0) {
181
+ main.appendChild(emptyState("No harnesses match this filter"));
182
+ } else {
183
+ main.appendChild(renderTable(visible, groups, nowMs, redraw, reload));
184
+ }
185
+ layout.appendChild(main);
186
+ root.appendChild(layout);
187
+ }
188
+
189
+ function renderRail(rows, groups, nowMs, redraw, reload) {
190
+ const rail = el("aside", { class: "rail", "aria-label": "groups" });
191
+ const item = (label, count, active, color, onClick) => {
192
+ const btn = el(
193
+ "button",
194
+ { class: `rail-item${active ? " active" : ""}`, type: "button", onClick },
195
+ [
196
+ color ? el("span", { class: "swatch", style: { background: color } }) : null,
197
+ el("span", { class: "rail-label", text: label }),
198
+ el("span", { class: "rail-count", text: String(count) }),
199
+ ],
200
+ );
201
+ return btn;
202
+ };
203
+ rail.appendChild(
204
+ item("All harnesses", rows.length, state.filter.kind === "all", null, () => {
205
+ state.filter = { kind: "all" };
206
+ redraw();
207
+ }),
208
+ );
209
+
210
+ if (groups.length > 0) rail.appendChild(el("div", { class: "rail-head", text: "Groups" }));
211
+ for (const g of groups) {
212
+ const count = rows.filter((r) => r.groups.includes(g.name)).length;
213
+ const active = state.filter.kind === "group" && state.filter.name === g.name;
214
+ rail.appendChild(
215
+ item(g.name, count, active, g.color || null, () => {
216
+ state.filter = { kind: "group", name: g.name };
217
+ redraw();
218
+ }),
219
+ );
220
+ }
221
+
222
+ const newBtn = el("button", { class: "rail-item rail-new", type: "button", text: "+ New group" });
223
+ newBtn.addEventListener("click", () => {
224
+ const form = groupForm(groups, reload, () => {
225
+ form.replaceWith(newBtn);
226
+ });
227
+ newBtn.replaceWith(form);
228
+ });
229
+ rail.appendChild(newBtn);
230
+
231
+ rail.appendChild(el("div", { class: "rail-head", text: "Smart groups" }));
232
+ for (const g of deriveSmartGroups(rows, nowMs)) {
233
+ const active = state.filter.kind === "smart" && state.filter.id === g.id;
234
+ rail.appendChild(
235
+ item(g.label, g.rows.length, active, null, () => {
236
+ state.filter = { kind: "smart", id: g.id };
237
+ redraw();
238
+ }),
239
+ );
240
+ }
241
+ return rail;
242
+ }
243
+
244
+ function groupForm(groups, reload, onCancel) {
245
+ const input = el("input", {
246
+ class: "input",
247
+ type: "text",
248
+ placeholder: "group name",
249
+ "aria-label": "new group name",
250
+ });
251
+ const form = el("form", { class: "rail-form" }, [
252
+ input,
253
+ el("button", { class: "btn btn-primary", type: "submit", text: "Add" }),
254
+ el("button", { class: "btn btn-ghost", type: "button", text: "Cancel", onClick: onCancel }),
255
+ ]);
256
+ form.addEventListener("submit", (e) => {
257
+ e.preventDefault();
258
+ const name = input.value.trim();
259
+ if (name === "" || groups.some((g) => g.name === name)) return;
260
+ tryWrite("Create group", () => api.addGroup(name), reload);
261
+ });
262
+ return form;
263
+ }
264
+
265
+ function renderToolbar(reload) {
266
+ const search = el("input", {
267
+ class: "input search",
268
+ type: "search",
269
+ placeholder: "Filter by name, dir, tag…",
270
+ value: state.query,
271
+ "aria-label": "filter harnesses",
272
+ });
273
+ search.addEventListener("input", () => {
274
+ state.query = search.value.trim();
275
+ });
276
+ search.addEventListener("change", reload);
277
+
278
+ // A dir-input form under the toolbar (Add harness / Add scan root share
279
+ // the pattern); only one open at a time.
280
+ let openedForm = null;
281
+ const openForm = (bar, placeholder, ariaLabel, submitLabel, onSubmit) => {
282
+ if (openedForm !== null) openedForm.remove();
283
+ const dirInput = el("input", {
284
+ class: "input grow",
285
+ type: "text",
286
+ placeholder,
287
+ "aria-label": ariaLabel,
288
+ });
289
+ const form = el("form", { class: "add-form" }, [
290
+ dirInput,
291
+ el("button", { class: "btn btn-primary", type: "submit", text: submitLabel }),
292
+ el("button", {
293
+ class: "btn btn-ghost",
294
+ type: "button",
295
+ text: "Cancel",
296
+ onClick: () => form.remove(),
297
+ }),
298
+ ]);
299
+ form.addEventListener("submit", (e) => {
300
+ e.preventDefault();
301
+ const dir = dirInput.value.trim();
302
+ if (dir === "") return;
303
+ onSubmit(dir);
304
+ });
305
+ bar.after(form);
306
+ openedForm = form;
307
+ dirInput.focus();
308
+ };
309
+
310
+ const scanBtn = el("button", { class: "btn", type: "button", text: "Scan" });
311
+ const rootBtn = el("button", { class: "btn", type: "button", text: "Add scan root…" });
312
+ const addBtn = el("button", { class: "btn", type: "button", text: "Add harness…" });
313
+ const bar = el("div", { class: "toolbar" }, [search, scanBtn, rootBtn, addBtn]);
314
+
315
+ scanBtn.addEventListener("click", async () => {
316
+ scanBtn.disabled = true;
317
+ scanBtn.textContent = "Scanning…";
318
+ try {
319
+ const result = await api.scan();
320
+ const roots = result && typeof result === "object" ? result.roots : null;
321
+ if (roots === 0) {
322
+ // Zero-work scan is not success — say so and open the fix.
323
+ toast("No scan roots configured — add one first", "info");
324
+ rootBtn.click();
325
+ } else {
326
+ reload();
327
+ return;
328
+ }
329
+ } catch (err) {
330
+ toast(`Scan failed: ${err instanceof Error ? err.message : String(err)}`);
331
+ } finally {
332
+ scanBtn.disabled = false;
333
+ scanBtn.textContent = "Scan";
334
+ }
335
+ });
336
+
337
+ rootBtn.addEventListener("click", () => {
338
+ openForm(bar, "/absolute/path/to/scan", "scan root directory", "Add root", (dir) => {
339
+ // After the root lands, scan it right away so the table fills.
340
+ tryWrite(
341
+ "Add scan root",
342
+ () => api.addScanRoot(dir),
343
+ async () => {
344
+ try {
345
+ await api.scan();
346
+ } catch {
347
+ // root saved; the next manual Scan retries discovery
348
+ }
349
+ reload();
350
+ },
351
+ );
352
+ });
353
+ });
354
+
355
+ addBtn.addEventListener("click", () => {
356
+ openForm(bar, "/absolute/path/to/harness", "harness directory", "Add", (dir) => {
357
+ tryWrite("Add harness", () => api.addHarness(dir), reload);
358
+ });
359
+ });
360
+ return bar;
361
+ }
362
+
363
+ function missingCard(row, reload) {
364
+ const name = row.specName || dirTail(row.dir, 1);
365
+ const dirInput = el("input", {
366
+ class: "input grow",
367
+ type: "text",
368
+ placeholder: "new absolute path",
369
+ "aria-label": `new path for ${name}`,
370
+ });
371
+ const relocate = el("form", { class: "add-form" }, [
372
+ dirInput,
373
+ el("button", { class: "btn", type: "submit", text: "Relocate" }),
374
+ ]);
375
+ relocate.addEventListener("submit", (e) => {
376
+ e.preventDefault();
377
+ const dir = dirInput.value.trim();
378
+ if (dir === "") return;
379
+ tryWrite("Relocate", () => api.relocateHarness(row.id, dir), reload);
380
+ });
381
+ const removeBtn = el("button", { class: "btn btn-danger", type: "button", text: "Remove entry" });
382
+ removeBtn.addEventListener("click", () => {
383
+ const sure = window.confirm(
384
+ `Remove "${name}" from the registry? Only the registry row is deleted — no harness data.`,
385
+ );
386
+ if (!sure) return;
387
+ tryWrite("Remove", () => api.removeHarness(row.id), reload);
388
+ });
389
+ return el("div", { class: "card missing-card" }, [
390
+ el("div", { class: "missing-head" }, [
391
+ dot("bad", "missing"),
392
+ el("strong", { text: name }),
393
+ el("span", {
394
+ class: "muted",
395
+ text: ` — directory gone since ${fmtRelativeTime(row.missingSince, Date.now())}`,
396
+ }),
397
+ ]),
398
+ el("div", { class: "missing-dir mono", text: row.dir }),
399
+ el("div", { class: "missing-actions" }, [relocate, removeBtn]),
400
+ ]);
401
+ }
402
+
403
+ function renderTable(rows, groups, nowMs, redraw, reload) {
404
+ const col = COLUMNS.find((c) => c.key === state.sortKey) ?? COLUMNS[0];
405
+ const sorted = sortRows(rows, col.get, state.sortDir);
406
+
407
+ const thead = el(
408
+ "thead",
409
+ null,
410
+ el(
411
+ "tr",
412
+ null,
413
+ COLUMNS.map((c) => {
414
+ const sortable = c.sortable !== false;
415
+ const active = state.sortKey === c.key;
416
+ const th = el("th", {
417
+ "aria-sort": active ? (state.sortDir === "asc" ? "ascending" : "descending") : null,
418
+ });
419
+ if (!sortable) {
420
+ th.appendChild(el("span", { text: c.label }));
421
+ return th;
422
+ }
423
+ th.appendChild(
424
+ el("button", {
425
+ class: `th-sort${active ? " active" : ""}`,
426
+ type: "button",
427
+ text: active ? `${c.label} ${state.sortDir === "asc" ? "↑" : "↓"}` : c.label,
428
+ onClick: () => {
429
+ if (state.sortKey === c.key) {
430
+ state.sortDir = state.sortDir === "asc" ? "desc" : "asc";
431
+ } else {
432
+ state.sortKey = c.key;
433
+ state.sortDir = "asc";
434
+ }
435
+ redraw();
436
+ },
437
+ }),
438
+ );
439
+ return th;
440
+ }),
441
+ ),
442
+ );
443
+
444
+ const tbody = el("tbody");
445
+ for (const row of sorted) {
446
+ tbody.appendChild(harnessRow(row, groups, reload));
447
+ }
448
+ return el("div", { class: "table-scroll" }, [el("table", { class: "fleet" }, [thead, tbody])]);
449
+ }
450
+
451
+ function harnessRow(row, groups, reload) {
452
+ const health = evalHealth(row.lastEval);
453
+ const name = row.specName || dirTail(row.dir, 1) || row.id;
454
+
455
+ const pin = el("button", {
456
+ class: `pin${row.pinned ? " pinned" : ""}`,
457
+ type: "button",
458
+ title: row.pinned ? "Unpin" : "Pin (pinned harnesses sort first)",
459
+ "aria-label": row.pinned ? `unpin ${name}` : `pin ${name}`,
460
+ text: row.pinned ? "★" : "☆",
461
+ });
462
+ pin.addEventListener("click", () => {
463
+ tryWrite("Pin", () => api.setPin(row.id, !row.pinned), reload);
464
+ });
465
+
466
+ const tr = el("tr", { class: "fleet-row" }, [
467
+ el("td", { class: "cell-name" }, [
468
+ el("div", { class: "name-line" }, [
469
+ pin,
470
+ el("a", { href: hrefHarness(row.id), class: "name-link", text: name }),
471
+ ]),
472
+ el("div", { class: "sub mono", title: row.dir, text: dirTail(row.dir, 2) }),
473
+ ]),
474
+ el("td", null, [
475
+ el("span", {
476
+ class: "shape-badge",
477
+ style: { "--accent": shapeAccent(row.target) },
478
+ text: shapeLabel(row.target),
479
+ }),
480
+ ]),
481
+ el("td", { class: "mono", text: row.model ?? "—" }),
482
+ el("td", null, [
483
+ row.supervision === null
484
+ ? el("span", { class: "muted", text: "—" })
485
+ : el("a", { class: "state-link", href: hrefHarness(row.id, "runs") }, [
486
+ dot(procStatePill(row.supervision).dot, procStatePill(row.supervision).label),
487
+ ]),
488
+ row.pendingApprovals > 0
489
+ ? el("a", {
490
+ class: "chip chip-warn",
491
+ href: "#/approvals",
492
+ text: `${row.pendingApprovals} waiting`,
493
+ })
494
+ : null,
495
+ ]),
496
+ el("td", null, [
497
+ dot(
498
+ health.state === "pass"
499
+ ? "ok"
500
+ : health.state === "fail"
501
+ ? "bad"
502
+ : health.state === "unknown"
503
+ ? "unknown"
504
+ : "off",
505
+ health.label,
506
+ ),
507
+ ]),
508
+ el("td", { class: "num", text: fmtCount(row.sessions) }),
509
+ el("td", { class: "num" }, [
510
+ el("span", { text: fmtUsd(row.spend7dUsd) }),
511
+ row.cachedAt ? asOf(row.cachedAt) : null,
512
+ ]),
513
+ el(
514
+ "td",
515
+ { class: "cell-caps" },
516
+ row.capabilities.length > 0
517
+ ? row.capabilities.map((c) => el("span", { class: "chip", text: c }))
518
+ : el("span", { class: "muted", text: "—" }),
519
+ ),
520
+ el(
521
+ "td",
522
+ { class: "cell-groups" },
523
+ row.groups.length > 0
524
+ ? row.groups.map((g) => el("span", { class: "chip chip-group", text: g }))
525
+ : el("span", { class: "muted", text: "—" }),
526
+ ),
527
+ el("td", { class: "cell-edit" }),
528
+ ]);
529
+
530
+ const editBtn = el("button", {
531
+ class: "btn btn-ghost",
532
+ type: "button",
533
+ text: "Edit",
534
+ "aria-label": `edit registry fields for ${name}`,
535
+ });
536
+ let editorTr = null;
537
+ editBtn.addEventListener("click", () => {
538
+ if (editorTr) {
539
+ editorTr.remove();
540
+ editorTr = null;
541
+ return;
542
+ }
543
+ editorTr = editorRow(row, groups, reload);
544
+ tr.after(editorTr);
545
+ });
546
+ tr.querySelector(".cell-edit").appendChild(editBtn);
547
+ return tr;
548
+ }
549
+
550
+ function editorRow(row, groups, reload) {
551
+ const tags = el("input", {
552
+ class: "input",
553
+ type: "text",
554
+ value: row.tags.join(", "),
555
+ placeholder: "tags, comma-separated",
556
+ "aria-label": "tags",
557
+ });
558
+ const notes = el("textarea", {
559
+ class: "input notes",
560
+ rows: "2",
561
+ placeholder: "notes",
562
+ "aria-label": "notes",
563
+ });
564
+ notes.value = row.notes;
565
+ const checks = groups.map((g) => {
566
+ const cb = el("input", { type: "checkbox", "aria-label": `group ${g.name}` });
567
+ cb.checked = row.groups.includes(g.name);
568
+ return { name: g.name, cb };
569
+ });
570
+ const form = el("form", { class: "row-editor" }, [
571
+ el("label", { class: "field" }, [el("span", { text: "Tags" }), tags]),
572
+ el("label", { class: "field" }, [el("span", { text: "Notes" }), notes]),
573
+ el("div", { class: "field" }, [
574
+ el("span", { text: groups.length > 0 ? "Groups" : "Groups (none defined yet)" }),
575
+ el(
576
+ "div",
577
+ { class: "group-checks" },
578
+ checks.map((c) => el("label", { class: "check" }, [c.cb, el("span", { text: c.name })])),
579
+ ),
580
+ ]),
581
+ el("div", { class: "editor-actions" }, [
582
+ el("button", { class: "btn btn-primary", type: "submit", text: "Save" }),
583
+ ]),
584
+ ]);
585
+ form.addEventListener("submit", (e) => {
586
+ e.preventDefault();
587
+ const tagList = tags.value
588
+ .split(",")
589
+ .map((t) => t.trim())
590
+ .filter((t) => t !== "");
591
+ const groupList = checks.filter((c) => c.cb.checked).map((c) => c.name);
592
+ // The server's registry writes are per-field PUTs; save all three.
593
+ tryWrite(
594
+ "Save",
595
+ async () => {
596
+ await api.setTags(row.id, tagList);
597
+ await api.setNotes(row.id, notes.value);
598
+ await api.setGroups(row.id, groupList);
599
+ },
600
+ reload,
601
+ );
602
+ });
603
+ const td = el("td", { colspan: String(COLUMNS.length) }, [form, copyBtn(row.dir, "copy dir")]);
604
+ return el("tr", { class: "editor-tr" }, [td]);
605
+ }