@cliangdev/flux-plugin 0.2.0 → 0.3.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.
- package/README.md +11 -7
- package/agents/coder.md +150 -25
- package/bin/install.cjs +171 -16
- package/commands/breakdown.md +47 -10
- package/commands/dashboard.md +29 -0
- package/commands/flux.md +92 -12
- package/commands/implement.md +166 -17
- package/commands/linear.md +6 -5
- package/commands/prd.md +996 -82
- package/manifest.json +2 -1
- package/package.json +9 -11
- package/skills/flux-orchestrator/SKILL.md +11 -3
- package/skills/prd-writer/SKILL.md +761 -0
- package/skills/ux-ui-design/SKILL.md +346 -0
- package/skills/ux-ui-design/references/design-tokens.md +359 -0
- package/src/__tests__/version.test.ts +37 -0
- package/src/adapters/local/.gitkeep +0 -0
- package/src/dashboard/__tests__/api.test.ts +211 -0
- package/src/dashboard/browser.ts +35 -0
- package/src/dashboard/public/app.js +869 -0
- package/src/dashboard/public/index.html +90 -0
- package/src/dashboard/public/styles.css +807 -0
- package/src/dashboard/public/vendor/highlight.css +10 -0
- package/src/dashboard/public/vendor/highlight.min.js +8422 -0
- package/src/dashboard/public/vendor/marked.min.js +2210 -0
- package/src/dashboard/server.ts +296 -0
- package/src/dashboard/watchers.ts +83 -0
- package/src/server/__tests__/config.test.ts +163 -0
- package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
- package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
- package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
- package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
- package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
- package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
- package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
- package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
- package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
- package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
- package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
- package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
- package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
- package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
- package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
- package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
- package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
- package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
- package/src/server/adapters/factory.ts +90 -0
- package/src/server/adapters/index.ts +9 -0
- package/src/server/adapters/linear/adapter.ts +1141 -0
- package/src/server/adapters/linear/client.ts +169 -0
- package/src/server/adapters/linear/config.ts +152 -0
- package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
- package/src/server/adapters/linear/helpers/index.ts +7 -0
- package/src/server/adapters/linear/index.ts +16 -0
- package/src/server/adapters/linear/mappers/description.ts +136 -0
- package/src/server/adapters/linear/mappers/epic.ts +81 -0
- package/src/server/adapters/linear/mappers/index.ts +27 -0
- package/src/server/adapters/linear/mappers/prd.ts +178 -0
- package/src/server/adapters/linear/mappers/task.ts +82 -0
- package/src/server/adapters/linear/types.ts +264 -0
- package/src/server/adapters/local-adapter.ts +1009 -0
- package/src/server/adapters/types.ts +293 -0
- package/src/server/config.ts +73 -0
- package/src/server/db/__tests__/queries.test.ts +473 -0
- package/src/server/db/ids.ts +17 -0
- package/src/server/db/index.ts +69 -0
- package/src/server/db/queries.ts +142 -0
- package/src/server/db/refs.ts +60 -0
- package/src/server/db/schema.ts +97 -0
- package/src/server/db/sqlite.ts +10 -0
- package/src/server/index.ts +81 -0
- package/src/server/tools/__tests__/crud.test.ts +411 -0
- package/src/server/tools/__tests__/get-version.test.ts +27 -0
- package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
- package/src/server/tools/__tests__/query.test.ts +405 -0
- package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
- package/src/server/tools/configure-linear.ts +373 -0
- package/src/server/tools/create-epic.ts +44 -0
- package/src/server/tools/create-prd.ts +40 -0
- package/src/server/tools/create-task.ts +47 -0
- package/src/server/tools/criteria.ts +50 -0
- package/src/server/tools/delete-entity.ts +76 -0
- package/src/server/tools/dependencies.ts +55 -0
- package/src/server/tools/get-entity.ts +240 -0
- package/src/server/tools/get-linear-url.ts +28 -0
- package/src/server/tools/get-stats.ts +52 -0
- package/src/server/tools/get-version.ts +20 -0
- package/src/server/tools/index.ts +158 -0
- package/src/server/tools/init-project.ts +108 -0
- package/src/server/tools/query-entities.ts +167 -0
- package/src/server/tools/render-status.ts +219 -0
- package/src/server/tools/update-entity.ts +140 -0
- package/src/server/tools/update-status.ts +166 -0
- package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
- package/src/server/utils/logger.ts +9 -0
- package/src/server/utils/mcp-response.ts +254 -0
- package/src/server/utils/status-transitions.ts +160 -0
- package/src/status-line/__tests__/status-line.test.ts +215 -0
- package/src/status-line/index.ts +147 -0
- package/src/utils/__tests__/chalk-import.test.ts +32 -0
- package/src/utils/__tests__/display.test.ts +97 -0
- package/src/utils/__tests__/status-renderer.test.ts +310 -0
- package/src/utils/display.ts +62 -0
- package/src/utils/status-renderer.ts +214 -0
- package/src/version.ts +5 -0
- package/dist/server/index.js +0 -87063
- package/skills/prd-template/SKILL.md +0 -242
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flux Dashboard Client Application
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// State
|
|
6
|
+
let treeData = [];
|
|
7
|
+
let tagsData = [];
|
|
8
|
+
let selectedRef = null;
|
|
9
|
+
let activeTag = "All";
|
|
10
|
+
let statusFilter = "";
|
|
11
|
+
let searchQuery = "";
|
|
12
|
+
let ws = null;
|
|
13
|
+
let reconnectAttempts = 0;
|
|
14
|
+
let projectKey = "flux-dashboard";
|
|
15
|
+
const MAX_RECONNECT_ATTEMPTS = 10;
|
|
16
|
+
const RECONNECT_BASE_DELAY = 1000;
|
|
17
|
+
|
|
18
|
+
// DOM Elements
|
|
19
|
+
const elements = {
|
|
20
|
+
tree: document.getElementById("tree"),
|
|
21
|
+
tagsList: document.getElementById("tags-list"),
|
|
22
|
+
contentHeader: document.getElementById("content-header"),
|
|
23
|
+
contentBody: document.getElementById("content-body"),
|
|
24
|
+
connectionStatus: document.getElementById("connection-status"),
|
|
25
|
+
search: document.getElementById("search"),
|
|
26
|
+
statusFilter: document.getElementById("status-filter"),
|
|
27
|
+
themeToggle: document.getElementById("theme-toggle"),
|
|
28
|
+
themeIcon: document.getElementById("theme-icon"),
|
|
29
|
+
sidebar: document.getElementById("sidebar"),
|
|
30
|
+
sidebarResize: document.getElementById("sidebar-resize"),
|
|
31
|
+
depGraphToggle: document.getElementById("dep-graph-toggle"),
|
|
32
|
+
depGraphOverlay: document.getElementById("dep-graph-overlay"),
|
|
33
|
+
depGraphClose: document.getElementById("dep-graph-close"),
|
|
34
|
+
depGraphSvg: document.getElementById("dep-graph-svg"),
|
|
35
|
+
depTypeFilter: document.getElementById("dep-type-filter"),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Initialize
|
|
39
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
40
|
+
initTheme();
|
|
41
|
+
initSidebarResize();
|
|
42
|
+
connectWebSocket();
|
|
43
|
+
loadData();
|
|
44
|
+
setupEventListeners();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Theme Management
|
|
48
|
+
function initTheme() {
|
|
49
|
+
loadProjectKey().then(() => {
|
|
50
|
+
const savedTheme = localStorage.getItem(`${projectKey}-theme`);
|
|
51
|
+
if (savedTheme === "dark") {
|
|
52
|
+
setTheme("dark");
|
|
53
|
+
} else {
|
|
54
|
+
setTheme("light");
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadProjectKey() {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch("/api/tree");
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
if (data.length > 0 && data[0].projectId) {
|
|
64
|
+
projectKey = `flux-${data[0].projectId}`;
|
|
65
|
+
}
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function setTheme(theme) {
|
|
70
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
71
|
+
elements.themeIcon.innerHTML = theme === "dark" ? "☼" : "☾";
|
|
72
|
+
localStorage.setItem(`${projectKey}-theme`, theme);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toggleTheme() {
|
|
76
|
+
const currentTheme = document.documentElement.getAttribute("data-theme");
|
|
77
|
+
setTheme(currentTheme === "dark" ? "light" : "dark");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Sidebar Resize
|
|
81
|
+
function initSidebarResize() {
|
|
82
|
+
const savedWidth = localStorage.getItem(`${projectKey}-sidebar-width`);
|
|
83
|
+
if (savedWidth) {
|
|
84
|
+
elements.sidebar.style.width = `${savedWidth}px`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let isResizing = false;
|
|
88
|
+
|
|
89
|
+
elements.sidebarResize.addEventListener("mousedown", (e) => {
|
|
90
|
+
isResizing = true;
|
|
91
|
+
elements.sidebarResize.classList.add("dragging");
|
|
92
|
+
document.body.style.cursor = "col-resize";
|
|
93
|
+
document.body.style.userSelect = "none";
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
document.addEventListener("mousemove", (e) => {
|
|
98
|
+
if (!isResizing) return;
|
|
99
|
+
|
|
100
|
+
const newWidth = e.clientX;
|
|
101
|
+
if (newWidth >= 200 && newWidth <= 500) {
|
|
102
|
+
elements.sidebar.style.width = `${newWidth}px`;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
document.addEventListener("mouseup", () => {
|
|
107
|
+
if (isResizing) {
|
|
108
|
+
isResizing = false;
|
|
109
|
+
elements.sidebarResize.classList.remove("dragging");
|
|
110
|
+
document.body.style.cursor = "";
|
|
111
|
+
document.body.style.userSelect = "";
|
|
112
|
+
localStorage.setItem(
|
|
113
|
+
`${projectKey}-sidebar-width`,
|
|
114
|
+
elements.sidebar.offsetWidth,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// WebSocket Connection
|
|
121
|
+
function connectWebSocket() {
|
|
122
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
123
|
+
ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
|
|
124
|
+
|
|
125
|
+
ws.onopen = () => {
|
|
126
|
+
reconnectAttempts = 0;
|
|
127
|
+
updateConnectionStatus("connected");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
ws.onmessage = (event) => {
|
|
131
|
+
try {
|
|
132
|
+
const data = JSON.parse(event.data);
|
|
133
|
+
if (data.type === "update") {
|
|
134
|
+
loadData();
|
|
135
|
+
if (selectedRef) {
|
|
136
|
+
loadContent(selectedRef);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error("WebSocket message error:", e);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
ws.onclose = () => {
|
|
145
|
+
updateConnectionStatus("disconnected");
|
|
146
|
+
attemptReconnect();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
ws.onerror = () => {
|
|
150
|
+
updateConnectionStatus("disconnected");
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function attemptReconnect() {
|
|
155
|
+
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
reconnectAttempts++;
|
|
160
|
+
updateConnectionStatus("reconnecting");
|
|
161
|
+
|
|
162
|
+
const delay = RECONNECT_BASE_DELAY * 2 ** (reconnectAttempts - 1);
|
|
163
|
+
setTimeout(() => {
|
|
164
|
+
if (ws.readyState === WebSocket.CLOSED) {
|
|
165
|
+
connectWebSocket();
|
|
166
|
+
}
|
|
167
|
+
}, delay);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function updateConnectionStatus(status) {
|
|
171
|
+
const el = elements.connectionStatus;
|
|
172
|
+
el.className = `status ${status}`;
|
|
173
|
+
|
|
174
|
+
switch (status) {
|
|
175
|
+
case "connected":
|
|
176
|
+
el.innerHTML = "● Connected";
|
|
177
|
+
break;
|
|
178
|
+
case "reconnecting":
|
|
179
|
+
el.innerHTML = "◐ Reconnecting...";
|
|
180
|
+
break;
|
|
181
|
+
case "disconnected":
|
|
182
|
+
el.innerHTML = "○ Disconnected";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Data Loading
|
|
188
|
+
async function loadData() {
|
|
189
|
+
try {
|
|
190
|
+
const [treeResponse, tagsResponse] = await Promise.all([
|
|
191
|
+
fetch("/api/tree"),
|
|
192
|
+
fetch("/api/tags"),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
treeData = await treeResponse.json();
|
|
196
|
+
tagsData = await tagsResponse.json();
|
|
197
|
+
|
|
198
|
+
renderTags();
|
|
199
|
+
renderTree();
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error("Failed to load data:", error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function loadContent(ref) {
|
|
206
|
+
selectedRef = ref;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
let endpoint;
|
|
210
|
+
if (ref.includes("-P")) {
|
|
211
|
+
endpoint = `/api/prd/${ref}`;
|
|
212
|
+
} else if (ref.includes("-E")) {
|
|
213
|
+
endpoint = `/api/epic/${ref}`;
|
|
214
|
+
} else {
|
|
215
|
+
endpoint = `/api/task/${ref}`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const response = await fetch(endpoint);
|
|
219
|
+
const data = await response.json();
|
|
220
|
+
|
|
221
|
+
renderContent(data, ref);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error("Failed to load content:", error);
|
|
224
|
+
elements.contentBody.innerHTML = `<div class="empty-state"><p>Error loading content</p></div>`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Event Listeners
|
|
229
|
+
function setupEventListeners() {
|
|
230
|
+
elements.search.addEventListener("input", (e) => {
|
|
231
|
+
searchQuery = e.target.value.toLowerCase();
|
|
232
|
+
renderTree();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
elements.statusFilter.addEventListener("change", (e) => {
|
|
236
|
+
statusFilter = e.target.value;
|
|
237
|
+
renderTree();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
elements.themeToggle.addEventListener("click", toggleTheme);
|
|
241
|
+
|
|
242
|
+
// Dependency graph events
|
|
243
|
+
elements.depGraphToggle.addEventListener("click", openDepGraph);
|
|
244
|
+
elements.depGraphClose.addEventListener("click", closeDepGraph);
|
|
245
|
+
elements.depGraphOverlay.addEventListener("click", (e) => {
|
|
246
|
+
if (e.target === elements.depGraphOverlay) {
|
|
247
|
+
closeDepGraph();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
elements.depTypeFilter.addEventListener("change", renderDepGraph);
|
|
251
|
+
|
|
252
|
+
// Close on Escape
|
|
253
|
+
document.addEventListener("keydown", (e) => {
|
|
254
|
+
if (
|
|
255
|
+
e.key === "Escape" &&
|
|
256
|
+
!elements.depGraphOverlay.classList.contains("hidden")
|
|
257
|
+
) {
|
|
258
|
+
closeDepGraph();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Rendering
|
|
264
|
+
function renderTags() {
|
|
265
|
+
elements.tagsList.innerHTML = tagsData
|
|
266
|
+
.map(
|
|
267
|
+
(tag) => `
|
|
268
|
+
<span class="tag-pill ${activeTag === tag.tag ? "active" : ""}" data-tag="${tag.tag}">
|
|
269
|
+
${tag.tag}<span class="tag-count">(${tag.count})</span>
|
|
270
|
+
</span>
|
|
271
|
+
`,
|
|
272
|
+
)
|
|
273
|
+
.join("");
|
|
274
|
+
|
|
275
|
+
elements.tagsList.querySelectorAll(".tag-pill").forEach((pill) => {
|
|
276
|
+
pill.addEventListener("click", () => {
|
|
277
|
+
activeTag = pill.dataset.tag;
|
|
278
|
+
renderTags();
|
|
279
|
+
renderTree();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function renderTree() {
|
|
285
|
+
const filtered = filterTree(treeData);
|
|
286
|
+
|
|
287
|
+
if (filtered.length === 0) {
|
|
288
|
+
elements.tree.innerHTML = `<div class="empty-state"><p>No items match your filters</p></div>`;
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
elements.tree.innerHTML = filtered.map((prd) => renderPrdNode(prd)).join("");
|
|
293
|
+
|
|
294
|
+
// Add click handlers
|
|
295
|
+
elements.tree.querySelectorAll(".tree-node-content").forEach((node) => {
|
|
296
|
+
node.addEventListener("click", (e) => {
|
|
297
|
+
const ref = node.dataset.ref;
|
|
298
|
+
|
|
299
|
+
// Handle expand/collapse
|
|
300
|
+
if (e.target.classList.contains("tree-expand")) {
|
|
301
|
+
const children = node.parentElement.querySelector(".tree-children");
|
|
302
|
+
if (children) {
|
|
303
|
+
children.classList.toggle("collapsed");
|
|
304
|
+
e.target.textContent = children.classList.contains("collapsed")
|
|
305
|
+
? "▶"
|
|
306
|
+
: "▼";
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Select node
|
|
312
|
+
elements.tree.querySelectorAll(".tree-node-content").forEach((n) => {
|
|
313
|
+
n.classList.remove("selected");
|
|
314
|
+
});
|
|
315
|
+
node.classList.add("selected");
|
|
316
|
+
loadContent(ref);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function filterTree(data) {
|
|
322
|
+
return data.filter((prd) => {
|
|
323
|
+
// Tag filter
|
|
324
|
+
if (activeTag !== "All" && prd.tag !== activeTag) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Status filter
|
|
329
|
+
if (statusFilter && prd.status !== statusFilter) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Search filter
|
|
334
|
+
if (searchQuery) {
|
|
335
|
+
const matchesPrd =
|
|
336
|
+
prd.ref.toLowerCase().includes(searchQuery) ||
|
|
337
|
+
prd.title.toLowerCase().includes(searchQuery);
|
|
338
|
+
|
|
339
|
+
const matchesChildren = prd.epics?.some(
|
|
340
|
+
(epic) =>
|
|
341
|
+
epic.ref.toLowerCase().includes(searchQuery) ||
|
|
342
|
+
epic.title.toLowerCase().includes(searchQuery) ||
|
|
343
|
+
epic.tasks?.some(
|
|
344
|
+
(task) =>
|
|
345
|
+
task.ref.toLowerCase().includes(searchQuery) ||
|
|
346
|
+
task.title.toLowerCase().includes(searchQuery),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
return matchesPrd || matchesChildren;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return true;
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderPrdNode(prd) {
|
|
358
|
+
const hasChildren = prd.epics && prd.epics.length > 0;
|
|
359
|
+
const statusClass = getStatusClass(prd.status);
|
|
360
|
+
const statusIcon = getStatusIcon(prd.status);
|
|
361
|
+
|
|
362
|
+
return `
|
|
363
|
+
<div class="tree-node">
|
|
364
|
+
<div class="tree-node-content ${selectedRef === prd.ref ? "selected" : ""}" data-ref="${prd.ref}" title="${escapeHtml(prd.title)}">
|
|
365
|
+
<span class="tree-expand">${hasChildren ? "▼" : ""}</span>
|
|
366
|
+
<span class="tree-status ${statusClass}">${statusIcon}</span>
|
|
367
|
+
<span class="tree-ref">${prd.ref}</span>
|
|
368
|
+
<span class="tree-title">${escapeHtml(prd.title)}</span>
|
|
369
|
+
</div>
|
|
370
|
+
${hasChildren ? `<div class="tree-children">${prd.epics.map((epic) => renderEpicNode(epic)).join("")}</div>` : ""}
|
|
371
|
+
</div>
|
|
372
|
+
`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function renderEpicNode(epic) {
|
|
376
|
+
const hasChildren = epic.tasks && epic.tasks.length > 0;
|
|
377
|
+
const statusClass = getStatusClass(epic.status);
|
|
378
|
+
const statusIcon = getStatusIcon(epic.status);
|
|
379
|
+
|
|
380
|
+
return `
|
|
381
|
+
<div class="tree-node">
|
|
382
|
+
<div class="tree-node-content ${selectedRef === epic.ref ? "selected" : ""}" data-ref="${epic.ref}" title="${escapeHtml(epic.title)}">
|
|
383
|
+
<span class="tree-expand">${hasChildren ? "▼" : ""}</span>
|
|
384
|
+
<span class="tree-status ${statusClass}">${statusIcon}</span>
|
|
385
|
+
<span class="tree-ref">${epic.ref}</span>
|
|
386
|
+
<span class="tree-title">${escapeHtml(epic.title)}</span>
|
|
387
|
+
</div>
|
|
388
|
+
${hasChildren ? `<div class="tree-children">${epic.tasks.map((task) => renderTaskNode(task)).join("")}</div>` : ""}
|
|
389
|
+
</div>
|
|
390
|
+
`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function renderTaskNode(task) {
|
|
394
|
+
const statusClass = getStatusClass(task.status);
|
|
395
|
+
const statusIcon = getStatusIcon(task.status);
|
|
396
|
+
|
|
397
|
+
return `
|
|
398
|
+
<div class="tree-node">
|
|
399
|
+
<div class="tree-node-content ${selectedRef === task.ref ? "selected" : ""}" data-ref="${task.ref}" title="${escapeHtml(task.title)}">
|
|
400
|
+
<span class="tree-expand"></span>
|
|
401
|
+
<span class="tree-status ${statusClass}">${statusIcon}</span>
|
|
402
|
+
<span class="tree-ref">${task.ref}</span>
|
|
403
|
+
<span class="tree-title">${escapeHtml(task.title)}</span>
|
|
404
|
+
</div>
|
|
405
|
+
</div>
|
|
406
|
+
`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function renderContent(data, ref) {
|
|
410
|
+
const type = ref.includes("-P")
|
|
411
|
+
? "prd"
|
|
412
|
+
: ref.includes("-E")
|
|
413
|
+
? "epic"
|
|
414
|
+
: "task";
|
|
415
|
+
|
|
416
|
+
// Header
|
|
417
|
+
let headerHtml = `<h1>${escapeHtml(data.title)}</h1>`;
|
|
418
|
+
headerHtml += `<div class="content-meta">`;
|
|
419
|
+
headerHtml += `<span><strong>Ref:</strong> ${data.ref}</span>`;
|
|
420
|
+
headerHtml += `<span><strong>Status:</strong> ${data.status}</span>`;
|
|
421
|
+
|
|
422
|
+
if (type === "prd" && data.tag) {
|
|
423
|
+
headerHtml += `<span><strong>Tag:</strong> ${data.tag}</span>`;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (type === "task" && data.priority) {
|
|
427
|
+
headerHtml += `<span><strong>Priority:</strong> ${data.priority}</span>`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
headerHtml += `</div>`;
|
|
431
|
+
|
|
432
|
+
elements.contentHeader.innerHTML = headerHtml;
|
|
433
|
+
|
|
434
|
+
// Body
|
|
435
|
+
let bodyHtml = "";
|
|
436
|
+
|
|
437
|
+
if (type === "prd" && data.markdown) {
|
|
438
|
+
bodyHtml = marked.parse(data.markdown);
|
|
439
|
+
} else if (data.description) {
|
|
440
|
+
bodyHtml = marked.parse(data.description);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Acceptance Criteria
|
|
444
|
+
if (data.criteria && data.criteria.length > 0) {
|
|
445
|
+
const met = data.criteria.filter((c) => c.is_met || c.isMet).length;
|
|
446
|
+
const total = data.criteria.length;
|
|
447
|
+
const percent = Math.round((met / total) * 100);
|
|
448
|
+
|
|
449
|
+
bodyHtml += `
|
|
450
|
+
<div class="criteria-list">
|
|
451
|
+
<h3>Acceptance Criteria (${met}/${total})</h3>
|
|
452
|
+
<div class="progress-bar">
|
|
453
|
+
<div class="progress-fill" style="width: ${percent}%"></div>
|
|
454
|
+
</div>
|
|
455
|
+
${data.criteria
|
|
456
|
+
.map(
|
|
457
|
+
(c) => `
|
|
458
|
+
<div class="criteria-item ${c.is_met || c.isMet ? "criteria-met" : ""}">
|
|
459
|
+
<input type="checkbox" class="criteria-checkbox" ${c.is_met || c.isMet ? "checked" : ""} disabled />
|
|
460
|
+
<span class="criteria-text">${escapeHtml(c.criteria)}</span>
|
|
461
|
+
</div>
|
|
462
|
+
`,
|
|
463
|
+
)
|
|
464
|
+
.join("")}
|
|
465
|
+
</div>
|
|
466
|
+
`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Dependencies
|
|
470
|
+
if (data.dependencies && data.dependencies.length > 0) {
|
|
471
|
+
bodyHtml += `
|
|
472
|
+
<div class="dependencies">
|
|
473
|
+
<h4>Dependencies</h4>
|
|
474
|
+
${data.dependencies
|
|
475
|
+
.map(
|
|
476
|
+
(dep) => `
|
|
477
|
+
<div class="dependency-item">
|
|
478
|
+
<span>Depends on: ${dep.dependsOnTaskId || dep.depends_on_task_id || "Unknown"}</span>
|
|
479
|
+
</div>
|
|
480
|
+
`,
|
|
481
|
+
)
|
|
482
|
+
.join("")}
|
|
483
|
+
</div>
|
|
484
|
+
`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
elements.contentBody.innerHTML = bodyHtml;
|
|
488
|
+
|
|
489
|
+
// Apply syntax highlighting
|
|
490
|
+
elements.contentBody.querySelectorAll("pre code").forEach((block) => {
|
|
491
|
+
if (typeof hljs !== "undefined") {
|
|
492
|
+
hljs.highlightElement(block);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Helpers
|
|
498
|
+
function getStatusClass(status) {
|
|
499
|
+
switch (status) {
|
|
500
|
+
case "COMPLETED":
|
|
501
|
+
return "completed";
|
|
502
|
+
case "IN_PROGRESS":
|
|
503
|
+
return "in-progress";
|
|
504
|
+
default:
|
|
505
|
+
return "pending";
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function getStatusIcon(status) {
|
|
510
|
+
switch (status) {
|
|
511
|
+
case "COMPLETED":
|
|
512
|
+
return "●";
|
|
513
|
+
case "IN_PROGRESS":
|
|
514
|
+
return "◐";
|
|
515
|
+
default:
|
|
516
|
+
return "○";
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function escapeHtml(text) {
|
|
521
|
+
const div = document.createElement("div");
|
|
522
|
+
div.textContent = text;
|
|
523
|
+
return div.innerHTML;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Dependency Graph
|
|
527
|
+
let depGraphData = null;
|
|
528
|
+
|
|
529
|
+
async function openDepGraph() {
|
|
530
|
+
elements.depGraphOverlay.classList.remove("hidden");
|
|
531
|
+
await loadDepGraph();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function closeDepGraph() {
|
|
535
|
+
elements.depGraphOverlay.classList.add("hidden");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function loadDepGraph() {
|
|
539
|
+
try {
|
|
540
|
+
const [depsResponse, treeResponse] = await Promise.all([
|
|
541
|
+
fetch("/api/dependencies"),
|
|
542
|
+
fetch("/api/tree"),
|
|
543
|
+
]);
|
|
544
|
+
|
|
545
|
+
const deps = await depsResponse.json();
|
|
546
|
+
const tree = await treeResponse.json();
|
|
547
|
+
|
|
548
|
+
// Build node map from tree data
|
|
549
|
+
const nodes = new Map();
|
|
550
|
+
for (const prd of tree) {
|
|
551
|
+
nodes.set(prd.ref, {
|
|
552
|
+
ref: prd.ref,
|
|
553
|
+
title: prd.title,
|
|
554
|
+
type: "prd",
|
|
555
|
+
status: prd.status,
|
|
556
|
+
});
|
|
557
|
+
if (prd.epics) {
|
|
558
|
+
for (const epic of prd.epics) {
|
|
559
|
+
nodes.set(epic.ref, {
|
|
560
|
+
ref: epic.ref,
|
|
561
|
+
title: epic.title,
|
|
562
|
+
type: "epic",
|
|
563
|
+
status: epic.status,
|
|
564
|
+
});
|
|
565
|
+
if (epic.tasks) {
|
|
566
|
+
for (const task of epic.tasks) {
|
|
567
|
+
nodes.set(task.ref, {
|
|
568
|
+
ref: task.ref,
|
|
569
|
+
title: task.title,
|
|
570
|
+
type: "task",
|
|
571
|
+
status: task.status,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
depGraphData = { edges: deps.edges || [], nodes };
|
|
580
|
+
renderDepGraph();
|
|
581
|
+
} catch (error) {
|
|
582
|
+
console.error("Failed to load dependency graph:", error);
|
|
583
|
+
elements.depGraphSvg.innerHTML = `
|
|
584
|
+
<foreignObject x="0" y="0" width="100%" height="100%">
|
|
585
|
+
<div class="dep-graph-empty">
|
|
586
|
+
<p>⚠</p>
|
|
587
|
+
<p>Failed to load dependencies</p>
|
|
588
|
+
</div>
|
|
589
|
+
</foreignObject>
|
|
590
|
+
`;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function renderDepGraph() {
|
|
595
|
+
if (!depGraphData) return;
|
|
596
|
+
|
|
597
|
+
const filterType = elements.depTypeFilter.value;
|
|
598
|
+
let edges = depGraphData.edges;
|
|
599
|
+
const nodes = depGraphData.nodes;
|
|
600
|
+
|
|
601
|
+
// Filter edges by type
|
|
602
|
+
if (filterType !== "all") {
|
|
603
|
+
edges = edges.filter((e) => e.type === filterType);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (edges.length === 0) {
|
|
607
|
+
const container = elements.depGraphSvg.parentElement;
|
|
608
|
+
const w = container.clientWidth || 600;
|
|
609
|
+
const h = container.clientHeight || 400;
|
|
610
|
+
elements.depGraphSvg.setAttribute("viewBox", `0 0 ${w} ${h}`);
|
|
611
|
+
elements.depGraphSvg.style.width = `${w}px`;
|
|
612
|
+
elements.depGraphSvg.style.height = `${h}px`;
|
|
613
|
+
elements.depGraphSvg.innerHTML = `
|
|
614
|
+
<text x="${w / 2}" y="${h / 2 - 20}" text-anchor="middle" class="empty-icon" style="font-size: 48px; fill: var(--text-secondary);">◆</text>
|
|
615
|
+
<text x="${w / 2}" y="${h / 2 + 20}" text-anchor="middle" style="font-size: 14px; fill: var(--text-secondary);">No dependencies${filterType !== "all" ? ` for ${filterType}s` : ""}</text>
|
|
616
|
+
`;
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Collect unique nodes involved in dependencies
|
|
621
|
+
const involvedNodes = new Set();
|
|
622
|
+
for (const edge of edges) {
|
|
623
|
+
involvedNodes.add(edge.from);
|
|
624
|
+
involvedNodes.add(edge.to);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Detect circular dependencies using DFS
|
|
628
|
+
const circularEdges = detectCircularDeps(edges);
|
|
629
|
+
|
|
630
|
+
// Layout: simple layered approach
|
|
631
|
+
const layout = computeLayout(Array.from(involvedNodes), edges, nodes);
|
|
632
|
+
|
|
633
|
+
// Render SVG
|
|
634
|
+
renderSvgGraph(layout, edges, nodes, circularEdges);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function detectCircularDeps(edges) {
|
|
638
|
+
const graph = new Map();
|
|
639
|
+
for (const edge of edges) {
|
|
640
|
+
if (!graph.has(edge.from)) graph.set(edge.from, []);
|
|
641
|
+
graph.get(edge.from).push(edge.to);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const circular = new Set();
|
|
645
|
+
const visited = new Set();
|
|
646
|
+
const recStack = new Set();
|
|
647
|
+
|
|
648
|
+
function dfs(node, path) {
|
|
649
|
+
visited.add(node);
|
|
650
|
+
recStack.add(node);
|
|
651
|
+
|
|
652
|
+
const neighbors = graph.get(node) || [];
|
|
653
|
+
for (const neighbor of neighbors) {
|
|
654
|
+
if (!visited.has(neighbor)) {
|
|
655
|
+
if (dfs(neighbor, [...path, node])) {
|
|
656
|
+
circular.add(`${node}->${neighbor}`);
|
|
657
|
+
}
|
|
658
|
+
} else if (recStack.has(neighbor)) {
|
|
659
|
+
// Found cycle
|
|
660
|
+
circular.add(`${node}->${neighbor}`);
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
recStack.delete(node);
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
for (const node of graph.keys()) {
|
|
670
|
+
if (!visited.has(node)) {
|
|
671
|
+
dfs(node, []);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
return circular;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function computeLayout(nodeRefs, edges, _nodesMap) {
|
|
679
|
+
// Compute in-degree for topological layering
|
|
680
|
+
// Edge semantics: {from: A, to: B} means "A depends on B"
|
|
681
|
+
// For layout: B should come before A (B is the root/dependency)
|
|
682
|
+
// So we reverse edge direction: treat as B -> A for layering
|
|
683
|
+
const inDegree = new Map();
|
|
684
|
+
const outEdges = new Map();
|
|
685
|
+
|
|
686
|
+
for (const ref of nodeRefs) {
|
|
687
|
+
inDegree.set(ref, 0);
|
|
688
|
+
outEdges.set(ref, []);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
for (const edge of edges) {
|
|
692
|
+
if (inDegree.has(edge.from) && inDegree.has(edge.to)) {
|
|
693
|
+
// Reverse: "from depends on to" means to -> from for layout
|
|
694
|
+
inDegree.set(edge.from, inDegree.get(edge.from) + 1);
|
|
695
|
+
outEdges.get(edge.to).push(edge.from);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Assign layers using Kahn's algorithm variant
|
|
700
|
+
const layers = [];
|
|
701
|
+
const assigned = new Set();
|
|
702
|
+
const remaining = new Set(nodeRefs);
|
|
703
|
+
|
|
704
|
+
while (remaining.size > 0) {
|
|
705
|
+
// Find nodes with 0 in-degree among remaining
|
|
706
|
+
const layer = [];
|
|
707
|
+
for (const ref of remaining) {
|
|
708
|
+
if (inDegree.get(ref) === 0) {
|
|
709
|
+
layer.push(ref);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// If no 0 in-degree nodes, pick one (handles cycles)
|
|
714
|
+
if (layer.length === 0) {
|
|
715
|
+
layer.push(remaining.values().next().value);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
layers.push(layer);
|
|
719
|
+
|
|
720
|
+
for (const ref of layer) {
|
|
721
|
+
assigned.add(ref);
|
|
722
|
+
remaining.delete(ref);
|
|
723
|
+
// Decrease in-degree of neighbors
|
|
724
|
+
for (const neighbor of outEdges.get(ref) || []) {
|
|
725
|
+
if (remaining.has(neighbor)) {
|
|
726
|
+
inDegree.set(neighbor, inDegree.get(neighbor) - 1);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Compute positions - larger boxes for title/description/status
|
|
733
|
+
const nodeWidth = 200;
|
|
734
|
+
const nodeHeight = 70;
|
|
735
|
+
const layerGap = 120;
|
|
736
|
+
const nodeGap = 40;
|
|
737
|
+
|
|
738
|
+
const positions = new Map();
|
|
739
|
+
let maxWidth = 0;
|
|
740
|
+
|
|
741
|
+
for (let i = 0; i < layers.length; i++) {
|
|
742
|
+
const layer = layers[i];
|
|
743
|
+
const layerWidth = layer.length * nodeWidth + (layer.length - 1) * nodeGap;
|
|
744
|
+
maxWidth = Math.max(maxWidth, layerWidth);
|
|
745
|
+
|
|
746
|
+
for (let j = 0; j < layer.length; j++) {
|
|
747
|
+
const ref = layer[j];
|
|
748
|
+
positions.set(ref, {
|
|
749
|
+
x: j * (nodeWidth + nodeGap) + nodeWidth / 2,
|
|
750
|
+
y: i * layerGap + nodeHeight / 2 + 40,
|
|
751
|
+
layer: i,
|
|
752
|
+
index: j,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// Center each layer
|
|
758
|
+
for (let i = 0; i < layers.length; i++) {
|
|
759
|
+
const layer = layers[i];
|
|
760
|
+
const layerWidth = layer.length * nodeWidth + (layer.length - 1) * nodeGap;
|
|
761
|
+
const offset = (maxWidth - layerWidth) / 2;
|
|
762
|
+
|
|
763
|
+
for (const ref of layer) {
|
|
764
|
+
const pos = positions.get(ref);
|
|
765
|
+
pos.x += offset;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return {
|
|
770
|
+
positions,
|
|
771
|
+
width: maxWidth + 80,
|
|
772
|
+
height: layers.length * layerGap + 80,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function renderSvgGraph(layout, edges, nodesMap, circularEdges) {
|
|
777
|
+
const { positions, width, height } = layout;
|
|
778
|
+
const boxWidth = 180;
|
|
779
|
+
const boxHeight = 60;
|
|
780
|
+
|
|
781
|
+
// Set SVG dimensions
|
|
782
|
+
elements.depGraphSvg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
|
783
|
+
elements.depGraphSvg.style.width = `${width}px`;
|
|
784
|
+
elements.depGraphSvg.style.height = `${height}px`;
|
|
785
|
+
|
|
786
|
+
let svg = "";
|
|
787
|
+
|
|
788
|
+
// Draw edges first (behind nodes) - simple connector lines
|
|
789
|
+
for (const edge of edges) {
|
|
790
|
+
// Edge: "from depends on to", but layout is reversed so "to" is above "from"
|
|
791
|
+
// Draw line from dependency (to) down to dependent (from)
|
|
792
|
+
const dependent = positions.get(edge.from);
|
|
793
|
+
const dependency = positions.get(edge.to);
|
|
794
|
+
if (!dependent || !dependency) continue;
|
|
795
|
+
|
|
796
|
+
const isCircular = circularEdges.has(`${edge.from}->${edge.to}`);
|
|
797
|
+
|
|
798
|
+
// Line from bottom of dependency to top of dependent
|
|
799
|
+
const startX = dependency.x;
|
|
800
|
+
const startY = dependency.y + boxHeight / 2;
|
|
801
|
+
const endX = dependent.x;
|
|
802
|
+
const endY = dependent.y - boxHeight / 2;
|
|
803
|
+
|
|
804
|
+
// Simple path: vertical down, then horizontal if needed, then vertical to target
|
|
805
|
+
let path;
|
|
806
|
+
if (startX === endX) {
|
|
807
|
+
// Straight vertical line
|
|
808
|
+
path = `M ${startX} ${startY} L ${endX} ${endY}`;
|
|
809
|
+
} else {
|
|
810
|
+
// Step connector: down, across, down
|
|
811
|
+
const midY = (startY + endY) / 2;
|
|
812
|
+
path = `M ${startX} ${startY} L ${startX} ${midY} L ${endX} ${midY} L ${endX} ${endY}`;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
svg += `
|
|
816
|
+
<path class="edge ${isCircular ? "circular" : ""}" d="${path}" />
|
|
817
|
+
<circle cx="${startX}" cy="${startY}" r="3" class="edge-dot" />
|
|
818
|
+
<circle cx="${endX}" cy="${endY}" r="3" class="edge-dot ${isCircular ? "circular" : ""}" />
|
|
819
|
+
`;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Draw nodes
|
|
823
|
+
for (const [ref, pos] of positions) {
|
|
824
|
+
const node = nodesMap.get(ref);
|
|
825
|
+
const title = node?.title || ref;
|
|
826
|
+
const status = node?.status || "PENDING";
|
|
827
|
+
const statusClass = getStatusClass(status);
|
|
828
|
+
|
|
829
|
+
// Truncate title for display
|
|
830
|
+
const truncatedTitle =
|
|
831
|
+
title.length > 22 ? `${title.substring(0, 22)}...` : title;
|
|
832
|
+
|
|
833
|
+
// Format status for display
|
|
834
|
+
const statusDisplay = status.replace(/_/g, " ");
|
|
835
|
+
|
|
836
|
+
svg += `
|
|
837
|
+
<g class="node" data-ref="${ref}" onclick="selectNodeFromGraph('${ref}')">
|
|
838
|
+
<rect
|
|
839
|
+
x="${pos.x - boxWidth / 2}"
|
|
840
|
+
y="${pos.y - boxHeight / 2}"
|
|
841
|
+
width="${boxWidth}"
|
|
842
|
+
height="${boxHeight}"
|
|
843
|
+
class="node-box ${statusClass}"
|
|
844
|
+
/>
|
|
845
|
+
<text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 18}" class="node-ref">${ref}</text>
|
|
846
|
+
<text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 34}" class="node-title">${escapeHtml(truncatedTitle)}</text>
|
|
847
|
+
<text x="${pos.x - boxWidth / 2 + 10}" y="${pos.y - boxHeight / 2 + 50}" class="node-status ${statusClass}">${statusDisplay}</text>
|
|
848
|
+
</g>
|
|
849
|
+
`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
elements.depGraphSvg.innerHTML = svg;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// Global function for graph node clicks
|
|
856
|
+
window.selectNodeFromGraph = (ref) => {
|
|
857
|
+
closeDepGraph();
|
|
858
|
+
loadContent(ref);
|
|
859
|
+
|
|
860
|
+
// Find and select the node in the tree
|
|
861
|
+
const node = elements.tree.querySelector(`[data-ref="${ref}"]`);
|
|
862
|
+
if (node) {
|
|
863
|
+
elements.tree.querySelectorAll(".tree-node-content").forEach((n) => {
|
|
864
|
+
n.classList.remove("selected");
|
|
865
|
+
});
|
|
866
|
+
node.classList.add("selected");
|
|
867
|
+
node.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
868
|
+
}
|
|
869
|
+
};
|