@unbrained/pm-web 2026.9.18 → 2026.9.23

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 (126) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -4
  3. package/dist/app.d.ts +10 -0
  4. package/dist/app.js +19 -9
  5. package/dist/app.js.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/routes/admin.js +6 -6
  8. package/dist/routes/admin.js.map +1 -1
  9. package/dist/routes/auth.js +2 -2
  10. package/dist/routes/auth.js.map +1 -1
  11. package/dist/routes/extensions.js +24 -37
  12. package/dist/routes/extensions.js.map +1 -1
  13. package/dist/routes/github.js +169 -107
  14. package/dist/routes/github.js.map +1 -1
  15. package/dist/routes/groups.js +9 -14
  16. package/dist/routes/groups.js.map +1 -1
  17. package/dist/routes/pm.js +532 -1015
  18. package/dist/routes/pm.js.map +1 -1
  19. package/dist/routes/projects.js +10 -6
  20. package/dist/routes/projects.js.map +1 -1
  21. package/dist/routes/route-helpers.d.ts +74 -0
  22. package/dist/routes/route-helpers.js +85 -0
  23. package/dist/routes/route-helpers.js.map +1 -0
  24. package/dist/routes/sharing.js +30 -18
  25. package/dist/routes/sharing.js.map +1 -1
  26. package/dist/server.js +1 -20
  27. package/dist/server.js.map +1 -1
  28. package/dist/services/mutation-event-watcher.js +3 -28
  29. package/dist/services/mutation-event-watcher.js.map +1 -1
  30. package/dist/services/pm-runner.js +3 -1
  31. package/dist/services/pm-runner.js.map +1 -1
  32. package/dist/services/project-watcher.js +14 -38
  33. package/dist/services/project-watcher.js.map +1 -1
  34. package/dist/services/watcher-utils.d.ts +42 -0
  35. package/dist/services/watcher-utils.js +60 -0
  36. package/dist/services/watcher-utils.js.map +1 -0
  37. package/manifest.json +2 -2
  38. package/package.json +17 -6
  39. package/public/src/app.js +22 -52
  40. package/public/src/app.js.map +1 -1
  41. package/public/src/app.ts +18 -75
  42. package/public/src/browser-window.js +29 -0
  43. package/public/src/browser-window.js.map +1 -0
  44. package/public/src/browser-window.ts +56 -0
  45. package/public/src/components/modals.js +1 -1
  46. package/public/src/components/modals.js.map +1 -1
  47. package/public/src/components/modals.ts +1 -1
  48. package/public/src/i18n.js +14 -6
  49. package/public/src/i18n.js.map +1 -1
  50. package/public/src/i18n.ts +16 -6
  51. package/public/src/sw.ts +1 -0
  52. package/public/src/utils.js +58 -0
  53. package/public/src/utils.js.map +1 -1
  54. package/public/src/utils.ts +53 -0
  55. package/public/src/views/admin.js +2 -5
  56. package/public/src/views/admin.js.map +1 -1
  57. package/public/src/views/admin.ts +2 -2
  58. package/public/src/views/auth.js +5 -1
  59. package/public/src/views/auth.js.map +1 -1
  60. package/public/src/views/auth.ts +6 -1
  61. package/public/src/views/calendar.js +1 -2
  62. package/public/src/views/calendar.js.map +1 -1
  63. package/public/src/views/calendar.ts +1 -2
  64. package/public/src/views/config.js +14 -21
  65. package/public/src/views/config.js.map +1 -1
  66. package/public/src/views/config.ts +14 -19
  67. package/public/src/views/context.js +11 -12
  68. package/public/src/views/context.js.map +1 -1
  69. package/public/src/views/context.ts +13 -12
  70. package/public/src/views/create.js +3 -11
  71. package/public/src/views/create.js.map +1 -1
  72. package/public/src/views/create.ts +3 -3
  73. package/public/src/views/export.js +2 -3
  74. package/public/src/views/export.js.map +1 -1
  75. package/public/src/views/export.ts +2 -3
  76. package/public/src/views/github.js +33 -47
  77. package/public/src/views/github.js.map +1 -1
  78. package/public/src/views/github.ts +35 -28
  79. package/public/src/views/graph-canvas.js +92 -99
  80. package/public/src/views/graph-canvas.js.map +1 -1
  81. package/public/src/views/graph-canvas.ts +94 -93
  82. package/public/src/views/graph.js +88 -73
  83. package/public/src/views/graph.js.map +1 -1
  84. package/public/src/views/graph.ts +97 -63
  85. package/public/src/views/groups.js +2 -5
  86. package/public/src/views/groups.js.map +1 -1
  87. package/public/src/views/groups.ts +2 -2
  88. package/public/src/views/guide.js +11 -10
  89. package/public/src/views/guide.js.map +1 -1
  90. package/public/src/views/guide.ts +12 -10
  91. package/public/src/views/items.js +100 -130
  92. package/public/src/views/items.js.map +1 -1
  93. package/public/src/views/items.ts +99 -117
  94. package/public/src/views/normalize.js +2 -9
  95. package/public/src/views/normalize.js.map +1 -1
  96. package/public/src/views/normalize.ts +2 -9
  97. package/public/src/views/packages.js +7 -4
  98. package/public/src/views/packages.js.map +1 -1
  99. package/public/src/views/packages.ts +8 -4
  100. package/public/src/views/plan-execution.js.map +1 -1
  101. package/public/src/views/plan-execution.ts +5 -15
  102. package/public/src/views/plan.js +2 -1
  103. package/public/src/views/plan.js.map +1 -1
  104. package/public/src/views/plan.ts +6 -17
  105. package/public/src/views/projects.js +5 -6
  106. package/public/src/views/projects.js.map +1 -1
  107. package/public/src/views/projects.ts +5 -6
  108. package/public/src/views/search.js +5 -1
  109. package/public/src/views/search.js.map +1 -1
  110. package/public/src/views/search.ts +7 -1
  111. package/public/src/views/settings.js +30 -55
  112. package/public/src/views/settings.js.map +1 -1
  113. package/public/src/views/settings.ts +23 -17
  114. package/public/src/views/shared.js +2 -5
  115. package/public/src/views/shared.js.map +1 -1
  116. package/public/src/views/shared.ts +2 -5
  117. package/public/src/views/sharing.js +2 -5
  118. package/public/src/views/sharing.js.map +1 -1
  119. package/public/src/views/sharing.ts +2 -2
  120. package/public/src/views/templates.js +9 -16
  121. package/public/src/views/templates.js.map +1 -1
  122. package/public/src/views/templates.ts +9 -14
  123. package/public/src/views/validate.js +2 -9
  124. package/public/src/views/validate.js.map +1 -1
  125. package/public/src/views/validate.ts +2 -9
  126. package/public/sw.js +1 -0
