@unbrained/pm-web 2026.8.10 → 2026.8.17

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 (182) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/app.d.ts +16 -0
  3. package/dist/app.js +16 -0
  4. package/dist/app.js.map +1 -1
  5. package/dist/auth.d.ts +5 -0
  6. package/dist/auth.js.map +1 -1
  7. package/dist/board.d.ts +53 -0
  8. package/dist/board.js +48 -0
  9. package/dist/board.js.map +1 -1
  10. package/dist/crypto.d.ts +23 -0
  11. package/dist/crypto.js +32 -0
  12. package/dist/crypto.js.map +1 -1
  13. package/dist/db.d.ts +19 -0
  14. package/dist/db.js +19 -0
  15. package/dist/db.js.map +1 -1
  16. package/dist/ical.d.ts +72 -0
  17. package/dist/ical.js +73 -0
  18. package/dist/ical.js.map +1 -1
  19. package/dist/index.d.ts +11 -0
  20. package/dist/index.js +52 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/middleware/auth.d.ts +8 -0
  23. package/dist/middleware/auth.js.map +1 -1
  24. package/dist/oidc.d.ts +87 -0
  25. package/dist/oidc.js +113 -0
  26. package/dist/oidc.js.map +1 -1
  27. package/dist/routes/admin.js +15 -0
  28. package/dist/routes/admin.js.map +1 -1
  29. package/dist/routes/extensions.js +10 -0
  30. package/dist/routes/extensions.js.map +1 -1
  31. package/dist/routes/github.js +13 -0
  32. package/dist/routes/github.js.map +1 -1
  33. package/dist/routes/oidc.d.ts +10 -0
  34. package/dist/routes/oidc.js +51 -0
  35. package/dist/routes/oidc.js.map +1 -1
  36. package/dist/routes/pm.js +132 -1
  37. package/dist/routes/pm.js.map +1 -1
  38. package/dist/routes/sharing.js +8 -0
  39. package/dist/routes/sharing.js.map +1 -1
  40. package/dist/services/mutation-event-watcher.js +13 -0
  41. package/dist/services/mutation-event-watcher.js.map +1 -1
  42. package/dist/services/package-catalog.d.ts +24 -0
  43. package/dist/services/package-catalog.js +22 -2
  44. package/dist/services/package-catalog.js.map +1 -1
  45. package/dist/services/pm-runner.d.ts +82 -0
  46. package/dist/services/pm-runner.js +121 -0
  47. package/dist/services/pm-runner.js.map +1 -1
  48. package/dist/services/project-watcher.d.ts +22 -0
  49. package/dist/services/project-watcher.js +51 -0
  50. package/dist/services/project-watcher.js.map +1 -1
  51. package/dist/services/realtime-bus.d.ts +46 -0
  52. package/dist/services/realtime-bus.js +59 -0
  53. package/dist/services/realtime-bus.js.map +1 -1
  54. package/dist/services/sse.d.ts +134 -0
  55. package/dist/services/sse.js +145 -0
  56. package/dist/services/sse.js.map +1 -1
  57. package/manifest.json +1 -1
  58. package/package.json +5 -3
  59. package/public/src/app.js +44 -1
  60. package/public/src/app.js.map +1 -1
  61. package/public/src/app.ts +44 -1
  62. package/public/src/components/modals.js +11 -0
  63. package/public/src/components/modals.js.map +1 -1
  64. package/public/src/components/modals.ts +11 -0
  65. package/public/src/components/toast.js +2 -0
  66. package/public/src/components/toast.js.map +1 -1
  67. package/public/src/components/toast.ts +2 -0
  68. package/public/src/constants.js +21 -4
  69. package/public/src/constants.js.map +1 -1
  70. package/public/src/constants.ts +21 -4
  71. package/public/src/filters.js +10 -7
  72. package/public/src/filters.js.map +1 -1
  73. package/public/src/filters.ts +10 -7
  74. package/public/src/i18n/de.json +3 -1
  75. package/public/src/i18n/en.json +3 -1
  76. package/public/src/i18n/es.json +3 -1
  77. package/public/src/i18n/zh.json +3 -1
  78. package/public/src/i18n.js +11 -1
  79. package/public/src/i18n.js.map +1 -1
  80. package/public/src/i18n.ts +13 -1
  81. package/public/src/state.js +3 -0
  82. package/public/src/state.js.map +1 -1
  83. package/public/src/state.ts +3 -0
  84. package/public/src/sw.ts +71 -7
  85. package/public/src/theme.js +13 -1
  86. package/public/src/theme.js.map +1 -1
  87. package/public/src/theme.ts +15 -1
  88. package/public/src/types.ts +2 -0
  89. package/public/src/utils.js +26 -1
  90. package/public/src/utils.js.map +1 -1
  91. package/public/src/utils.ts +26 -1
  92. package/public/src/views/activity.js +1 -0
  93. package/public/src/views/activity.js.map +1 -1
  94. package/public/src/views/activity.ts +1 -0
  95. package/public/src/views/admin.js +134 -0
  96. package/public/src/views/admin.js.map +1 -1
  97. package/public/src/views/admin.ts +134 -0
  98. package/public/src/views/auth.js +31 -0
  99. package/public/src/views/auth.js.map +1 -1
  100. package/public/src/views/auth.ts +31 -0
  101. package/public/src/views/calendar.js +3 -0
  102. package/public/src/views/calendar.js.map +1 -1
  103. package/public/src/views/calendar.ts +3 -0
  104. package/public/src/views/comments-audit.js +1 -0
  105. package/public/src/views/comments-audit.js.map +1 -1
  106. package/public/src/views/comments-audit.ts +1 -0
  107. package/public/src/views/config.js +67 -0
  108. package/public/src/views/config.js.map +1 -1
  109. package/public/src/views/config.ts +67 -0
  110. package/public/src/views/context.js +2 -0
  111. package/public/src/views/context.js.map +1 -1
  112. package/public/src/views/context.ts +2 -0
  113. package/public/src/views/create.js +3 -0
  114. package/public/src/views/create.js.map +1 -1
  115. package/public/src/views/create.ts +3 -0
  116. package/public/src/views/dedupe.js +1 -0
  117. package/public/src/views/dedupe.js.map +1 -1
  118. package/public/src/views/dedupe.ts +1 -0
  119. package/public/src/views/export.js +6 -0
  120. package/public/src/views/export.js.map +1 -1
  121. package/public/src/views/export.ts +6 -0
  122. package/public/src/views/github.js +58 -0
  123. package/public/src/views/github.js.map +1 -1
  124. package/public/src/views/github.ts +58 -0
  125. package/public/src/views/graph-canvas.js +18 -0
  126. package/public/src/views/graph-canvas.js.map +1 -1
  127. package/public/src/views/graph-canvas.ts +20 -0
  128. package/public/src/views/graph.js +41 -0
  129. package/public/src/views/graph.js.map +1 -1
  130. package/public/src/views/graph.ts +42 -1
  131. package/public/src/views/groups.js +45 -0
  132. package/public/src/views/groups.js.map +1 -1
  133. package/public/src/views/groups.ts +45 -0
  134. package/public/src/views/guide.js +3 -0
  135. package/public/src/views/guide.js.map +1 -1
  136. package/public/src/views/guide.ts +3 -0
  137. package/public/src/views/health.js +3 -0
  138. package/public/src/views/health.js.map +1 -1
  139. package/public/src/views/health.ts +3 -0
  140. package/public/src/views/items.js +196 -6
  141. package/public/src/views/items.js.map +1 -1
  142. package/public/src/views/items.ts +196 -6
  143. package/public/src/views/normalize.js +2 -0
  144. package/public/src/views/normalize.js.map +1 -1
  145. package/public/src/views/normalize.ts +2 -0
  146. package/public/src/views/packages.js +67 -5
  147. package/public/src/views/packages.js.map +1 -1
  148. package/public/src/views/packages.ts +74 -5
  149. package/public/src/views/plan-execution.js +6 -0
  150. package/public/src/views/plan-execution.js.map +1 -1
  151. package/public/src/views/plan-execution.ts +8 -0
  152. package/public/src/views/plan.js +145 -0
  153. package/public/src/views/plan.js.map +1 -1
  154. package/public/src/views/plan.ts +146 -1
  155. package/public/src/views/projects.js +60 -0
  156. package/public/src/views/projects.js.map +1 -1
  157. package/public/src/views/projects.ts +60 -0
  158. package/public/src/views/router.js +43 -0
  159. package/public/src/views/router.js.map +1 -1
  160. package/public/src/views/router.ts +43 -0
  161. package/public/src/views/search.js +5 -0
  162. package/public/src/views/search.js.map +1 -1
  163. package/public/src/views/search.ts +5 -0
  164. package/public/src/views/settings.js +36 -0
  165. package/public/src/views/settings.js.map +1 -1
  166. package/public/src/views/settings.ts +36 -0
  167. package/public/src/views/shared.js +1 -0
  168. package/public/src/views/shared.js.map +1 -1
  169. package/public/src/views/shared.ts +1 -0
  170. package/public/src/views/sharing.js +32 -0
  171. package/public/src/views/sharing.js.map +1 -1
  172. package/public/src/views/sharing.ts +32 -0
  173. package/public/src/views/stats.js +1 -0
  174. package/public/src/views/stats.js.map +1 -1
  175. package/public/src/views/stats.ts +1 -0
  176. package/public/src/views/templates.js +4 -0
  177. package/public/src/views/templates.js.map +1 -1
  178. package/public/src/views/templates.ts +4 -0
  179. package/public/src/views/validate.js +1 -0
  180. package/public/src/views/validate.js.map +1 -1
  181. package/public/src/views/validate.ts +1 -0
  182. package/public/sw.js +60 -5
@@ -43,9 +43,11 @@ interface BulkClosePayload {
43
43
  reason: string;
44
44
  }
45
45
 
46
- // Mirror the active item filters into the URL query string (on the /items
47
- // path) so a filtered view is shareable and bookmarkable. Replaces the current
48
- // history entry to avoid polluting back/forward with every keystroke.
46
+ /**
47
+ * Mirrors the current item filters into the page URL query string when the
48
+ * items view is active, using history.replaceState so the filtered view is
49
+ * shareable without adding back/forward entries. Does nothing on other views.
50
+ */
49
51
  function syncFiltersToUrl(): void {
50
52
  if (state.currentView !== 'items') return;
51
53
  const qs = filtersToQueryString(state.itemFilters);
@@ -53,8 +55,12 @@ function syncFiltersToUrl(): void {
53
55
  try { history.replaceState(history.state, '', url); } catch { /* ignore */ }
54
56
  }
55
57
 
56
- // Read filters from the current URL's query string into state. Called when the
57
- // Items view is opened so a shared link applies its filters.
58
+ /**
59
+ * Reads item filters from the current URL query string into state. Only
60
+ * replaces the in-memory filters when the URL actually carries filter
61
+ * parameters, so navigating to /items without a query string preserves the
62
+ * existing filters.
63
+ */
58
64
  export function loadFiltersFromUrl(): void {
59
65
  const params = new URLSearchParams(window.location.search);
60
66
  // Only override when the URL actually carries filter params, so navigating
@@ -86,6 +92,12 @@ const DEP_REL_OPTIONS = [
86
92
  { value: 'related', label: 'Related' },
87
93
  ] as const;
88
94
 
95
+ /**
96
+ * Normalizes a raw dependency relationship label to a canonical value by
97
+ * trimming, lowercasing, and converting dashes to underscores, then applying a
98
+ * known-alias map (e.g. blockedby → blocked_by). Returns the alias when one
99
+ * exists, otherwise the normalized input unchanged.
100
+ */
89
101
  function normalizeDepRelation(raw?: string): string {
90
102
  const aliases: Record<string, string> = {
91
103
  blockedby: 'blocked_by',
@@ -107,6 +119,11 @@ function normalizeDepRelation(raw?: string): string {
107
119
  return aliases[normalized] ?? normalized;
108
120
  }
109
121
 
122
+ /**
123
+ * Builds the HTML <option> elements for a dependency relationship dropdown from
124
+ * the fixed DEP_REL_OPTIONS list, marking the option that matches the
125
+ * normalized selected value as selected.
126
+ */
110
127
  function renderDependencyOptions(selected?: string): string {
111
128
  const current = normalizeDepRelation(selected);
112
129
  return DEP_REL_OPTIONS
@@ -114,6 +131,11 @@ function renderDependencyOptions(selected?: string): string {
114
131
  .join('');
115
132
  }
116
133
 
134
+ /**
135
+ * Returns a human-readable display label for a dependency relationship by
136
+ * looking up its normalized value in a fixed label map, falling back to the
137
+ * original relationship string when no label is defined.
138
+ */
117
139
  function depLabel(rel: string): string {
118
140
  const labels: Record<string, string> = {
119
141
  blocked_by: 'Blocked by',
@@ -136,6 +158,11 @@ function depRelation(dep: RawDependency): string {
136
158
  // ═══════════════════════════════════════════════════════════════
137
159
  // BULK UPDATE
138
160
  // ═══════════════════════════════════════════════════════════════
161
+ /**
162
+ * Opens the Bulk Update Items modal, which lets the user filter items and
163
+ * choose fields to change across many items at once. Shows an info toast and
164
+ * returns early when no project is currently selected.
165
+ */
139
166
  export function showBulkUpdateModal(): void {
140
167
  if (!state.currentProject) { toast('Select a project first', 'info'); return; }
141
168
  createModal('bulk-update-modal', 'Bulk Update Items', `
@@ -208,6 +235,12 @@ export function showBulkUpdateModal(): void {
208
235
  showModal('bulk-update-modal');
209
236
  }
210
237
 
238
+ /**
239
+ * Runs a dry-run bulk update and renders a preview of the matched items and
240
+ * their field changes inside the modal. Requires at least one field to update,
241
+ * and stashes the actual (non-dry-run) payload on the Apply button so
242
+ * applyBulkUpdate can run without re-reading the form.
243
+ */
211
244
  export async function previewBulkUpdate(): Promise<void> {
212
245
  const pid = state.currentProject?.id;
213
246
  if (!pid) return;
@@ -281,6 +314,12 @@ export async function previewBulkUpdate(): Promise<void> {
281
314
  }
282
315
  }
283
316
 
317
+ /**
318
+ * Applies the bulk update payload stashed on the Apply button by
319
+ * previewBulkUpdate, posting to the update-many endpoint. Reports the updated
320
+ * and failed counts via toast, closes the bulk update modal, and refreshes the
321
+ * items list and project badge.
322
+ */
284
323
  export async function applyBulkUpdate(): Promise<void> {
285
324
  const pid = state.currentProject?.id;
286
325
  if (!pid) return;
@@ -309,6 +348,11 @@ export async function applyBulkUpdate(): Promise<void> {
309
348
  // ═══════════════════════════════════════════════════════════════
310
349
  // BULK CLOSE
311
350
  // ═══════════════════════════════════════════════════════════════
351
+ /**
352
+ * Opens the Bulk Close Items modal, which lets the user filter items and close
353
+ * or cancel many of them at once with a required reason. Shows an info toast
354
+ * and returns early when no project is currently selected.
355
+ */
312
356
  export function showBulkCloseModal(): void {
313
357
  if (!state.currentProject) { toast('Select a project first', 'info'); return; }
314
358
  createModal('bulk-close-modal', 'Bulk Close Items', `
@@ -364,6 +408,12 @@ export function showBulkCloseModal(): void {
364
408
  showModal('bulk-close-modal');
365
409
  }
366
410
 
411
+ /**
412
+ * Previews the items that would be closed or canceled by running an
413
+ * update-many dry-run, and renders the matched count and first few items
414
+ * inside the modal. Requires a close reason, and stashes the close payload on
415
+ * the Apply button for applyBulkClose to use.
416
+ */
367
417
  export async function previewBulkClose(): Promise<void> {
368
418
  const pid = state.currentProject?.id;
369
419
  if (!pid) return;
@@ -423,6 +473,13 @@ export async function previewBulkClose(): Promise<void> {
423
473
  }
424
474
  }
425
475
 
476
+ /**
477
+ * Applies the bulk close payload stashed on the Apply button by
478
+ * previewBulkClose, posting to the close-many endpoint which closes or cancels
479
+ * each matched item with the chosen reason. Reports the closed and failed
480
+ * counts via toast, closes the modal, and refreshes the items list and project
481
+ * badge.
482
+ */
426
483
  export async function applyBulkClose(): Promise<void> {
427
484
  const pid = state.currentProject?.id;
428
485
  if (!pid) return;
@@ -460,6 +517,12 @@ export async function applyBulkClose(): Promise<void> {
460
517
  }
461
518
  }
462
519
 
520
+ /**
521
+ * Renders the Items view into the page: a header with refresh and bulk
522
+ * actions, the status filter tabs, the multi-field filter bar, and a loading
523
+ * list placeholder, then fetches and renders the items. Shows an empty state
524
+ * and returns when no project is selected.
525
+ */
463
526
  export async function renderItemsView(): Promise<void> {
464
527
  const el = document.getElementById('content-items');
465
528
  if (!el) return;
@@ -512,6 +575,12 @@ export async function renderItemsView(): Promise<void> {
512
575
  await fetchAndRenderItems();
513
576
  }
514
577
 
578
+ /**
579
+ * Fetches items for the current project using the active filters (capped at
580
+ * 200, with tags filtered client-side because the list endpoint has no tag
581
+ * flag), stores them in state, updates the count subtitle, and renders the
582
+ * list. Shows an error state in the list element if the request fails.
583
+ */
515
584
  export async function fetchAndRenderItems(): Promise<void> {
516
585
  const pid = state.currentProject?.id;
517
586
  if (!pid) return;
@@ -545,6 +614,10 @@ export async function fetchAndRenderItems(): Promise<void> {
545
614
  }
546
615
  }
547
616
 
617
+ /**
618
+ * Renders the items list element from state.items, showing an empty-state
619
+ * placeholder when there are no items or the mapped item rows otherwise.
620
+ */
548
621
  function renderItemsList(): void {
549
622
  const el = document.getElementById('items-list');
550
623
  if (!el) return;
@@ -559,6 +632,11 @@ function renderItemsList(): void {
559
632
  el.innerHTML = `<div class="item-list">${state.items.map(item => renderItemRow(item)).join('')}</div>`;
560
633
  }
561
634
 
635
+ /**
636
+ * Returns the HTML markup for a single items-list row: a clickable card
637
+ * showing the item's type icon, id, title, tags, priority dot, and status
638
+ * badge that opens the item detail modal when clicked.
639
+ */
562
640
  export function renderItemRow(item: Item): string {
563
641
  const tags = (item.tags||[]).map(t=>`<span class="tag">${escHtml(t)}</span>`).join('');
564
642
  return `<div class="item-row" onclick="window.__app.openItemDetail('${escHtml(item.id)}')">
@@ -573,6 +651,11 @@ export function renderItemRow(item: Item): string {
573
651
  </div>`;
574
652
  }
575
653
 
654
+ /**
655
+ * Reads the current values from every items filter-bar control into
656
+ * state.itemFilters, mirrors them to the URL, and re-fetches and renders the
657
+ * list.
658
+ */
576
659
  export function applyItemFilters(): void {
577
660
  const fs = document.getElementById('filter-status') as HTMLSelectElement | null;
578
661
  const ft = document.getElementById('filter-type') as HTMLSelectElement | null;
@@ -592,6 +675,10 @@ export function applyItemFilters(): void {
592
675
  fetchAndRenderItems();
593
676
  }
594
677
 
678
+ /**
679
+ * Resets the item filters to empty, clears every filter-bar control in the
680
+ * DOM, mirrors the cleared filters to the URL, and re-renders the items list.
681
+ */
595
682
  export function clearFilters(): void {
596
683
  state.itemFilters = { ...EMPTY_FILTERS };
597
684
  const ids = ['filter-status','filter-type','filter-priority','filter-sprint','filter-release','filter-assignee','filter-tag'];
@@ -603,7 +690,11 @@ export function clearFilters(): void {
603
690
  fetchAndRenderItems();
604
691
  }
605
692
 
606
- // Copy a shareable URL for the current filtered Items view to the clipboard.
693
+ /**
694
+ * Builds a shareable URL for the current filtered items view and copies it to
695
+ * the clipboard, toasting success or, if the clipboard write fails, toasting
696
+ * the URL itself for manual copy.
697
+ */
607
698
  export async function copyFilterLink(): Promise<void> {
608
699
  const qs = filtersToQueryString(state.itemFilters);
609
700
  const url = `${window.location.origin}/items${qs ? `?${qs}` : ''}`;
@@ -615,6 +706,10 @@ export async function copyFilterLink(): Promise<void> {
615
706
  }
616
707
  }
617
708
 
709
+ /**
710
+ * Sets the status filter to the provided value, mirrors it to the URL, and
711
+ * re-renders the entire items view.
712
+ */
618
713
  export function setStatusFilter(status: string): void {
619
714
  state.itemFilters.status = status;
620
715
  syncFiltersToUrl();
@@ -624,6 +719,12 @@ export function setStatusFilter(status: string): void {
624
719
  // ═══════════════════════════════════════════════════════════════
625
720
  // ITEM DETAIL MODAL
626
721
  // ═══════════════════════════════════════════════════════════════
722
+ /**
723
+ * Opens the item detail modal and renders it. Fetches the item together with
724
+ * its comments, history, dependencies, learnings, notes, tests, and linked
725
+ * files in parallel (each detail call falls back to empty on failure) and
726
+ * builds the full tabbed detail view.
727
+ */
627
728
  export async function openItemDetail(itemId: string): Promise<void> {
628
729
  const pid = state.currentProject?.id;
629
730
  if (!pid) return;
@@ -966,6 +1067,11 @@ export async function openItemDetail(itemId: string): Promise<void> {
966
1067
  }
967
1068
  }
968
1069
 
1070
+ /**
1071
+ * Formats a timestamp as a human-readable relative age string (just now,
1072
+ * Nm/Nh/Nd ago) for recent times, or a localized date for anything older than
1073
+ * a week. Returns an empty string when the timestamp is missing.
1074
+ */
969
1075
  function relTime(ts: string | undefined | null): string {
970
1076
  if (!ts) return '';
971
1077
  const d = new Date(ts);
@@ -983,6 +1089,12 @@ function fmtDate(ts: string | undefined | null): string {
983
1089
  return new Date(ts).toLocaleDateString('en-US', { year:'numeric', month:'short', day:'numeric' });
984
1090
  }
985
1091
 
1092
+ /**
1093
+ * Switches the item detail modal to the selected tab: toggles the active class
1094
+ * on the tabs, shows the target tab panel while hiding the others, tears down
1095
+ * the local graph when leaving the graph tab, and initializes the graph along
1096
+ * with its depth-slider listener when entering the graph tab with a nodeId.
1097
+ */
986
1098
  export function switchDetailTab(tabEl: HTMLElement, targetId: string, nodeId?: string): void {
987
1099
  const allTabs = tabEl.parentElement?.querySelectorAll('.tab');
988
1100
  allTabs?.forEach(t => t.classList.remove('active'));
@@ -1008,6 +1120,11 @@ export function switchDetailTab(tabEl: HTMLElement, targetId: string, nodeId?: s
1008
1120
  }
1009
1121
  }
1010
1122
 
1123
+ /**
1124
+ * Posts the comment typed into the detail modal's comment box to the item,
1125
+ * requiring non-empty text. On success it clears the box, toasts confirmation,
1126
+ * and reopens the item detail to refresh the comments tab.
1127
+ */
1011
1128
  export async function addComment(itemId: string): Promise<void> {
1012
1129
  const el = document.getElementById('new-comment') as HTMLTextAreaElement | null;
1013
1130
  if (!el) return;
@@ -1021,6 +1138,10 @@ export async function addComment(itemId: string): Promise<void> {
1021
1138
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1022
1139
  }
1023
1140
 
1141
+ /**
1142
+ * Posts the note typed into the detail modal's note box to the item, requiring
1143
+ * non-empty text. On success it clears the box and toasts confirmation.
1144
+ */
1024
1145
  export async function addNote(itemId: string): Promise<void> {
1025
1146
  const el = document.getElementById('new-note') as HTMLTextAreaElement | null;
1026
1147
  if (!el) return;
@@ -1033,6 +1154,11 @@ export async function addNote(itemId: string): Promise<void> {
1033
1154
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1034
1155
  }
1035
1156
 
1157
+ /**
1158
+ * Appends the text from the detail modal's append box to the item's
1159
+ * description, requiring non-empty text. On success it clears the box and
1160
+ * toasts confirmation.
1161
+ */
1036
1162
  export async function appendItem(itemId: string): Promise<void> {
1037
1163
  const el = document.getElementById('new-append') as HTMLTextAreaElement | null;
1038
1164
  if (!el) return;
@@ -1045,6 +1171,11 @@ export async function appendItem(itemId: string): Promise<void> {
1045
1171
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1046
1172
  }
1047
1173
 
1174
+ /**
1175
+ * Saves the item detail's Update form: reads every edit field, requires a
1176
+ * title, and PATCHes the changed fields to the backend. On success it reopens
1177
+ * the item detail and refreshes the items list and project badge.
1178
+ */
1048
1179
  export async function updateItem(itemId: string): Promise<void> {
1049
1180
  const titleEl = document.getElementById('edit-title') as HTMLInputElement | null;
1050
1181
  const statusEl = document.getElementById('edit-status') as HTMLSelectElement | null;
@@ -1093,6 +1224,12 @@ export async function updateItem(itemId: string): Promise<void> {
1093
1224
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1094
1225
  }
1095
1226
 
1227
+ /**
1228
+ * Closes or cancels the item with a required reason. For canceled it first
1229
+ * sets the status to canceled via the update endpoint before posting the close
1230
+ * reason; for closed it posts the close reason directly. Reports the outcome,
1231
+ * hides the detail modal, and refreshes the items list and project badge.
1232
+ */
1096
1233
  export async function closeItem(itemId: string, targetStatus: string): Promise<void> {
1097
1234
  const reasonEl = document.getElementById('close-reason') as HTMLTextAreaElement | null;
1098
1235
  const reason = reasonEl?.value?.trim();
@@ -1111,6 +1248,10 @@ export async function closeItem(itemId: string, targetStatus: string): Promise<v
1111
1248
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1112
1249
  }
1113
1250
 
1251
+ /**
1252
+ * Shows a confirmation dialog and, on confirm, DELETEs the item, hides the
1253
+ * detail modal, and refreshes the items list and project badge.
1254
+ */
1114
1255
  export function confirmDeleteItem(itemId: string): void {
1115
1256
  confirmDialog('Delete Item?', 'This action cannot be undone. The item and all its data will be permanently removed.', async () => {
1116
1257
  try {
@@ -1126,6 +1267,12 @@ export function confirmDeleteItem(itemId: string): void {
1126
1267
  // ═══════════════════════════════════════════════════════════════
1127
1268
  // CLAIM / RELEASE / START / PAUSE
1128
1269
  // ═══════════════════════════════════════════════════════════════
1270
+ /**
1271
+ * Claims the item for the current user via the claim endpoint, dimming the
1272
+ * matching list row optimistically. On success it reopens the detail and
1273
+ * refreshes the list; on failure it restores the row opacity and toasts the
1274
+ * error.
1275
+ */
1129
1276
  export async function claimItem(itemId: string): Promise<void> {
1130
1277
  const row = document.querySelector(`.item-row[onclick*="${itemId}"]`) as HTMLElement | null;
1131
1278
  if (row) row.style.opacity = '0.6';
@@ -1137,6 +1284,10 @@ export async function claimItem(itemId: string): Promise<void> {
1137
1284
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); if (row) row.style.opacity = ''; }
1138
1285
  }
1139
1286
 
1287
+ /**
1288
+ * Releases the current user's claim on the item via the release endpoint, then
1289
+ * reopens the detail and refreshes the items list.
1290
+ */
1140
1291
  export async function releaseItem(itemId: string): Promise<void> {
1141
1292
  try {
1142
1293
  await api('POST',`/projects/${state.currentProject!.id}/pm/release/${itemId}`,{});
@@ -1146,6 +1297,10 @@ export async function releaseItem(itemId: string): Promise<void> {
1146
1297
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1147
1298
  }
1148
1299
 
1300
+ /**
1301
+ * Starts the item (moving it into in_progress) via the start-task endpoint,
1302
+ * then reopens the detail and refreshes the items list and project badge.
1303
+ */
1149
1304
  export async function startItem(itemId: string): Promise<void> {
1150
1305
  try {
1151
1306
  await api('POST',`/projects/${state.currentProject!.id}/pm/start-task/${itemId}`,{});
@@ -1156,6 +1311,10 @@ export async function startItem(itemId: string): Promise<void> {
1156
1311
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1157
1312
  }
1158
1313
 
1314
+ /**
1315
+ * Pauses the in-progress item via the pause-task endpoint, then reopens the
1316
+ * detail and refreshes the items list and project badge.
1317
+ */
1159
1318
  export async function pauseItem(itemId: string): Promise<void> {
1160
1319
  try {
1161
1320
  await api('POST',`/projects/${state.currentProject!.id}/pm/pause-task/${itemId}`,{});
@@ -1169,6 +1328,12 @@ export async function pauseItem(itemId: string): Promise<void> {
1169
1328
  // ═══════════════════════════════════════════════════════════════
1170
1329
  // DEPS / LEARNINGS / TESTS / FILES
1171
1330
  // ═══════════════════════════════════════════════════════════════
1331
+ /**
1332
+ * Adds a dependency to the item from the detail modal's dependency inputs:
1333
+ * reads and normalizes the target id and relationship, rejects an empty or
1334
+ * self-referential target, and posts to the deps endpoint. On success it
1335
+ * reopens the item detail.
1336
+ */
1172
1337
  export async function addDep(itemId: string): Promise<void> {
1173
1338
  const targetIdEl = document.getElementById('dep-target-id') as HTMLInputElement | null;
1174
1339
  const relEl = document.getElementById('dep-rel') as HTMLSelectElement | null;
@@ -1186,6 +1351,11 @@ export async function addDep(itemId: string): Promise<void> {
1186
1351
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1187
1352
  }
1188
1353
 
1354
+ /**
1355
+ * Removes a dependency from the item after confirming with the user. Normalizes
1356
+ * the relationship, requires a target id, and DELETEs the dependency from the
1357
+ * deps endpoint; on success it reopens the item detail.
1358
+ */
1189
1359
  export async function removeDep(itemId: string, targetId: string, relation: string): Promise<void> {
1190
1360
  const rel = normalizeDepRelation(relation);
1191
1361
  if (!targetId) { toast('Target item ID is required','error'); return; }
@@ -1203,6 +1373,11 @@ export async function removeDep(itemId: string, targetId: string, relation: stri
1203
1373
  );
1204
1374
  }
1205
1375
 
1376
+ /**
1377
+ * Records a learning typed into the detail modal on the item, requiring
1378
+ * non-empty text. On success it toasts confirmation and reopens the item
1379
+ * detail.
1380
+ */
1206
1381
  export async function addLearning(itemId: string): Promise<void> {
1207
1382
  const el = document.getElementById('new-learning') as HTMLTextAreaElement | null;
1208
1383
  const text = el?.value?.trim() || '';
@@ -1214,6 +1389,11 @@ export async function addLearning(itemId: string): Promise<void> {
1214
1389
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1215
1390
  }
1216
1391
 
1392
+ /**
1393
+ * Adds a test command (required) with an optional description to the item via
1394
+ * the tests endpoint. On success it toasts confirmation and reopens the item
1395
+ * detail.
1396
+ */
1217
1397
  export async function addTest(itemId: string): Promise<void> {
1218
1398
  const cmdEl = document.getElementById('new-test-cmd') as HTMLInputElement | null;
1219
1399
  const descEl = document.getElementById('new-test-desc') as HTMLInputElement | null;
@@ -1227,6 +1407,10 @@ export async function addTest(itemId: string): Promise<void> {
1227
1407
  } catch(err: unknown) { toast(err instanceof Error ? err.message : String(err),'error'); }
1228
1408
  }
1229
1409
 
1410
+ /**
1411
+ * Links a file path (required) to the item via the files endpoint. On success
1412
+ * it toasts confirmation and reopens the item detail.
1413
+ */
1230
1414
  export async function addFileLink(itemId: string): Promise<void> {
1231
1415
  const el = document.getElementById('file-path-input') as HTMLInputElement | null;
1232
1416
  const filePath = el?.value?.trim() || '';
@@ -1241,6 +1425,12 @@ export async function addFileLink(itemId: string): Promise<void> {
1241
1425
  // ═══════════════════════════════════════════════════════════════
1242
1426
  // USE ITEM AS TEMPLATE
1243
1427
  // ═══════════════════════════════════════════════════════════════
1428
+ /**
1429
+ * Closes the item detail modal and opens the create view pre-filled from the
1430
+ * given item's fields, prefixing the title with "Copy of" and focusing and
1431
+ * selecting it so the user can immediately edit. The form is filled after a
1432
+ * short delay to let the create view render.
1433
+ */
1244
1434
  export function useItemAsTemplate(item: Record<string, unknown>): void {
1245
1435
  // Close the item detail modal and navigate to create view
1246
1436
  hideModal('item-detail-modal');
@@ -5,6 +5,7 @@ import { state } from '../state.js';
5
5
  import { api } from '../api.js';
6
6
  import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
+ /** Renders a dry-run preview of lifecycle metadata normalization for the current project, listing each suggested change with an item link and an apply button that only advises using the CLI. */
8
9
  export async function renderNormalizeView() {
9
10
  const el = document.getElementById('content-normalize');
10
11
  if (!el)
@@ -59,6 +60,7 @@ export async function renderNormalizeView() {
59
60
  el2.innerHTML = `<div class="empty-state"><div class="empty-state-text">Error: ${escHtml(err instanceof Error ? err.message : String(err))}</div></div>`;
60
61
  }
61
62
  }
63
+ /** Notifies the user that normalization in the web UI is a dry-run and that applying changes requires the CLI with the --apply flag. */
62
64
  export function applyNormalize() {
63
65
  if (!state.currentProject)
64
66
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"normalize.js","sourceRoot":"","sources":["normalize.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,iBAAiB;AACjB,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAChC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAG/C,MAAM,CAAC,KAAK,UAAU,mBAAmB;IACvC,MAAM,EAAE,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;IACxD,IAAI,CAAC,EAAE;QAAE,OAAO;IAChB,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;QAAC,EAAE,CAAC,SAAS,GAAG,wFAAwF,CAAC;QAAC,OAAO;IAAC,CAAC;IAC/I,EAAE,CAAC,SAAS,GAAG;;oHAEmG,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC;;;2GAG3C,CAAC;IAC1G,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAoB,MAAM,EAAE,aAAa,KAAK,CAAC,cAAc,CAAC,EAAE,eAAe,CAAC,CAAC;QACvG,MAAM,IAAI,GAAkB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC;QACpE,MAAM,KAAK,GAAsB,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC;QAClE,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;QACzD,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/C,GAAG,CAAC,SAAS,GAAG,mKAAmK,CAAC;QACtL,CAAC;aAAM,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpD,GAAG,CAAC,SAAS,GAAG;;gFAE0D,KAAK,CAAC,MAAM;;cAE9E,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;;;;gDAIiB,OAAO,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;oBACpF,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,wHAAwH,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,gDAAgD,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE;;qBAEzN,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;;;;;;eAMhB,CAAC;QACZ,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,SAAS,GAAG,yHAAyH,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,oBAAoB,CAAC;QACtM,CAAC;IACH,CAAC;IAAC,OAAM,GAAY,EAAE,CAAC;QACrB,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;QACzD,IAAI,GAAG;YAAE,GAAG,CAAC,SAAS,GAAG,iEAAiE,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,cAAc,CAAC;IACpK,CAAC;AACH,CAAC;AAED,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC,KAAK,CAAC,cAAc;QAAE,OAAO;IAClC,KAAK,CAAC,wEAAwE,EAAE,MAAM,CAAC,CAAC;AAC1F,CAAC"}
1
+ {"version":3,"file":"normalize.js","sourceRoot":"","sources":["normalize.ts"],"names":[],"mappings":"AAAA,kEAAkE;AAClE,iBAAiB;AACjB,kEAAkE;AAClE,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAChC,OAAO,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AACtC,OAAO,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAC;AAG/C,kMAAkM;AAClM,MAAM,CAAC,KAAK,UAAU,mBAAmB;IACvC,MAAM,EAAE,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;IACxD,IAAI,CAAC,EAAE;QAAE,OAAO;IAChB,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;QAAC,EAAE,CAAC,SAAS,GAAG,wFAAwF,CAAC;QAAC,OAAO;IAAC,CAAC;IAC/I,EAAE,CAAC,SAAS,GAAG;;oHAEmG,OAAO,CAAC,KAAK,CAAC,cAAc,CAAC,IAAI,CAAC;;;2GAG3C,CAAC;IAC1G,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,GAAG,CAAoB,MAAM,EAAE,aAAa,KAAK,CAAC,cAAc,CAAC,EAAE,eAAe,CAAC,CAAC;QACvG,MAAM,IAAI,GAAkB,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,aAAa,IAAI,IAAI,CAAC;QACpE,MAAM,KAAK,GAAsB,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC;QAClE,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;QACzD,IAAI,CAAC,GAAG;YAAE,OAAO;QACjB,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC/C,GAAG,CAAC,SAAS,GAAG,mKAAmK,CAAC;QACtL,CAAC;aAAM,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpD,GAAG,CAAC,SAAS,GAAG;;gFAE0D,KAAK,CAAC,MAAM;;cAE9E,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;;;;gDAIiB,OAAO,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,WAAW,IAAI,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;oBACpF,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,wHAAwH,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,gDAAgD,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE;;qBAEzN,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;;;;;;eAMhB,CAAC;QACZ,CAAC;aAAM,CAAC;YACN,GAAG,CAAC,SAAS,GAAG,yHAAyH,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,oBAAoB,CAAC;QACtM,CAAC;IACH,CAAC;IAAC,OAAM,GAAY,EAAE,CAAC;QACrB,MAAM,GAAG,GAAG,QAAQ,CAAC,cAAc,CAAC,mBAAmB,CAAC,CAAC;QACzD,IAAI,GAAG;YAAE,GAAG,CAAC,SAAS,GAAG,iEAAiE,OAAO,CAAC,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,cAAc,CAAC;IACpK,CAAC;AACH,CAAC;AAED,wIAAwI;AACxI,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC,KAAK,CAAC,cAAc;QAAE,OAAO;IAClC,KAAK,CAAC,wEAAwE,EAAE,MAAM,CAAC,CAAC;AAC1F,CAAC"}
@@ -7,6 +7,7 @@ import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
8
  import type { NormalizeChange, NormalizePlan, NormalizeResponse } from '../api-types.js';
9
9
 
10
+ /** Renders a dry-run preview of lifecycle metadata normalization for the current project, listing each suggested change with an item link and an apply button that only advises using the CLI. */
10
11
  export async function renderNormalizeView(): Promise<void> {
11
12
  const el = document.getElementById('content-normalize');
12
13
  if (!el) return;
@@ -53,6 +54,7 @@ export async function renderNormalizeView(): Promise<void> {
53
54
  }
54
55
  }
55
56
 
57
+ /** Notifies the user that normalization in the web UI is a dry-run and that applying changes requires the CLI with the --apply flag. */
56
58
  export function applyNormalize(): void {
57
59
  if (!state.currentProject) return;
58
60
  toast('Normalization applied (dry-run — use CLI with --apply to make changes)', 'info');
@@ -30,6 +30,12 @@ let currentPackages = [];
30
30
  * own response if a newer fetch has begun since.
31
31
  */
32
32
  let packagesFetchToken = 0;
33
+ /**
34
+ * Mounts the packages view into the #content-packages element. Wires the
35
+ * action handler, shows an empty state when no project is selected, otherwise
36
+ * renders the header and a loading placeholder, then fetches and renders the
37
+ * package cards.
38
+ */
33
39
  export async function renderPackagesView() {
34
40
  ensureActionsWired();
35
41
  const el = document.getElementById('content-packages');
@@ -52,6 +58,12 @@ export async function renderPackagesView() {
52
58
  <div id="packages-content"><div class="loading-state"><div class="loading-spinner"></div></div></div>`;
53
59
  await fetchAndRenderPackages();
54
60
  }
61
+ /**
62
+ * Fetches the project's package catalog from the extensions endpoint and
63
+ * renders the package cards into the content container, or an empty/error
64
+ * state. Uses a fetch token to discard stale responses that resolve after a
65
+ * project switch or a newer refresh.
66
+ */
55
67
  async function fetchAndRenderPackages() {
56
68
  const pid = state.currentProject?.id;
57
69
  if (!pid)
@@ -85,6 +97,14 @@ async function fetchAndRenderPackages() {
85
97
  target.innerHTML = `<div class="empty-state"><div class="empty-state-text">${escHtml(t('packages.loadError', { error: msg }))}</div></div>`;
86
98
  }
87
99
  }
100
+ /**
101
+ * Renders one package as a card showing its title, install status and
102
+ * version, a template badge for authoring scaffolds, honest gating notes for
103
+ * required services or credentials, its capabilities, and action buttons
104
+ * appropriate to its installed/active state.
105
+ * @param row - The package catalog row to render.
106
+ * @returns The card markup string.
107
+ */
88
108
  function renderPackageCard(row) {
89
109
  const statusChip = row.installed
90
110
  ? `<span style="font-size:11px;color:var(--text-muted);background:var(--bg-input);padding:2px 8px;border-radius:4px">${escHtml(t('packages.installed'))}${row.version ? ' · ' + escHtml(t('packages.version', { version: row.version })) : ''}</span>`
@@ -95,6 +115,19 @@ function renderPackageCard(row) {
95
115
  const templateBadge = row.category === 'template'
96
116
  ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-accent);padding:2px 8px;border-radius:4px" data-i18n="packages.templateBadge">${escHtml(t('packages.templateBadge'))}</span>`
97
117
  : '';
118
+ // A package with no published release is shown, because hiding it would make
119
+ // the catalog look complete while the fleet plainly contains it — but it is
120
+ // shown as unavailable, never with an install action that cannot succeed.
121
+ const unreleased = row.availability === 'unreleased';
122
+ const unreleasedBadge = unreleased
123
+ ? `<span style="font-size:11px;color:var(--text-secondary);background:var(--bg-accent);padding:2px 8px;border-radius:4px" data-i18n="packages.unreleasedBadge">${escHtml(t('packages.unreleasedBadge'))}</span>`
124
+ : '';
125
+ // The explanation is about not being installable, so it only applies while
126
+ // the package is not installed. Showing it beside an installed package would
127
+ // contradict the card's own status chip.
128
+ const unreleasedNote = unreleased && !row.installed
129
+ ? `<div class="pkg-req" style="font-size:12px;color:var(--text-secondary);margin-top:6px">🚧 ${escHtml(t('packages.unreleased'))}</div>`
130
+ : '';
98
131
  // Honest gating explanations — the UI must not promise a one-click install
99
132
  // for a package that needs a Neo4j instance or an API token.
100
133
  const serviceNote = row.requiresService
@@ -114,7 +147,15 @@ function renderPackageCard(row) {
114
147
  // a raw DOM string — and the server validates it against the catalog again
115
148
  // before any pm command is spawned.
116
149
  let actions = '';
117
- if (!row.installed) {
150
+ if (unreleased && !row.installed) {
151
+ // Nothing to install: there is no published version to install from. An
152
+ // unreleased package that IS installed — from a local path or a
153
+ // preexisting install — keeps its full management controls below, because
154
+ // the server supports deactivating and removing it and hiding those would
155
+ // strand the user with a package they cannot get rid of.
156
+ actions = '';
157
+ }
158
+ else if (!row.installed) {
118
159
  actions = `<button class="btn btn-primary btn-sm" style="width:100%" data-pkg-action="install" data-pkg-name="${escHtml(row.name)}">${escHtml(t('packages.install'))}</button>`;
119
160
  }
120
161
  else if (row.active && row.enabled) {
@@ -136,20 +177,27 @@ function renderPackageCard(row) {
136
177
  <div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px">
137
178
  <div style="display:flex;align-items:center;gap:6px">
138
179
  <div class="card-title">${escHtml(row.title)}</div>
139
- ${templateBadge}
180
+ ${templateBadge}${unreleasedBadge}
140
181
  </div>
141
- ${statusChip}
182
+ ${unreleased && !row.installed ? '' : statusChip}
142
183
  </div>
143
184
  <div class="card-body" style="padding-top:0">
144
185
  <div style="font-size:12px;color:var(--text-muted);margin-bottom:8px;line-height:1.4">${escHtml(row.description)}</div>
145
- ${serviceNote}${credNotes}${caps}
146
- <div style="margin-top:12px">${actions}</div>
186
+ ${unreleasedNote}${serviceNote}${credNotes}${caps}
187
+ ${actions ? `<div style="margin-top:12px">${actions}</div>` : ''}
147
188
  </div>
148
189
  </div>`;
149
190
  }
150
191
  // Delegate clicks on package action buttons so each card stays simple and the
151
192
  // handler resolves the action + name from data attributes, then calls the
152
193
  // server-validated extensions route.
194
+ /**
195
+ * Installs a delegated click handler on the given container that routes
196
+ * package action button clicks (install/activate/deactivate/uninstall) to the
197
+ * package action handler, reading the action and package name from data
198
+ * attributes.
199
+ * @param root - The container element capturing the clicks.
200
+ */
153
201
  function wirePackageActions(root) {
154
202
  root.addEventListener('click', (ev) => {
155
203
  const btn = ev.target.closest('[data-pkg-action]');
@@ -162,6 +210,16 @@ function wirePackageActions(root) {
162
210
  void handlePackageAction(name, action, btn);
163
211
  });
164
212
  }
213
+ /**
214
+ * Performs a package action against the extensions route for the current
215
+ * project. Disables and marks the clicked button while in flight, confirms
216
+ * before uninstalling, picks the HTTP method and URL suffix from the action,
217
+ * toasts the result, and re-renders the package list. Restores the button on
218
+ * failure.
219
+ * @param name - The catalog name of the target package.
220
+ * @param action - One of install, activate, deactivate, or uninstall.
221
+ * @param btn - The button element that triggered the action.
222
+ */
165
223
  async function handlePackageAction(name, action, btn) {
166
224
  const pid = state.currentProject?.id;
167
225
  if (!pid)
@@ -198,6 +256,10 @@ async function handlePackageAction(name, action, btn) {
198
256
  }
199
257
  // Wire the delegated action handler once the view container exists.
200
258
  let actionsWired = false;
259
+ /**
260
+ * Wires the delegated package action handler onto the #content-packages
261
+ * container exactly once, guarded by the actionsWired flag.
262
+ */
201
263
  function ensureActionsWired() {
202
264
  if (actionsWired)
203
265
  return;