@@ -230,6 +230,12 @@ function escHtml(s: string): string {
230
230
  .replace(/"/g, '"');
231
231
  }
232
232
 
233
+ /** Builds a tag-to-color map from a tag frequency map, assigning palette colors to the most frequent tags in descending order. */
234
+ export function tagColorMapFromFreq(freq: Map<string, number>): Map<string, string> {
235
+ const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE.length).map(([t]) => t);
236
+ return new Map(top.map((t, i) => [t, TAG_PALETTE[i]]));
237
+ }
238
+
233
239
  // ═══════════════════════════════════════════════════════════════
234
240
  // GraphCanvas class
235
241
  // ═══════════════════════════════════════════════════════════════
@@ -682,6 +688,23 @@ export class GraphCanvas {
682
688
  return a.source.id === b.source.id && a.target.id === b.target.id && a.type === b.type;
683
689
  }
684
690
 
691
+ /** Returns the bidirectional edge key, flag, and curve direction for the given source/target node pair. */
692
+ private edgeBidirInfo(s: SimNode, t: SimNode): { key: string; isBiDir: boolean; curveDir: number } {
693
+ const key = [s.id, t.id].sort().join('|');
694
+ const isBiDir = this.biDirPairs.has(key);
695
+ const curveDir = isBiDir ? (s.id < t.id ? 1 : -1) : 0;
696
+ return { key, isBiDir, curveDir };
697
+ }
698
+
699
+ /** Computes the start and end points of an edge segment, offset from the node radii along the unit direction vector. */
700
+ private edgeEndpoints(s: SimNode, t: SimNode, nx: number, ny: number): { x1: number; y1: number; x2: number; y2: number } {
701
+ const x1 = s.x + nx * s.r;
702
+ const y1 = s.y + ny * s.r;
703
+ const x2 = t.x - nx * (t.r + 7);
704
+ const y2 = t.y - ny * (t.r + 7);
705
+ return { x1, y1, x2, y2 };
706
+ }
707
+
685
708
  private buildEdgeGeometry(edge: SimEdge): EdgeGeometry {
686
709
  const { source: s, target: t } = edge;
687
710
 
@@ -693,17 +716,12 @@ export class GraphCanvas {
693
716
  const px = -dy / len;
694
717
  const py = dx / len;
695
718
 
696
- const key = [s.id, t.id].sort().join('|');
697
- const isBiDir = this.biDirPairs.has(key);
698
- const curveDir = isBiDir ? (s.id < t.id ? 1 : -1) : 0;
719
+ const { key, isBiDir, curveDir } = this.edgeBidirInfo(s, t);
699
720
  const cpOffset = isBiDir ? len * 0.22 * curveDir : 0;
700
721
  const cpX = (s.x + t.x) / 2 + px * cpOffset;
701
722
  const cpY = (s.y + t.y) / 2 + py * cpOffset;
702
723
 
703
- const x1 = s.x + nx * s.r;
704
- const y1 = s.y + ny * s.r;
705
- const x2 = t.x - nx * (t.r + 7);
706
- const y2 = t.y - ny * (t.r + 7);
724
+ const { x1, y1, x2, y2 } = this.edgeEndpoints(s, t, nx, ny);
707
725
 
708
726
  const qX = (u: number): number => (1 - u) * (1 - u) * x1 + 2 * (1 - u) * u * cpX + u * u * x2;
709
727
  const qY = (u: number): number => (1 - u) * (1 - u) * y1 + 2 * (1 - u) * u * cpY + u * u * y2;
@@ -839,8 +857,7 @@ export class GraphCanvas {
839
857
  for (const n of this.nodes) {
840
858
  for (const t of n.tags ?? []) freq.set(t, (freq.get(t) ?? 0) + 1);
841
859
  }
842
- const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE.length).map(([t]) => t);
843
- this.tagColorMap = new Map(top.map((t, i) => [t, TAG_PALETTE[i]]));
860
+ this.tagColorMap = tagColorMapFromFreq(freq);
844
861
  } else {
845
862
  this.tagColorMap = new Map();
846
863
  }
@@ -1474,9 +1491,7 @@ export class GraphCanvas {
1474
1491
  const py = dx / len;
1475
1492
 
1476
1493
  // Determine curvature direction for bidirectional edges
1477
- const key = [s.id, t.id].sort().join('|');
1478
- const isBiDir = this.biDirPairs.has(key);
1479
- const curveDir = isBiDir ? (s.id < t.id ? 1 : -1) : 0;
1494
+ const { isBiDir, curveDir } = this.edgeBidirInfo(s, t);
1480
1495
  const curvature = isBiDir ? 0.22 : 0.0;
1481
1496
  const cpFactor = len * curvature * curveDir;
1482
1497
 
@@ -1487,10 +1502,7 @@ export class GraphCanvas {
1487
1502
  // Start/end points offset from node radii
1488
1503
  const nx = dx / len;
1489
1504
  const ny = dy / len;
1490
- const x1 = s.x + nx * s.r;
1491
- const y1 = s.y + ny * s.r;
1492
- const x2 = t.x - nx * (t.r + 7);
1493
- const y2 = t.y - ny * (t.r + 7);
1505
+ const { x1, y1, x2, y2 } = this.edgeEndpoints(s, t, nx, ny);
1494
1506
 
1495
1507
  const color = onCritPath ? '#fbbf24' : getEdgeColor(edge.type);
1496
1508
  const isActive = highlighted || onCritPath;
@@ -2020,14 +2032,11 @@ export class GraphCanvas {
2020
2032
  }
2021
2033
  }
2022
2034
 
2023
- private onMouseDown(e: MouseEvent): void {
2024
- if (e.button !== 0) return;
2025
- this.canvas.focus();
2026
- const { x, y } = this.getPos(e);
2035
+ /** Initiates a pointer drag at the given canvas coordinates: records the start position, performs a hit test, and begins either a node drag or canvas pan. */
2036
+ private beginPointerDrag(x: number, y: number, setCursor: boolean): void {
2027
2037
  this.lastX = x; this.lastY = y;
2028
2038
  this.downX = x; this.downY = y;
2029
2039
  this.hasMoved = false;
2030
-
2031
2040
  const [wx, wy] = this.toWorld(x, y);
2032
2041
  const hit = this.hitTest(wx, wy);
2033
2042
  if (hit) {
@@ -2036,32 +2045,72 @@ export class GraphCanvas {
2036
2045
  hit.fx = hit.x;
2037
2046
  hit.fy = hit.y;
2038
2047
  this.alpha = Math.max(this.alpha, 0.25);
2039
- this.canvas.style.cursor = 'grabbing';
2048
+ if (setCursor) this.canvas.style.cursor = 'grabbing';
2040
2049
  } else {
2041
2050
  this.isDraggingCanvas = true;
2042
- this.canvas.style.cursor = 'grabbing';
2051
+ if (setCursor) this.canvas.style.cursor = 'grabbing';
2043
2052
  }
2044
2053
  }
2045
2054
 
2046
- private onMouseMove(e: MouseEvent): void {
2047
- const r = this.canvas.getBoundingClientRect();
2048
- const x = e.clientX - r.left;
2049
- const y = e.clientY - r.top;
2050
- const dx = x - this.lastX;
2051
- const dy = y - this.lastY;
2052
-
2053
- if (Math.abs(x - this.downX) > 3 || Math.abs(y - this.downY) > 3) this.hasMoved = true;
2055
+ /** Returns the canvas-relative coordinates of the first touch, or null when there is not exactly one active touch. */
2056
+ private firstTouchPos(e: TouchEvent): { x: number; y: number } | null {
2057
+ if (e.touches.length !== 1) return null;
2058
+ const rect = this.canvas.getBoundingClientRect();
2059
+ return { x: e.touches[0].clientX - rect.left, y: e.touches[0].clientY - rect.top };
2060
+ }
2054
2061
 
2062
+ /** Handles a pointer move during an active drag: updates hasMoved, drags the grabbed node or pans the canvas, and returns true when a drag consumed the event. */
2063
+ private handleDragMove(x: number, y: number, dx: number, dy: number, moveThreshold: number, clearFlyOnNode: boolean): boolean {
2064
+ if (Math.abs(x - this.downX) > moveThreshold || Math.abs(y - this.downY) > moveThreshold) this.hasMoved = true;
2055
2065
  if (this.isDraggingNode && this.dragNode) {
2056
2066
  const [wx, wy] = this.toWorld(x, y);
2057
2067
  this.dragNode.fx = wx;
2058
2068
  this.dragNode.fy = wy;
2059
- this.flyTarget = null;
2060
- } else if (this.isDraggingCanvas) {
2069
+ if (clearFlyOnNode) this.flyTarget = null;
2070
+ return true;
2071
+ }
2072
+ if (this.isDraggingCanvas) {
2061
2073
  this.tx += dx;
2062
2074
  this.ty += dy;
2063
2075
  this.flyTarget = null;
2064
- } else {
2076
+ return true;
2077
+ }
2078
+ return false;
2079
+ }
2080
+
2081
+ /** Ends a node drag gesture: if the pointer did not move, toggles selection and flies to the node, then clears the drag state. Returns true when a node drag was in progress. */
2082
+ private endNodeDrag(): boolean {
2083
+ if (!this.isDraggingNode || !this.dragNode) return false;
2084
+ if (!this.hasMoved) {
2085
+ const id = this.dragNode.id;
2086
+ const newSel = this.filter.selectedId === id ? null : id;
2087
+ this.filter = { ...this.filter, selectedId: newSel };
2088
+ this.particles = [];
2089
+ this.lastParticleSpawn = 0;
2090
+ this.onSelectNode(newSel);
2091
+ if (newSel) this.flyTo(this.dragNode);
2092
+ }
2093
+ this.dragNode.fx = null;
2094
+ this.dragNode.fy = null;
2095
+ this.dragNode = null;
2096
+ return true;
2097
+ }
2098
+
2099
+ private onMouseDown(e: MouseEvent): void {
2100
+ if (e.button !== 0) return;
2101
+ this.canvas.focus();
2102
+ const { x, y } = this.getPos(e);
2103
+ this.beginPointerDrag(x, y, true);
2104
+ }
2105
+
2106
+ private onMouseMove(e: MouseEvent): void {
2107
+ const r = this.canvas.getBoundingClientRect();
2108
+ const x = e.clientX - r.left;
2109
+ const y = e.clientY - r.top;
2110
+ const dx = x - this.lastX;
2111
+ const dy = y - this.lastY;
2112
+
2113
+ if (!this.handleDragMove(x, y, dx, dy, 3, true)) {
2065
2114
  const [wx, wy] = this.toWorld(x, y);
2066
2115
  const hit = this.hitTest(wx, wy);
2067
2116
  const newHov = hit?.id ?? null;
@@ -2079,19 +2128,7 @@ export class GraphCanvas {
2079
2128
  private onMouseUp(e: MouseEvent): void {
2080
2129
  if (e.button !== 0) return;
2081
2130
 
2082
- if (this.isDraggingNode && this.dragNode) {
2083
- if (!this.hasMoved) {
2084
- const id = this.dragNode.id;
2085
- const newSel = this.filter.selectedId === id ? null : id;
2086
- this.filter = { ...this.filter, selectedId: newSel };
2087
- this.particles = [];
2088
- this.lastParticleSpawn = 0;
2089
- this.onSelectNode(newSel);
2090
- if (newSel) this.flyTo(this.dragNode);
2091
- }
2092
- this.dragNode.fx = null;
2093
- this.dragNode.fy = null;
2094
- this.dragNode = null;
2131
+ if (this.endNodeDrag()) {
2095
2132
  this.isDraggingNode = false;
2096
2133
  } else if (this.isDraggingCanvas) {
2097
2134
  this.isDraggingCanvas = false;
@@ -2121,25 +2158,11 @@ export class GraphCanvas {
2121
2158
 
2122
2159
  private onTouchStart(e: TouchEvent): void {
2123
2160
  e.preventDefault();
2124
- const rect = this.canvas.getBoundingClientRect();
2125
- if (e.touches.length === 1) {
2126
- const x = e.touches[0].clientX - rect.left;
2127
- const y = e.touches[0].clientY - rect.top;
2128
- this.lastX = x; this.lastY = y;
2129
- this.downX = x; this.downY = y;
2130
- this.hasMoved = false;
2131
- const [wx, wy] = this.toWorld(x, y);
2132
- const hit = this.hitTest(wx, wy);
2133
- if (hit) {
2134
- this.isDraggingNode = true;
2135
- this.dragNode = hit;
2136
- hit.fx = hit.x;
2137
- hit.fy = hit.y;
2138
- this.alpha = Math.max(this.alpha, 0.25);
2139
- } else {
2140
- this.isDraggingCanvas = true;
2141
- }
2161
+ const pos = this.firstTouchPos(e);
2162
+ if (pos) {
2163
+ this.beginPointerDrag(pos.x, pos.y, false);
2142
2164
  } else if (e.touches.length === 2) {
2165
+ const rect = this.canvas.getBoundingClientRect();
2143
2166
  this.isDraggingNode = false;
2144
2167
  this.isDraggingCanvas = false;
2145
2168
  if (this.dragNode) { this.dragNode.fx = null; this.dragNode.fy = null; this.dragNode = null; }
@@ -2153,25 +2176,16 @@ export class GraphCanvas {
2153
2176
 
2154
2177
  private onTouchMove(e: TouchEvent): void {
2155
2178
  e.preventDefault();
2156
- const rect = this.canvas.getBoundingClientRect();
2157
- if (e.touches.length === 1) {
2158
- const x = e.touches[0].clientX - rect.left;
2159
- const y = e.touches[0].clientY - rect.top;
2179
+ const pos = this.firstTouchPos(e);
2180
+ if (pos) {
2181
+ const { x, y } = pos;
2160
2182
  const dx = x - this.lastX;
2161
2183
  const dy = y - this.lastY;
2162
- if (Math.abs(x - this.downX) > 5 || Math.abs(y - this.downY) > 5) this.hasMoved = true;
2163
- if (this.isDraggingNode && this.dragNode) {
2164
- const [wx, wy] = this.toWorld(x, y);
2165
- this.dragNode.fx = wx;
2166
- this.dragNode.fy = wy;
2167
- } else if (this.isDraggingCanvas) {
2168
- this.tx += dx;
2169
- this.ty += dy;
2170
- this.flyTarget = null;
2171
- }
2184
+ this.handleDragMove(x, y, dx, dy, 5, false);
2172
2185
  this.lastX = x;
2173
2186
  this.lastY = y;
2174
2187
  } else if (e.touches.length === 2) {
2188
+ const rect = this.canvas.getBoundingClientRect();
2175
2189
  const dx = e.touches[0].clientX - e.touches[1].clientX;
2176
2190
  const dy = e.touches[0].clientY - e.touches[1].clientY;
2177
2191
  const dist = Math.sqrt(dx * dx + dy * dy);
@@ -2190,20 +2204,7 @@ export class GraphCanvas {
2190
2204
  private onTouchEnd(e: TouchEvent): void {
2191
2205
  e.preventDefault();
2192
2206
  if (e.touches.length === 0) {
2193
- if (this.isDraggingNode && this.dragNode) {
2194
- if (!this.hasMoved) {
2195
- const id = this.dragNode.id;
2196
- const newSel = this.filter.selectedId === id ? null : id;
2197
- this.filter = { ...this.filter, selectedId: newSel };
2198
- this.particles = [];
2199
- this.lastParticleSpawn = 0;
2200
- this.onSelectNode(newSel);
2201
- if (newSel) this.flyTo(this.dragNode);
2202
- }
2203
- this.dragNode.fx = null;
2204
- this.dragNode.fy = null;
2205
- this.dragNode = null;
2206
- }
2207
+ this.endNodeDrag();
2207
2208
  this.isDraggingNode = false;
2208
2209
  this.isDraggingCanvas = false;
2209
2210
  }
@@ -5,13 +5,18 @@ import { api } from '../api.js';
5
5
  import { state } from '../state.js';
6
6
  import { escHtml } from '../utils.js';
7
7
  import { toast } from '../components/toast.js';
8
- import { GraphCanvas } from './graph-canvas.js';
8
+ import { browserWindow } from '../browser-window.js';
9
+ import { GraphCanvas, tagColorMapFromFreq } from './graph-canvas.js';
9
10
  // ── Module state ──────────────────────────────────────────────
10
11
  let currentGraph = null;
11
12
  let selectedNodeId = '';
12
13
  const canvasRef = { current: null };
13
14
  let physicsLabel = 'Pause Physics';
14
15
  let graphSyncInFlight = false;
16
+ /** Record whether a graph sync is running, outside the async sync function. */
17
+ function setGraphSyncInFlight(active) {
18
+ graphSyncInFlight = active;
19
+ }
15
20
  let infoDrawerOpen = false;
16
21
  let relDrawerOpen = false;
17
22
  let filterOpen = false;
@@ -70,7 +75,7 @@ function showCtxMenu(nodeId, x, y) {
70
75
  return b;
71
76
  };
72
77
  if (isItem) {
73
- menu.appendChild(btn('⊡', 'Open Item', () => window.__app?.openItemDetail(nodeId)));
78
+ menu.appendChild(btn('⊡', 'Open Item', () => browserWindow().__app?.openItemDetail(nodeId)));
74
79
  const sep1 = document.createElement('div');
75
80
  sep1.className = 'graph-ctx-sep';
76
81
  menu.appendChild(sep1);
@@ -302,7 +307,7 @@ function blockerStats(rels) {
302
307
  function visibleGraph(graph) {
303
308
  const nodes = graph.nodes || [];
304
309
  let rels = graph.relationships || [];
305
- const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
310
+ const connected = connectedNodeIds(rels);
306
311
  // Dep mode: restrict to dependency/block edges
307
312
  if (filter.depMode) {
308
313
  rels = rels.filter(isDependencyRel);
@@ -352,23 +357,69 @@ function visibleGraph(graph) {
352
357
  });
353
358
  return { nodes: visNodes, rels: visRels, connected };
354
359
  }
355
- // ── Canvas data conversion ────────────────────────────────────
356
- /** Converts graph nodes into canvas display nodes, attaching each node's edge degree (computed from the relationships) so the renderer can size them. */
357
- function toCanvasNodes(nodes, rels) {
358
- const deg = degreeMap(rels);
359
- return nodes.map((n) => ({
360
+ // ── Shared graph helpers ─────────────────────────────────────
361
+ /** Returns the set of node ids that appear as either endpoint of any relationship in the given list. */
362
+ function connectedNodeIds(rels) {
363
+ return new Set(rels.flatMap((r) => [r.from, r.to]));
364
+ }
365
+ /** Counts how many times each relationship type appears, returning a type-to-count record. */
366
+ function countRelTypes(rels) {
367
+ return rels.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
368
+ }
369
+ /** Counts how many nodes fall into each type category, returning a type-to-count record. */
370
+ function countNodeTypes(nodes) {
371
+ return nodes.reduce((acc, n) => {
372
+ const t = nodeType(n);
373
+ acc[t] = (acc[t] || 0) + 1;
374
+ return acc;
375
+ }, {});
376
+ }
377
+ /** Converts a single graph node into a canvas display node with the given edge degree, copying id, label, type, status, lane, degree, and tags. */
378
+ function nodeToCanvasNode(n, degree) {
379
+ return {
360
380
  id: n.id,
361
381
  label: nodeTitle(n),
362
382
  type: nodeType(n),
363
383
  status: nodeStatus(n),
364
384
  lane: nodeLane(n),
365
- degree: deg.get(n.id) || 0,
385
+ degree,
366
386
  tags: Array.isArray(n.properties?.tags)
367
387
  ? n.properties.tags.map(String)
368
388
  : [],
369
- priority: n.properties?.priority !== undefined ? Number(n.properties.priority) : undefined,
370
- assignee: n.properties?.assignee ? String(n.properties.assignee) : undefined,
371
- }));
389
+ };
390
+ }
391
+ /** Destroys the current canvas instance and re-renders the graph view from scratch. */
392
+ function refreshGraph() {
393
+ canvasRef.current?.destroy();
394
+ canvasRef.current = null;
395
+ void renderGraphView();
396
+ }
397
+ /** Displays an error message on a form-error element, extracting the message from an unknown catch value. */
398
+ function displayFormError(errEl, err) {
399
+ if (errEl) {
400
+ errEl.textContent = err instanceof Error ? err.message : String(err);
401
+ errEl.style.display = '';
402
+ }
403
+ }
404
+ /** Reads the four physics-slider input elements from the DOM, returning null for any that are missing. */
405
+ function getPhysicsInputs() {
406
+ return {
407
+ rep: document.getElementById('graph-physics-repulsion'),
408
+ linkDist: document.getElementById('graph-physics-linkdist'),
409
+ linkStr: document.getElementById('graph-physics-linkstr'),
410
+ gravity: document.getElementById('graph-physics-gravity'),
411
+ };
412
+ }
413
+ // ── Canvas data conversion ────────────────────────────────────
414
+ /** Converts graph nodes into canvas display nodes, attaching each node's edge degree (computed from the relationships) so the renderer can size them. */
415
+ function toCanvasNodes(nodes, rels) {
416
+ const deg = degreeMap(rels);
417
+ return nodes.map((n) => {
418
+ const cn = nodeToCanvasNode(n, deg.get(n.id) || 0);
419
+ cn.priority = n.properties?.priority !== undefined ? Number(n.properties.priority) : undefined;
420
+ cn.assignee = n.properties?.assignee ? String(n.properties.assignee) : undefined;
421
+ return cn;
422
+ });
372
423
  }
373
424
  function toCanvasEdges(rels) {
374
425
  return rels.map((r) => ({ from: r.from, to: r.to, type: r.type }));
@@ -596,15 +647,11 @@ function renderInfoPanel(data, fullItem) {
596
647
  const nodes = graph.nodes || [];
597
648
  const rels = graph.relationships || [];
598
649
  const byId = new Map(nodes.map((n) => [n.id, n]));
599
- const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
650
+ const connected = connectedNodeIds(rels);
600
651
  const itemNodes = nodes.filter(isItemNode);
601
- const relCounts = rels.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
652
+ const relCounts = countRelTypes(rels);
602
653
  const selectedNode = selectedNodeId ? byId.get(selectedNodeId) : undefined;
603
- const typeCounts = nodes.reduce((acc, n) => {
604
- const t = nodeType(n);
605
- acc[t] = (acc[t] || 0) + 1;
606
- return acc;
607
- }, {});
654
+ const typeCounts = countNodeTypes(nodes);
608
655
  return `
609
656
  <div class="graph-panel-title">Graph Coverage</div>
610
657
  ${renderCoverage(itemNodes, rels, connected, relCounts)}
@@ -712,9 +759,9 @@ function renderGraphShell(data) {
712
759
  const rels = graph.relationships || [];
713
760
  const itemNodes = nodes.filter(isItemNode);
714
761
  const facetNodes = nodes.filter(isFacetNode);
715
- const connected = new Set(rels.flatMap((r) => [r.from, r.to]));
762
+ const connected = connectedNodeIds(rels);
716
763
  const isolated = itemNodes.filter((n) => !connected.has(n.id)).length;
717
- const relCounts = rels.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {});
764
+ const relCounts = countRelTypes(rels);
718
765
  const relOptions = Object.keys(relCounts).sort();
719
766
  const errText = compactError(data.extensionError);
720
767
  const { rels: visRels } = visibleGraph(graph);
@@ -985,7 +1032,7 @@ function initCanvas() {
985
1032
  selectedItemCache = null;
986
1033
  },
987
1034
  onOpenNode(id) {
988
- window.__app?.openItemDetail(id);
1035
+ browserWindow().__app?.openItemDetail(id);
989
1036
  },
990
1037
  onContextMenu(id, x, y) { showCtxMenu(id, x, y); },
991
1038
  });
@@ -1030,7 +1077,6 @@ const TYPE_COLORS_MAP = {
1030
1077
  task: '#2dd4bf', feature: '#60a5fa', epic: '#a78bfa', bug: '#f87171',
1031
1078
  milestone: '#fbbf24', story: '#34d399', chore: '#94a3b8', release: '#38bdf8',
1032
1079
  };
1033
- const TAG_PALETTE_JS = ['#2dd4bf', '#60a5fa', '#a78bfa', '#f87171', '#fbbf24', '#34d399', '#fb923c', '#e879f9'];
1034
1080
  /** Computes a tag-to-color map by ranking tags by how often they appear across the nodes and assigning each of the most frequent ones a palette color. */
1035
1081
  function computeTagColorMap(nodes) {
1036
1082
  const freq = new Map();
@@ -1039,8 +1085,7 @@ function computeTagColorMap(nodes) {
1039
1085
  for (const t of tags)
1040
1086
  freq.set(t, (freq.get(t) ?? 0) + 1);
1041
1087
  }
1042
- const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, TAG_PALETTE_JS.length).map(([t]) => t);
1043
- return new Map(top.map((t, i) => [t, TAG_PALETTE_JS[i]]));
1088
+ return tagColorMapFromFreq(freq);
1044
1089
  }
1045
1090
  /** Rebuilds the legend HUD HTML to match the current color mode (status, type, or tag) or dependency mode, including clickable tag chips when coloring by tag. */
1046
1091
  function updateLegend() {
@@ -1049,11 +1094,7 @@ function updateLegend() {
1049
1094
  return;
1050
1095
  const nodes = currentGraph?.graph?.nodes ?? [];
1051
1096
  if (filter.colorMode === 'type') {
1052
- const typeCounts = nodes.filter(isItemNode).reduce((acc, n) => {
1053
- const t = nodeType(n);
1054
- acc[t] = (acc[t] || 0) + 1;
1055
- return acc;
1056
- }, {});
1097
+ const typeCounts = countNodeTypes(nodes.filter(isItemNode));
1057
1098
  const shown = Object.entries(typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6);
1058
1099
  legend.innerHTML = `
1059
1100
  <span><i class="legend-dot legend-facet"></i>Metadata</span>
@@ -1122,7 +1163,7 @@ function updateLegend() {
1122
1163
  function bindInfoPanelEvents() {
1123
1164
  document.getElementById('graph-open-selected')?.addEventListener('click', () => {
1124
1165
  if (selectedNodeId)
1125
- window.__app?.openItemDetail(selectedNodeId);
1166
+ browserWindow().__app?.openItemDetail(selectedNodeId);
1126
1167
  });
1127
1168
  document.getElementById('graph-clear-selected')?.addEventListener('click', () => {
1128
1169
  selectedNodeId = '';
@@ -1177,18 +1218,18 @@ function bindHudEvents() {
1177
1218
  document.getElementById('graph-back-btn')?.addEventListener('click', () => {
1178
1219
  removeCtxMenu();
1179
1220
  // Remove graph keyboard handler
1180
- const kh = window.__graphKeyHandler;
1221
+ const kh = browserWindow().__graphKeyHandler;
1181
1222
  if (kh) {
1182
1223
  document.removeEventListener('keydown', kh);
1183
- delete window.__graphKeyHandler;
1224
+ delete browserWindow().__graphKeyHandler;
1184
1225
  }
1185
- window.__app?.showView('items');
1226
+ browserWindow().__app?.showView('items');
1186
1227
  });
1187
1228
  const runGraphSync = async () => {
1188
1229
  if (!state.currentProject || graphSyncInFlight)
1189
1230
  return;
1190
1231
  const syncBtn = document.getElementById('graph-sync-btn');
1191
- graphSyncInFlight = true;
1232
+ setGraphSyncInFlight(true);
1192
1233
  if (syncBtn) {
1193
1234
  syncBtn.disabled = true;
1194
1235
  syncBtn.textContent = 'Syncing…';
@@ -1202,7 +1243,7 @@ function bindHudEvents() {
1202
1243
  toast(err instanceof Error ? err.message : String(err), 'error');
1203
1244
  }
1204
1245
  finally {
1205
- graphSyncInFlight = false;
1246
+ setGraphSyncInFlight(false);
1206
1247
  if (syncBtn) {
1207
1248
  syncBtn.disabled = false;
1208
1249
  syncBtn.textContent = '⧉ Sync';
@@ -1366,10 +1407,7 @@ function bindHudEvents() {
1366
1407
  document.getElementById('graph-physics-panel-toggle')?.classList.toggle('active', physicsOpen);
1367
1408
  if (physicsOpen && canvasRef.current) {
1368
1409
  const params = canvasRef.current.getPhysicsParams();
1369
- const repEl = document.getElementById('graph-physics-repulsion');
1370
- const ldEl = document.getElementById('graph-physics-linkdist');
1371
- const lsEl = document.getElementById('graph-physics-linkstr');
1372
- const gEl = document.getElementById('graph-physics-gravity');
1410
+ const { rep: repEl, linkDist: ldEl, linkStr: lsEl, gravity: gEl } = getPhysicsInputs();
1373
1411
  if (repEl) {
1374
1412
  repEl.value = String(params.repulsion);
1375
1413
  }
@@ -1405,10 +1443,7 @@ function bindHudEvents() {
1405
1443
  bindPhysicsSlider('graph-physics-gravity', 'graph-physics-gravity-val', 'centerForce', 1000, 3);
1406
1444
  document.getElementById('graph-physics-reset')?.addEventListener('click', () => {
1407
1445
  canvasRef.current?.setPhysicsParams({ repulsion: 2000, linkDistance: 140, linkStrength: 0.065, centerForce: 0.010 });
1408
- const repEl = document.getElementById('graph-physics-repulsion');
1409
- const ldEl = document.getElementById('graph-physics-linkdist');
1410
- const lsEl = document.getElementById('graph-physics-linkstr');
1411
- const gEl = document.getElementById('graph-physics-gravity');
1446
+ const { rep: repEl, linkDist: ldEl, linkStr: lsEl, gravity: gEl } = getPhysicsInputs();
1412
1447
  if (repEl)
1413
1448
  repEl.value = '2000';
1414
1449
  if (ldEl)
@@ -1513,7 +1548,7 @@ function bindHudEvents() {
1513
1548
  };
1514
1549
  document.addEventListener('keydown', graphKeyHandler);
1515
1550
  // Store for cleanup on graph exit
1516
- window.__graphKeyHandler = graphKeyHandler;
1551
+ browserWindow().__graphKeyHandler = graphKeyHandler;
1517
1552
  }
1518
1553
  // ── URL routing (pushState) ─────────────────────────────────
1519
1554
  /** Serializes the current selection and filter into URL query parameters and replaces the browser history entry so the view can be restored or shared. */
@@ -1622,16 +1657,10 @@ function showAddDependencyModal() {
1622
1657
  try {
1623
1658
  await api('POST', `/projects/${state.currentProject.id}/pm/deps/${fromId}`, { targetId: toId, rel: relType });
1624
1659
  document.getElementById('graph-add-dep-modal')?.remove();
1625
- // Refresh graph
1626
- canvasRef.current?.destroy();
1627
- canvasRef.current = null;
1628
- void renderGraphView();
1660
+ refreshGraph();
1629
1661
  }
1630
1662
  catch (err) {
1631
- if (errEl) {
1632
- errEl.textContent = err instanceof Error ? err.message : String(err);
1633
- errEl.style.display = '';
1634
- }
1663
+ displayFormError(errEl, err);
1635
1664
  }
1636
1665
  });
1637
1666
  }
@@ -1649,7 +1678,7 @@ function showRemoveDependencyModal() {
1649
1678
  return `<option value="${i}">${escHtml(dependencyLabel(r))}: ${escHtml(nodeTitle(from || { id: r.from }))} → ${escHtml(nodeTitle(to || { id: r.to }))}</option>`;
1650
1679
  }).join('');
1651
1680
  if (!depRels.length) {
1652
- window.__app?.toast('No dependencies to remove', 'info');
1681
+ browserWindow().__app?.toast('No dependencies to remove', 'info');
1653
1682
  return;
1654
1683
  }
1655
1684
  const html = `
@@ -1687,16 +1716,10 @@ function showRemoveDependencyModal() {
1687
1716
  try {
1688
1717
  await api('DELETE', `/projects/${state.currentProject.id}/pm/deps/${rel.from}`, { targetId: rel.to, rel: rel.type });
1689
1718
  document.getElementById('graph-remove-dep-modal')?.remove();
1690
- // Refresh graph
1691
- canvasRef.current?.destroy();
1692
- canvasRef.current = null;
1693
- void renderGraphView();
1719
+ refreshGraph();
1694
1720
  }
1695
1721
  catch (err) {
1696
- if (errEl) {
1697
- errEl.textContent = err instanceof Error ? err.message : String(err);
1698
- errEl.style.display = '';
1699
- }
1722
+ displayFormError(errEl, err);
1700
1723
  }
1701
1724
  });
1702
1725
  }
@@ -1828,24 +1851,16 @@ export async function renderLocalGraph(containerId, nodeId, depth = 2) {
1828
1851
  if (id !== nodeId) {
1829
1852
  const n = nodes.find((nd) => nd.id === id);
1830
1853
  if (n && isItemNode(n)) {
1831
- window.__app?.openItemDetail(id);
1854
+ browserWindow().__app?.openItemDetail(id);
1832
1855
  }
1833
1856
  }
1834
1857
  },
1835
1858
  onOpenNode(id) {
1836
- window.__app?.openItemDetail(id);
1859
+ browserWindow().__app?.openItemDetail(id);
1837
1860
  },
1838
1861
  onContextMenu() { },
1839
1862
  });
1840
- const canvasNodes = subNodes.map((n) => ({
1841
- id: n.id,
1842
- label: nodeTitle(n),
1843
- type: nodeType(n),
1844
- status: nodeStatus(n),
1845
- lane: nodeLane(n),
1846
- degree: deg.get(n.id) || 0,
1847
- tags: Array.isArray(n.properties?.tags) ? n.properties.tags.map(String) : [],
1848
- }));
1863
+ const canvasNodes = subNodes.map((n) => nodeToCanvasNode(n, deg.get(n.id) || 0));
1849
1864
  const canvasEdges = subRels.map((r) => ({ from: r.from, to: r.to, type: r.type }));
1850
1865
  canvas.setData(canvasNodes, canvasEdges);
1851
1866
  canvas.setSelected(nodeId);