@syke1/mcp-server 1.0.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 +112 -0
- package/dist/ai/analyzer.d.ts +3 -0
- package/dist/ai/analyzer.js +120 -0
- package/dist/ai/realtime-analyzer.d.ts +20 -0
- package/dist/ai/realtime-analyzer.js +182 -0
- package/dist/graph.d.ts +13 -0
- package/dist/graph.js +105 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +518 -0
- package/dist/languages/cpp.d.ts +2 -0
- package/dist/languages/cpp.js +109 -0
- package/dist/languages/dart.d.ts +2 -0
- package/dist/languages/dart.js +162 -0
- package/dist/languages/go.d.ts +2 -0
- package/dist/languages/go.js +111 -0
- package/dist/languages/java.d.ts +2 -0
- package/dist/languages/java.js +113 -0
- package/dist/languages/plugin.d.ts +20 -0
- package/dist/languages/plugin.js +148 -0
- package/dist/languages/python.d.ts +2 -0
- package/dist/languages/python.js +129 -0
- package/dist/languages/ruby.d.ts +2 -0
- package/dist/languages/ruby.js +97 -0
- package/dist/languages/rust.d.ts +2 -0
- package/dist/languages/rust.js +121 -0
- package/dist/languages/typescript.d.ts +2 -0
- package/dist/languages/typescript.js +138 -0
- package/dist/license/validator.d.ts +23 -0
- package/dist/license/validator.js +297 -0
- package/dist/tools/analyze-impact.d.ts +23 -0
- package/dist/tools/analyze-impact.js +102 -0
- package/dist/tools/gate-build.d.ts +25 -0
- package/dist/tools/gate-build.js +243 -0
- package/dist/watcher/file-cache.d.ts +56 -0
- package/dist/watcher/file-cache.js +241 -0
- package/dist/web/public/app.js +2398 -0
- package/dist/web/public/index.html +258 -0
- package/dist/web/public/style.css +1827 -0
- package/dist/web/server.d.ts +29 -0
- package/dist/web/server.js +744 -0
- package/package.json +50 -0
|
@@ -0,0 +1,2398 @@
|
|
|
1
|
+
// SYKE Dashboard — Advanced v2.1
|
|
2
|
+
|
|
3
|
+
let Graph = null;
|
|
4
|
+
let graphData = null;
|
|
5
|
+
let selectedFile = null;
|
|
6
|
+
let autoRotate = false;
|
|
7
|
+
let highlightNodes = new Set();
|
|
8
|
+
let highlightLinks = new Set();
|
|
9
|
+
let selectedNodeId = null;
|
|
10
|
+
let hiddenLayers = new Set();
|
|
11
|
+
let hiddenNodes = new Set();
|
|
12
|
+
let pathMode = false;
|
|
13
|
+
let pathFrom = null;
|
|
14
|
+
let pathTo = null;
|
|
15
|
+
let contextNode = null;
|
|
16
|
+
let crawlAnimationId = null;
|
|
17
|
+
let crawlData = null;
|
|
18
|
+
let modifyingNodes = new Set(); // nodes currently being modified by AI
|
|
19
|
+
let heartbeatNodes = new Map(); // nodeId → { riskLevel, startTime, interval }
|
|
20
|
+
let diffScrollAnim = null; // animation for diff scroll
|
|
21
|
+
|
|
22
|
+
const LAYER_HEX = {
|
|
23
|
+
FE: "#00d4ff", BE: "#c084fc", DB: "#ff6b35",
|
|
24
|
+
API: "#00ffaa", CONFIG: "#ffd700", UTIL: "#ff69b4",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const LAYER_KEYS = ["FE", "BE", "DB", "API", "CONFIG", "UTIL"];
|
|
28
|
+
|
|
29
|
+
const LAYER_CENTERS = {
|
|
30
|
+
FE: { x: -800, y: 300, z: -300 },
|
|
31
|
+
BE: { x: 800, y: 300, z: 300 },
|
|
32
|
+
DB: { x: 0, y: -700, z: 600 },
|
|
33
|
+
API: { x: 700, y: -400, z: -600 },
|
|
34
|
+
CONFIG: { x: -700, y: 800, z: 500 },
|
|
35
|
+
UTIL: { x: 0, y: 50, z: 0 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ═══════════════════════════════════════════
|
|
39
|
+
// INIT
|
|
40
|
+
// ═══════════════════════════════════════════
|
|
41
|
+
document.addEventListener("DOMContentLoaded", async () => {
|
|
42
|
+
console.log("[SYKE] init v2.1");
|
|
43
|
+
await loadProjectInfo();
|
|
44
|
+
await loadGraph();
|
|
45
|
+
await loadHubFiles();
|
|
46
|
+
setupEventListeners();
|
|
47
|
+
setupKeyboardShortcuts();
|
|
48
|
+
setupContextMenu();
|
|
49
|
+
setupTabs();
|
|
50
|
+
setupProjectModal();
|
|
51
|
+
initSSE();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ═══════════════════════════════════════════
|
|
55
|
+
// GRAPH LOADING
|
|
56
|
+
// ═══════════════════════════════════════════
|
|
57
|
+
async function loadGraph() {
|
|
58
|
+
const res = await fetch("/api/graph");
|
|
59
|
+
const raw = await res.json();
|
|
60
|
+
console.log("[SYKE]", raw.nodes.length, "nodes", raw.edges.length, "edges");
|
|
61
|
+
|
|
62
|
+
const nodes = raw.nodes.map(n => {
|
|
63
|
+
const layer = n.data.layer || "UTIL";
|
|
64
|
+
const c = LAYER_CENTERS[layer] || LAYER_CENTERS.UTIL;
|
|
65
|
+
return {
|
|
66
|
+
id: n.data.id, label: n.data.label, fullPath: n.data.fullPath,
|
|
67
|
+
riskLevel: n.data.riskLevel, dependentCount: n.data.dependentCount,
|
|
68
|
+
lineCount: n.data.lineCount || 0, importsCount: n.data.importsCount || 0,
|
|
69
|
+
depth: n.data.depth || 0, group: n.data.group,
|
|
70
|
+
layer, action: n.data.action || "X", env: n.data.env || "PROD",
|
|
71
|
+
x: c.x + (Math.random() - 0.5) * 200,
|
|
72
|
+
y: c.y + (Math.random() - 0.5) * 200,
|
|
73
|
+
z: c.z + (Math.random() - 0.5) * 200,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
const links = raw.edges.map(e => ({ source: e.data.source, target: e.data.target }));
|
|
77
|
+
graphData = { nodes, links };
|
|
78
|
+
|
|
79
|
+
const layerCounts = {};
|
|
80
|
+
nodes.forEach(n => { layerCounts[n.layer] = (layerCounts[n.layer] || 0) + 1; });
|
|
81
|
+
|
|
82
|
+
document.getElementById("stat-files").textContent = nodes.length;
|
|
83
|
+
document.getElementById("stat-edges").textContent = links.length;
|
|
84
|
+
const highRisk = nodes.filter(n => n.riskLevel === "HIGH").length;
|
|
85
|
+
document.getElementById("stat-high").textContent = highRisk;
|
|
86
|
+
|
|
87
|
+
const container = document.getElementById("3d-graph");
|
|
88
|
+
|
|
89
|
+
Graph = ForceGraph3D()(container)
|
|
90
|
+
.width(window.innerWidth - 380)
|
|
91
|
+
.height(window.innerHeight - 100)
|
|
92
|
+
.graphData(graphData)
|
|
93
|
+
.backgroundColor("#050a18")
|
|
94
|
+
.showNavInfo(false)
|
|
95
|
+
|
|
96
|
+
.nodeColor(node => getNodeColor(node))
|
|
97
|
+
.nodeVal(node => {
|
|
98
|
+
if (!isNodeVisible(node)) return 0.001;
|
|
99
|
+
const base = Math.max(2, Math.sqrt(node.lineCount / 3));
|
|
100
|
+
const hb = heartbeatNodes.get(node.id);
|
|
101
|
+
if (hb) {
|
|
102
|
+
const elapsed = Date.now() - hb.startTime;
|
|
103
|
+
const period = hb.riskLevel === "CRITICAL" ? 400 : hb.riskLevel === "HIGH" ? 600 : 900;
|
|
104
|
+
const t = (elapsed % period) / period;
|
|
105
|
+
const spike = Math.max(0, 1 - Math.abs(t - 0.15) * 10, 1 - Math.abs(t - 0.35) * 12);
|
|
106
|
+
return base * (1 + spike * 0.6);
|
|
107
|
+
}
|
|
108
|
+
if (modifyingNodes.has(node.id)) {
|
|
109
|
+
const pulse = 0.5 + 0.5 * Math.sin(Date.now() / 200);
|
|
110
|
+
return base * (1 + pulse * 0.4);
|
|
111
|
+
}
|
|
112
|
+
return base;
|
|
113
|
+
})
|
|
114
|
+
.nodeOpacity(1.0)
|
|
115
|
+
.nodeResolution(16)
|
|
116
|
+
.nodeVisibility(node => isNodeVisible(node))
|
|
117
|
+
.nodeLabel(node => {
|
|
118
|
+
const c = LAYER_HEX[node.layer] || "#ccc";
|
|
119
|
+
return `<div style="background:rgba(5,10,24,0.95);border:1px solid ${c};padding:6px 12px;border-radius:3px;font-family:Consolas,monospace;font-size:12px;color:#fff;text-shadow:none">
|
|
120
|
+
<span style="color:${c};font-weight:700">[${node.layer}]</span> ${node.fullPath}<br>
|
|
121
|
+
<span style="color:${c}">${node.lineCount} lines</span> · ${node.dependentCount} deps · depth ${node.depth} · ${node.importsCount} imports · ${node.riskLevel}
|
|
122
|
+
</div>`;
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
.linkColor(link => getLinkColor(link))
|
|
126
|
+
.linkWidth(link => highlightLinks.has(link) ? 1.2 : 0.4)
|
|
127
|
+
.linkOpacity(0.8)
|
|
128
|
+
.linkVisibility(link => isLinkVisible(link))
|
|
129
|
+
.linkCurvature(link => 0.12 + (hc(getSrcId(link) + getTgtId(link)) % 20) * 0.01)
|
|
130
|
+
.linkCurveRotation(link => (hc(getTgtId(link) + getSrcId(link)) % 628) / 100)
|
|
131
|
+
|
|
132
|
+
.linkDirectionalParticles(link => {
|
|
133
|
+
if (highlightLinks.has(link)) return 10;
|
|
134
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
135
|
+
return isActive ? 1 : 4;
|
|
136
|
+
})
|
|
137
|
+
.linkDirectionalParticleWidth(link => {
|
|
138
|
+
if (highlightLinks.has(link)) return 2.0;
|
|
139
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
140
|
+
return isActive ? 0.3 : 0.8;
|
|
141
|
+
})
|
|
142
|
+
.linkDirectionalParticleSpeed(0.005)
|
|
143
|
+
.linkDirectionalParticleColor(link => {
|
|
144
|
+
if (highlightLinks.has(link)) return "#ff2d55";
|
|
145
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
146
|
+
if (isActive) return "rgba(100,120,150,0.15)";
|
|
147
|
+
return LAYER_HEX[srcLayer(link)] || "#ff69b4";
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
.linkDirectionalArrowLength(2)
|
|
151
|
+
.linkDirectionalArrowRelPos(1)
|
|
152
|
+
.linkDirectionalArrowColor(link => {
|
|
153
|
+
if (highlightLinks.has(link)) return "#ff2d55";
|
|
154
|
+
return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.4);
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
.onNodeClick(node => {
|
|
158
|
+
if (pathMode) { handlePathNodeClick(node); return; }
|
|
159
|
+
handleNodeClick(node);
|
|
160
|
+
})
|
|
161
|
+
.onNodeHover(node => handleNodeHover(node))
|
|
162
|
+
.onNodeRightClick((node, event) => showContextMenu(node, event))
|
|
163
|
+
.onBackgroundClick(() => { hideContextMenu(); handleBackgroundClick(); })
|
|
164
|
+
.onBackgroundRightClick(() => hideContextMenu())
|
|
165
|
+
|
|
166
|
+
.enableNodeDrag(true)
|
|
167
|
+
.onNodeDrag(node => { stopUser(); })
|
|
168
|
+
.onNodeDragEnd(node => {
|
|
169
|
+
node.fx = undefined;
|
|
170
|
+
node.fy = undefined;
|
|
171
|
+
node.fz = undefined;
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
.d3AlphaDecay(0.015)
|
|
175
|
+
.d3VelocityDecay(0.35)
|
|
176
|
+
.warmupTicks(100)
|
|
177
|
+
.cooldownTicks(500)
|
|
178
|
+
.enablePointerInteraction(true);
|
|
179
|
+
|
|
180
|
+
Graph.d3Force("cluster", clusterForce(0.25));
|
|
181
|
+
Graph.d3Force("charge").strength(-25);
|
|
182
|
+
Graph.d3Force("link")
|
|
183
|
+
.distance(l => srcLayer(l) === tgtLayer(l) ? 35 : 150)
|
|
184
|
+
.strength(l => srcLayer(l) === tgtLayer(l) ? 0.8 : 0.2);
|
|
185
|
+
|
|
186
|
+
Graph.cameraPosition({ x: 0, y: 0, z: 1600 });
|
|
187
|
+
|
|
188
|
+
setTimeout(() => {
|
|
189
|
+
try {
|
|
190
|
+
const scene = Graph.scene();
|
|
191
|
+
if (!scene) return;
|
|
192
|
+
scene.add(new THREE.AmbientLight(0xffffff, 8));
|
|
193
|
+
scene.add(new THREE.PointLight(0xffffff, 3, 5000));
|
|
194
|
+
scene.fog = new THREE.FogExp2(0x050a18, 0.00012);
|
|
195
|
+
console.log("[SYKE] Scene ready");
|
|
196
|
+
} catch(e) { console.warn(e); }
|
|
197
|
+
}, 500);
|
|
198
|
+
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
autoRotate = true;
|
|
201
|
+
document.getElementById("btn-auto-rotate").classList.add("active");
|
|
202
|
+
startAutoRotate();
|
|
203
|
+
}, 3500);
|
|
204
|
+
|
|
205
|
+
container.addEventListener("wheel", stopUser);
|
|
206
|
+
container.addEventListener("mousedown", stopUser);
|
|
207
|
+
container.addEventListener("touchstart", stopUser);
|
|
208
|
+
|
|
209
|
+
window.addEventListener("resize", () => {
|
|
210
|
+
if (Graph) Graph.width(window.innerWidth - 380).height(window.innerHeight - 100);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
buildLegend(layerCounts);
|
|
214
|
+
createNodeLabels();
|
|
215
|
+
updateLabelsLoop();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ═══════════════════════════════════════════
|
|
219
|
+
// VISIBILITY FILTERS
|
|
220
|
+
// ═══════════════════════════════════════════
|
|
221
|
+
function isNodeVisible(node) {
|
|
222
|
+
if (hiddenLayers.has(node.layer)) return false;
|
|
223
|
+
if (hiddenNodes.has(node.id)) return false;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function isLinkVisible(link) {
|
|
228
|
+
const src = getNodeById(getSrcId(link));
|
|
229
|
+
const tgt = getNodeById(getTgtId(link));
|
|
230
|
+
if (!src || !tgt) return false;
|
|
231
|
+
return isNodeVisible(src) && isNodeVisible(tgt);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getNodeById(id) {
|
|
235
|
+
return graphData.nodes.find(n => n.id === id);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ═══════════════════════════════════════════
|
|
239
|
+
// HTML NODE LABELS
|
|
240
|
+
// ═══════════════════════════════════════════
|
|
241
|
+
function createNodeLabels() {
|
|
242
|
+
const container = document.getElementById("node-labels");
|
|
243
|
+
container.innerHTML = "";
|
|
244
|
+
graphData.nodes.forEach(node => {
|
|
245
|
+
const el = document.createElement("div");
|
|
246
|
+
el.className = "node-lbl";
|
|
247
|
+
el.id = "lbl-" + node.id.replace(/[\/\\.]/g, "_");
|
|
248
|
+
const col = LAYER_HEX[node.layer] || "#00d4ff";
|
|
249
|
+
el.style.borderColor = col;
|
|
250
|
+
el.innerHTML = `<span style="color:${col}">${node.lineCount}L</span> ${node.dependentCount}D` +
|
|
251
|
+
`<br><span class="lbl-dim">dp${node.depth} im${node.importsCount}</span>`;
|
|
252
|
+
container.appendChild(el);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function updateLabelsLoop() {
|
|
257
|
+
if (!Graph) return;
|
|
258
|
+
const graphRect = document.getElementById("graph-panel").getBoundingClientRect();
|
|
259
|
+
|
|
260
|
+
graphData.nodes.forEach(node => {
|
|
261
|
+
const el = document.getElementById("lbl-" + node.id.replace(/[\/\\.]/g, "_"));
|
|
262
|
+
if (!el) return;
|
|
263
|
+
|
|
264
|
+
if (!isNodeVisible(node)) { el.style.display = "none"; return; }
|
|
265
|
+
|
|
266
|
+
const coords = Graph.graph2ScreenCoords(node.x || 0, node.y || 0, node.z || 0);
|
|
267
|
+
if (!coords) { el.style.display = "none"; return; }
|
|
268
|
+
|
|
269
|
+
const sx = coords.x;
|
|
270
|
+
const sy = coords.y;
|
|
271
|
+
|
|
272
|
+
if (sx < -50 || sx > graphRect.width + 50 || sy < -50 || sy > graphRect.height + 50) {
|
|
273
|
+
el.style.display = "none";
|
|
274
|
+
} else {
|
|
275
|
+
el.style.display = "";
|
|
276
|
+
el.style.left = sx + "px";
|
|
277
|
+
el.style.top = (sy - 18) + "px";
|
|
278
|
+
|
|
279
|
+
if (highlightNodes.size > 0 && !highlightNodes.has(node.id)) {
|
|
280
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
281
|
+
el.style.opacity = isActive ? "0.06" : "0.15";
|
|
282
|
+
} else {
|
|
283
|
+
el.style.opacity = "1";
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
requestAnimationFrame(updateLabelsLoop);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ═══════════════════════════════════════════
|
|
292
|
+
// COLORS
|
|
293
|
+
// ═══════════════════════════════════════════
|
|
294
|
+
function getNodeColor(node) {
|
|
295
|
+
// Heartbeat pulse: connected nodes pulsing by risk level
|
|
296
|
+
const hb = heartbeatNodes.get(node.id);
|
|
297
|
+
if (hb) {
|
|
298
|
+
const elapsed = Date.now() - hb.startTime;
|
|
299
|
+
// Heartbeat: sharp spike then fade, like a real heartbeat
|
|
300
|
+
const period = hb.riskLevel === "CRITICAL" ? 400 : hb.riskLevel === "HIGH" ? 600 : 900;
|
|
301
|
+
const t = (elapsed % period) / period;
|
|
302
|
+
// Double-spike heartbeat waveform
|
|
303
|
+
const spike1 = Math.max(0, 1 - Math.abs(t - 0.15) * 10);
|
|
304
|
+
const spike2 = Math.max(0, 1 - Math.abs(t - 0.35) * 12);
|
|
305
|
+
const beat = Math.max(spike1, spike2);
|
|
306
|
+
|
|
307
|
+
const colors = {
|
|
308
|
+
CRITICAL: [255, 0, 40],
|
|
309
|
+
HIGH: [255, 45, 85],
|
|
310
|
+
MEDIUM: [255, 159, 10],
|
|
311
|
+
LOW: [48, 209, 88],
|
|
312
|
+
SAFE: [48, 209, 88],
|
|
313
|
+
};
|
|
314
|
+
const [cr, cg, cb] = colors[hb.riskLevel] || colors.MEDIUM;
|
|
315
|
+
const base = LAYER_HEX[node.layer] || "#ff69b4";
|
|
316
|
+
const br = parseInt(base.slice(1,3),16);
|
|
317
|
+
const bg = parseInt(base.slice(3,5),16);
|
|
318
|
+
const bb = parseInt(base.slice(5,7),16);
|
|
319
|
+
const r = Math.round(br + (cr - br) * beat);
|
|
320
|
+
const g = Math.round(bg + (cg - bg) * beat);
|
|
321
|
+
const b = Math.round(bb + (cb - bb) * beat);
|
|
322
|
+
return `rgb(${r},${g},${b})`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// AI is modifying this node → bright pulsing white/orange
|
|
326
|
+
if (modifyingNodes.has(node.id)) {
|
|
327
|
+
const t = Date.now() / 200;
|
|
328
|
+
const pulse = 0.5 + 0.5 * Math.sin(t);
|
|
329
|
+
return `rgb(255,${Math.round(180 + pulse * 75)},${Math.round(50 + pulse * 50)})`;
|
|
330
|
+
}
|
|
331
|
+
if (node.id === selectedNodeId) return "#ffffff";
|
|
332
|
+
const base = LAYER_HEX[node.layer] || "#ff69b4";
|
|
333
|
+
if (highlightNodes.size > 0 && !highlightNodes.has(node.id)) {
|
|
334
|
+
// When actively modifying → dim harder so protagonists stand out
|
|
335
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
336
|
+
return dimHex(base, isActive ? 0.12 : 0.35);
|
|
337
|
+
}
|
|
338
|
+
return base;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Start heartbeat on connected nodes with risk-based coloring
|
|
342
|
+
function startHeartbeat(nodeIds, riskLevel) {
|
|
343
|
+
const now = Date.now();
|
|
344
|
+
for (const id of nodeIds) {
|
|
345
|
+
heartbeatNodes.set(id, { riskLevel, startTime: now });
|
|
346
|
+
}
|
|
347
|
+
// Ensure continuous visual refresh for heartbeat
|
|
348
|
+
if (!window._heartbeatRAF) {
|
|
349
|
+
function heartbeatLoop() {
|
|
350
|
+
if (heartbeatNodes.size > 0 && Graph) {
|
|
351
|
+
Graph.nodeColor(Graph.nodeColor());
|
|
352
|
+
Graph.nodeVal(Graph.nodeVal());
|
|
353
|
+
}
|
|
354
|
+
window._heartbeatRAF = requestAnimationFrame(heartbeatLoop);
|
|
355
|
+
}
|
|
356
|
+
heartbeatLoop();
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function stopHeartbeat(nodeIds) {
|
|
361
|
+
for (const id of nodeIds) {
|
|
362
|
+
heartbeatNodes.delete(id);
|
|
363
|
+
}
|
|
364
|
+
if (heartbeatNodes.size === 0 && window._heartbeatRAF) {
|
|
365
|
+
cancelAnimationFrame(window._heartbeatRAF);
|
|
366
|
+
window._heartbeatRAF = null;
|
|
367
|
+
// Restore normal brightness when all heartbeats done
|
|
368
|
+
refreshGraph();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function stopAllHeartbeats() {
|
|
373
|
+
heartbeatNodes.clear();
|
|
374
|
+
if (window._heartbeatRAF) {
|
|
375
|
+
cancelAnimationFrame(window._heartbeatRAF);
|
|
376
|
+
window._heartbeatRAF = null;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Focus camera on a specific node (smooth transition)
|
|
381
|
+
function focusCameraOnNode(nodeId) {
|
|
382
|
+
const node = graphData?.nodes.find(n => n.id === nodeId);
|
|
383
|
+
if (!node || !Graph) return;
|
|
384
|
+
stopUser(); // stop auto-rotate
|
|
385
|
+
const nx = node.x || 0, ny = node.y || 0, nz = node.z || 0;
|
|
386
|
+
const d = Math.max(1, Math.hypot(nx, ny, nz));
|
|
387
|
+
Graph.cameraPosition(
|
|
388
|
+
{ x: nx + 200 * nx / d, y: ny + 200 * ny / d, z: nz + 200 * nz / d },
|
|
389
|
+
{ x: nx, y: ny, z: nz },
|
|
390
|
+
1200
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function dimHex(hex, factor) {
|
|
395
|
+
const r = Math.round(parseInt(hex.slice(1,3),16) * factor);
|
|
396
|
+
const g = Math.round(parseInt(hex.slice(3,5),16) * factor);
|
|
397
|
+
const b = Math.round(parseInt(hex.slice(5,7),16) * factor);
|
|
398
|
+
return `#${r.toString(16).padStart(2,"0")}${g.toString(16).padStart(2,"0")}${b.toString(16).padStart(2,"0")}`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getLinkColor(link) {
|
|
402
|
+
if (highlightLinks.has(link)) return "#ff2d55";
|
|
403
|
+
if (highlightNodes.size > 0 && !highlightLinks.has(link)) {
|
|
404
|
+
const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
|
|
405
|
+
return isActive ? "rgba(60,70,90,0.03)" : "rgba(100,120,150,0.06)";
|
|
406
|
+
}
|
|
407
|
+
return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.25);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ═══════════════════════════════════════════
|
|
411
|
+
// CLUSTER FORCE
|
|
412
|
+
// ═══════════════════════════════════════════
|
|
413
|
+
function clusterForce(str) {
|
|
414
|
+
const f = (alpha) => {
|
|
415
|
+
if (!graphData) return;
|
|
416
|
+
const k = alpha * str;
|
|
417
|
+
for (const n of graphData.nodes) {
|
|
418
|
+
const c = LAYER_CENTERS[n.layer] || LAYER_CENTERS.UTIL;
|
|
419
|
+
if (n.x != null) n.vx = (n.vx || 0) + (c.x - n.x) * k;
|
|
420
|
+
if (n.y != null) n.vy = (n.vy || 0) + (c.y - n.y) * k;
|
|
421
|
+
if (n.z != null) n.vz = (n.vz || 0) + (c.z - n.z) * k;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
f.initialize = () => {};
|
|
425
|
+
return f;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ═══════════════════════════════════════════
|
|
429
|
+
// HELPERS
|
|
430
|
+
// ═══════════════════════════════════════════
|
|
431
|
+
function getSrcId(l) { return (typeof l.source === "object" && l.source) ? l.source.id : l.source; }
|
|
432
|
+
function getTgtId(l) { return (typeof l.target === "object" && l.target) ? l.target.id : l.target; }
|
|
433
|
+
function srcLayer(l) { const n = graphData.nodes.find(x => x.id === getSrcId(l)); return n ? n.layer : "UTIL"; }
|
|
434
|
+
function tgtLayer(l) { const n = graphData.nodes.find(x => x.id === getTgtId(l)); return n ? n.layer : "UTIL"; }
|
|
435
|
+
function hc(s) { let h=0; for(let i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h|=0;} return Math.abs(h); }
|
|
436
|
+
function rgba(hex,a) { return `rgba(${parseInt(hex.slice(1,3),16)},${parseInt(hex.slice(3,5),16)},${parseInt(hex.slice(5,7),16)},${a})`; }
|
|
437
|
+
|
|
438
|
+
// ═══════════════════════════════════════════
|
|
439
|
+
// LEGEND (clickable layer filter)
|
|
440
|
+
// ═══════════════════════════════════════════
|
|
441
|
+
function buildLegend(counts) {
|
|
442
|
+
const el = document.getElementById("layer-legend");
|
|
443
|
+
if (!el) return;
|
|
444
|
+
let h = "";
|
|
445
|
+
Object.entries(LAYER_HEX).forEach(([layer, color]) => {
|
|
446
|
+
const c = counts[layer] || 0;
|
|
447
|
+
if (!c) return;
|
|
448
|
+
const filtered = hiddenLayers.has(layer) ? " filtered" : "";
|
|
449
|
+
h += `<div class="legend-item${filtered}" data-layer="${layer}">
|
|
450
|
+
<span class="legend-dot" style="background:${color};box-shadow:0 0 10px ${color}"></span>
|
|
451
|
+
<span class="legend-label" style="color:${color}">${layer}</span>
|
|
452
|
+
<span class="legend-count">${c}</span></div>`;
|
|
453
|
+
});
|
|
454
|
+
el.innerHTML = h;
|
|
455
|
+
el.querySelectorAll(".legend-item").forEach(item => {
|
|
456
|
+
item.addEventListener("click", (e) => {
|
|
457
|
+
if (e.shiftKey) {
|
|
458
|
+
// Shift+click: isolate this layer only
|
|
459
|
+
const layer = item.dataset.layer;
|
|
460
|
+
const allHidden = LAYER_KEYS.every(l => l === layer || hiddenLayers.has(l));
|
|
461
|
+
if (allHidden) {
|
|
462
|
+
// All others hidden → show all
|
|
463
|
+
hiddenLayers.clear();
|
|
464
|
+
} else {
|
|
465
|
+
hiddenLayers.clear();
|
|
466
|
+
LAYER_KEYS.forEach(l => { if (l !== layer) hiddenLayers.add(l); });
|
|
467
|
+
}
|
|
468
|
+
} else {
|
|
469
|
+
// Regular click: toggle this layer
|
|
470
|
+
const layer = item.dataset.layer;
|
|
471
|
+
if (hiddenLayers.has(layer)) {
|
|
472
|
+
hiddenLayers.delete(layer);
|
|
473
|
+
} else {
|
|
474
|
+
hiddenLayers.add(layer);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Update legend visual
|
|
478
|
+
el.querySelectorAll(".legend-item").forEach(li => {
|
|
479
|
+
li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
|
|
480
|
+
});
|
|
481
|
+
refreshGraph();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ═══════════════════════════════════════════
|
|
487
|
+
// AUTO-ROTATE
|
|
488
|
+
// ═══════════════════════════════════════════
|
|
489
|
+
let rotAngle = 0, rotRAF = null;
|
|
490
|
+
function startAutoRotate() {
|
|
491
|
+
if (!autoRotate || !Graph) return;
|
|
492
|
+
rotAngle += 0.0005;
|
|
493
|
+
Graph.cameraPosition({
|
|
494
|
+
x: 1600 * Math.sin(rotAngle),
|
|
495
|
+
y: 200 + Math.sin(rotAngle * 0.3) * 100,
|
|
496
|
+
z: 1600 * Math.cos(rotAngle),
|
|
497
|
+
});
|
|
498
|
+
rotRAF = requestAnimationFrame(startAutoRotate);
|
|
499
|
+
}
|
|
500
|
+
function stopAutoRotate() { if (rotRAF) { cancelAnimationFrame(rotRAF); rotRAF = null; } }
|
|
501
|
+
function stopUser() {
|
|
502
|
+
if (autoRotate) {
|
|
503
|
+
autoRotate = false; stopAutoRotate();
|
|
504
|
+
document.getElementById("btn-auto-rotate").classList.remove("active");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ═══════════════════════════════════════════
|
|
509
|
+
// HOVER
|
|
510
|
+
// ═══════════════════════════════════════════
|
|
511
|
+
function handleNodeHover(node) {
|
|
512
|
+
const tt = document.getElementById("node-tooltip");
|
|
513
|
+
if (node) {
|
|
514
|
+
document.getElementById("3d-graph").style.cursor = pathMode ? "crosshair" : "pointer";
|
|
515
|
+
tt.classList.remove("hidden");
|
|
516
|
+
const c = LAYER_HEX[node.layer] || "#ccc";
|
|
517
|
+
tt.innerHTML = `<span style="color:${c};font-weight:700">[${node.layer}]</span> ${node.fullPath} <span style="color:${c}">${node.lineCount}L</span> ${node.dependentCount}D`;
|
|
518
|
+
} else {
|
|
519
|
+
document.getElementById("3d-graph").style.cursor = pathMode ? "crosshair" : "grab";
|
|
520
|
+
tt.classList.add("hidden");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
document.addEventListener("mousemove", e => {
|
|
524
|
+
const t = document.getElementById("node-tooltip");
|
|
525
|
+
if (!t.classList.contains("hidden")) { t.style.left=(e.clientX+16)+"px"; t.style.top=(e.clientY-10)+"px"; }
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
// ═══════════════════════════════════════════
|
|
529
|
+
// CLICK HANDLING
|
|
530
|
+
// ═══════════════════════════════════════════
|
|
531
|
+
async function handleNodeClick(node) {
|
|
532
|
+
if (!node) return;
|
|
533
|
+
hideContextMenu();
|
|
534
|
+
selectedFile = node.id; selectedNodeId = node.id;
|
|
535
|
+
stopUser();
|
|
536
|
+
try {
|
|
537
|
+
const nx=node.x||0, ny=node.y||0, nz=node.z||0;
|
|
538
|
+
const d = Math.max(1, Math.hypot(nx,ny,nz));
|
|
539
|
+
Graph.cameraPosition(
|
|
540
|
+
{ x: nx+150*nx/d, y: ny+150*ny/d, z: nz+150*nz/d },
|
|
541
|
+
{ x: nx, y: ny, z: nz }, 1200
|
|
542
|
+
);
|
|
543
|
+
} catch(e) {}
|
|
544
|
+
refreshGraph();
|
|
545
|
+
await showImpact(node.id, node);
|
|
546
|
+
// Auto-load code preview
|
|
547
|
+
loadCodePreview(node.id);
|
|
548
|
+
// Auto-load simulation
|
|
549
|
+
loadSimulation(node.id);
|
|
550
|
+
// Start Star Wars code crawl
|
|
551
|
+
startCodeCrawl(node.id);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function handleBackgroundClick() {
|
|
555
|
+
selectedFile = null; selectedNodeId = null;
|
|
556
|
+
highlightNodes.clear(); highlightLinks.clear();
|
|
557
|
+
document.getElementById("file-info-content").innerHTML = '<p class="placeholder">Select a node to identify target</p>';
|
|
558
|
+
document.getElementById("impact-content").innerHTML = '<p class="placeholder">Select a node to trace impact chain</p>';
|
|
559
|
+
document.getElementById("ai-content").innerHTML = '<p class="placeholder">Select target, then request AI analysis</p>';
|
|
560
|
+
document.getElementById("code-content").innerHTML = '<p class="placeholder">Select a node to preview source code</p>';
|
|
561
|
+
document.getElementById("sim-content").innerHTML = '<p class="placeholder">Select a node, then switch to SIM tab</p>';
|
|
562
|
+
document.getElementById("btn-ai-analyze").disabled = true;
|
|
563
|
+
stopCodeCrawl();
|
|
564
|
+
refreshGraph();
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ═══════════════════════════════════════════
|
|
568
|
+
// IMPACT ANALYSIS
|
|
569
|
+
// ═══════════════════════════════════════════
|
|
570
|
+
async function showImpact(fileId, nd) {
|
|
571
|
+
const col = LAYER_HEX[nd.layer] || "#999";
|
|
572
|
+
document.getElementById("file-info-content").innerHTML = `
|
|
573
|
+
<div class="file-detail"><span class="label">PATH </span><span class="value">${nd.fullPath||fileId}</span></div>
|
|
574
|
+
<div class="file-detail"><span class="label">LAYER </span><span class="layer-badge" style="color:${col};border-color:${col}">${nd.layer}</span>
|
|
575
|
+
<span class="label">LINES </span><span class="value">${nd.lineCount}</span>
|
|
576
|
+
<span class="label">ACTION </span><span class="value">${nd.action}</span></div>
|
|
577
|
+
<div class="file-detail"><span class="label">RISK </span><span class="risk-badge ${nd.riskLevel}">${nd.riskLevel}</span>
|
|
578
|
+
<span class="label">DEPS </span><span class="value">${nd.dependentCount}</span>
|
|
579
|
+
<span class="label">DEPTH </span><span class="value">${nd.depth}</span>
|
|
580
|
+
<span class="label">IMPORTS </span><span class="value">${nd.importsCount}</span></div>`;
|
|
581
|
+
document.getElementById("btn-ai-analyze").disabled = false;
|
|
582
|
+
document.getElementById("ai-content").innerHTML = '<p class="placeholder">Click ANALYZE for AI intel</p>';
|
|
583
|
+
document.getElementById("impact-content").innerHTML = '<div class="loading"><div class="spinner"></div>TRACING...</div>';
|
|
584
|
+
|
|
585
|
+
try {
|
|
586
|
+
const res = await fetch("/api/impact/" + fileId);
|
|
587
|
+
const impact = await res.json();
|
|
588
|
+
if (!res.ok) { document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: ${impact.error}</p>`; return; }
|
|
589
|
+
|
|
590
|
+
highlightNodes.clear(); highlightLinks.clear(); highlightNodes.add(fileId);
|
|
591
|
+
if (impact.directDependents) impact.directDependents.forEach(d => highlightNodes.add(d));
|
|
592
|
+
if (impact.transitiveDependents) impact.transitiveDependents.forEach(t => highlightNodes.add(t));
|
|
593
|
+
graphData.links.forEach(l => {
|
|
594
|
+
if (highlightNodes.has(getSrcId(l)) && highlightNodes.has(getTgtId(l))) highlightLinks.add(l);
|
|
595
|
+
});
|
|
596
|
+
refreshGraph();
|
|
597
|
+
|
|
598
|
+
let html = `<div class="file-detail"><span class="label">IMPACTED </span><span class="value" style="color:#ff2d55">${impact.totalImpacted}</span></div>`;
|
|
599
|
+
if (impact.directDependents && impact.directDependents.length > 0) {
|
|
600
|
+
html += `<div style="margin-top:10px;font-size:10px;color:#ff2d55;font-weight:600;letter-spacing:2px">DIRECT (${impact.directDependents.length})</div>`;
|
|
601
|
+
for (const d of impact.directDependents) {
|
|
602
|
+
const dn = graphData.nodes.find(n => n.id === d);
|
|
603
|
+
const dc = dn ? LAYER_HEX[dn.layer]||"#999" : "#999";
|
|
604
|
+
html += `<div class="impact-item direct" data-file="${d}"><span style="color:${dc};margin-right:4px">[${dn?dn.layer:"?"}]</span>${d}</div>`;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (impact.transitiveDependents && impact.transitiveDependents.length > 0) {
|
|
608
|
+
html += `<div style="margin-top:10px;font-size:10px;color:#ff9f0a;font-weight:600;letter-spacing:2px">TRANSITIVE (${impact.transitiveDependents.length})</div>`;
|
|
609
|
+
for (const t of impact.transitiveDependents) {
|
|
610
|
+
const tn = graphData.nodes.find(n => n.id === t);
|
|
611
|
+
const tc = tn ? LAYER_HEX[tn.layer]||"#999" : "#999";
|
|
612
|
+
html += `<div class="impact-item transitive" data-file="${t}"><span style="color:${tc};margin-right:4px">[${tn?tn.layer:"?"}]</span>${t}</div>`;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (impact.totalImpacted === 0) html += '<p class="placeholder" style="margin-top:10px">SAFE TO MODIFY.</p>';
|
|
616
|
+
document.getElementById("impact-content").innerHTML = html;
|
|
617
|
+
document.querySelectorAll(".impact-item").forEach(el => {
|
|
618
|
+
el.addEventListener("click", () => {
|
|
619
|
+
const tn = graphData.nodes.find(n => n.id === el.dataset.file);
|
|
620
|
+
if (tn && Graph) {
|
|
621
|
+
const d = Math.max(1, Math.hypot(tn.x||1,tn.y||1,tn.z||1));
|
|
622
|
+
Graph.cameraPosition(
|
|
623
|
+
{x:(tn.x||0)+100*(tn.x||1)/d, y:(tn.y||0)+100*(tn.y||1)/d, z:(tn.z||0)+100*(tn.z||1)/d},
|
|
624
|
+
{x:tn.x||0, y:tn.y||0, z:tn.z||0}, 800
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
} catch(err) {
|
|
630
|
+
document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: ${err.message}</p>`;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function refreshGraph() {
|
|
635
|
+
if (!Graph) return;
|
|
636
|
+
Graph.nodeColor(Graph.nodeColor())
|
|
637
|
+
.nodeVal(Graph.nodeVal())
|
|
638
|
+
.nodeVisibility(Graph.nodeVisibility())
|
|
639
|
+
.linkColor(Graph.linkColor()).linkWidth(Graph.linkWidth())
|
|
640
|
+
.linkVisibility(Graph.linkVisibility())
|
|
641
|
+
.linkDirectionalParticles(Graph.linkDirectionalParticles())
|
|
642
|
+
.linkDirectionalParticleWidth(Graph.linkDirectionalParticleWidth())
|
|
643
|
+
.linkDirectionalParticleColor(Graph.linkDirectionalParticleColor())
|
|
644
|
+
.linkDirectionalArrowColor(Graph.linkDirectionalArrowColor());
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ═══════════════════════════════════════════
|
|
648
|
+
// AI ANALYSIS
|
|
649
|
+
// ═══════════════════════════════════════════
|
|
650
|
+
async function runAIAnalysis() {
|
|
651
|
+
if (!selectedFile) return;
|
|
652
|
+
const p = document.getElementById("ai-content");
|
|
653
|
+
p.innerHTML = '<div class="loading"><div class="spinner"></div>GEMINI AI PROCESSING...</div>';
|
|
654
|
+
try {
|
|
655
|
+
const r = await fetch("/api/ai-analyze", {
|
|
656
|
+
method: "POST", headers: {"Content-Type":"application/json"},
|
|
657
|
+
body: JSON.stringify({ file: selectedFile }),
|
|
658
|
+
});
|
|
659
|
+
const j = await r.json();
|
|
660
|
+
p.innerHTML = r.ok ? renderMD(j.analysis) : `<p class="placeholder">ERROR: ${j.error}</p>`;
|
|
661
|
+
} catch(e) { p.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`; }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function renderMD(t) {
|
|
665
|
+
return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
|
666
|
+
.replace(/^## (.+)$/gm,"<h2>$1</h2>").replace(/^### (.+)$/gm,"<h3>$1</h3>")
|
|
667
|
+
.replace(/\*\*(.+?)\*\*/g,"<strong>$1</strong>").replace(/`([^`]+)`/g,"<code>$1</code>")
|
|
668
|
+
.replace(/^- (.+)$/gm,"<li>$1</li>").replace(/(<li>.*<\/li>)/gs,"<ul>$1</ul>")
|
|
669
|
+
.replace(/\n\n/g,"<br><br>").replace(/\n/g,"<br>");
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ═══════════════════════════════════════════
|
|
673
|
+
// CODE PREVIEW
|
|
674
|
+
// ═══════════════════════════════════════════
|
|
675
|
+
async function loadCodePreview(fileId) {
|
|
676
|
+
const el = document.getElementById("code-content");
|
|
677
|
+
el.innerHTML = '<div class="loading"><div class="spinner"></div>LOADING...</div>';
|
|
678
|
+
try {
|
|
679
|
+
const res = await fetch("/api/file-content/" + fileId);
|
|
680
|
+
const data = await res.json();
|
|
681
|
+
if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
|
|
682
|
+
|
|
683
|
+
const lines = data.content.split("\n");
|
|
684
|
+
let html = '<div class="code-block">';
|
|
685
|
+
lines.forEach((line, i) => {
|
|
686
|
+
const escaped = line.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
|
687
|
+
html += `<span class="line-num">${i+1}</span>${escaped}\n`;
|
|
688
|
+
});
|
|
689
|
+
html += '</div>';
|
|
690
|
+
if (data.truncated) html += '<p class="placeholder" style="margin-top:8px">File truncated at 500 lines</p>';
|
|
691
|
+
el.innerHTML = html;
|
|
692
|
+
} catch(e) {
|
|
693
|
+
el.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ═══════════════════════════════════════════
|
|
698
|
+
// DELETION SIMULATION
|
|
699
|
+
// ═══════════════════════════════════════════
|
|
700
|
+
async function loadSimulation(fileId) {
|
|
701
|
+
const el = document.getElementById("sim-content");
|
|
702
|
+
el.innerHTML = '<div class="loading"><div class="spinner"></div>SIMULATING...</div>';
|
|
703
|
+
try {
|
|
704
|
+
const res = await fetch("/api/simulate-delete/" + fileId);
|
|
705
|
+
const data = await res.json();
|
|
706
|
+
if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
|
|
707
|
+
|
|
708
|
+
let html = `<div class="file-detail"><span class="label">TARGET </span><span class="value">${data.deletedFile}</span></div>`;
|
|
709
|
+
html += `<div class="sim-severity ${data.severity}">${data.severity} IMPACT</div>`;
|
|
710
|
+
|
|
711
|
+
html += '<div class="sim-section"><h4>BROKEN IMPORTS (' + data.brokenCount + ')</h4>';
|
|
712
|
+
if (data.brokenImports.length === 0) {
|
|
713
|
+
html += '<p class="placeholder">No files import this directly</p>';
|
|
714
|
+
} else {
|
|
715
|
+
data.brokenImports.forEach(f => {
|
|
716
|
+
html += `<div class="sim-file" data-file="${f}">${f}</div>`;
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
html += '</div>';
|
|
720
|
+
|
|
721
|
+
html += '<div class="sim-section"><h4>CASCADE AFFECTED (' + data.cascadeCount + ')</h4>';
|
|
722
|
+
if (data.cascadeFiles.length === 0) {
|
|
723
|
+
html += '<p class="placeholder">No cascade impact</p>';
|
|
724
|
+
} else {
|
|
725
|
+
data.cascadeFiles.slice(0, 30).forEach(f => {
|
|
726
|
+
html += `<div class="sim-file" data-file="${f}">${f}</div>`;
|
|
727
|
+
});
|
|
728
|
+
if (data.cascadeFiles.length > 30) html += `<p class="placeholder">...and ${data.cascadeFiles.length - 30} more</p>`;
|
|
729
|
+
}
|
|
730
|
+
html += '</div>';
|
|
731
|
+
|
|
732
|
+
if (data.orphanedCount > 0) {
|
|
733
|
+
html += '<div class="sim-section"><h4>ORPHANED FILES (' + data.orphanedCount + ')</h4>';
|
|
734
|
+
data.orphanedFiles.forEach(f => {
|
|
735
|
+
html += `<div class="sim-file" data-file="${f}">${f}</div>`;
|
|
736
|
+
});
|
|
737
|
+
html += '</div>';
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
el.innerHTML = html;
|
|
741
|
+
|
|
742
|
+
// Click to navigate
|
|
743
|
+
el.querySelectorAll(".sim-file").forEach(sf => {
|
|
744
|
+
sf.addEventListener("click", () => {
|
|
745
|
+
const node = graphData.nodes.find(n => n.id === sf.dataset.file);
|
|
746
|
+
if (node) handleNodeClick(node);
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
} catch(e) {
|
|
750
|
+
el.innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ═══════════════════════════════════════════
|
|
755
|
+
// PATH MODE (shortest path between two nodes)
|
|
756
|
+
// ═══════════════════════════════════════════
|
|
757
|
+
function enterPathMode() {
|
|
758
|
+
pathMode = true;
|
|
759
|
+
pathFrom = null;
|
|
760
|
+
pathTo = null;
|
|
761
|
+
document.getElementById("btn-path-mode").classList.add("active");
|
|
762
|
+
document.getElementById("path-indicator").classList.remove("hidden");
|
|
763
|
+
document.getElementById("path-status").textContent = "SELECT FIRST NODE";
|
|
764
|
+
document.getElementById("3d-graph").style.cursor = "crosshair";
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function exitPathMode() {
|
|
768
|
+
pathMode = false;
|
|
769
|
+
pathFrom = null;
|
|
770
|
+
pathTo = null;
|
|
771
|
+
document.getElementById("btn-path-mode").classList.remove("active");
|
|
772
|
+
document.getElementById("path-indicator").classList.add("hidden");
|
|
773
|
+
document.getElementById("3d-graph").style.cursor = "grab";
|
|
774
|
+
highlightNodes.clear();
|
|
775
|
+
highlightLinks.clear();
|
|
776
|
+
selectedNodeId = null;
|
|
777
|
+
refreshGraph();
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function handlePathNodeClick(node) {
|
|
781
|
+
if (!pathFrom) {
|
|
782
|
+
pathFrom = node.id;
|
|
783
|
+
selectedNodeId = node.id;
|
|
784
|
+
highlightNodes.clear();
|
|
785
|
+
highlightNodes.add(node.id);
|
|
786
|
+
refreshGraph();
|
|
787
|
+
document.getElementById("path-status").textContent = `FROM: ${node.label} → SELECT SECOND NODE`;
|
|
788
|
+
} else if (!pathTo) {
|
|
789
|
+
pathTo = node.id;
|
|
790
|
+
document.getElementById("path-status").textContent = `TRACING PATH...`;
|
|
791
|
+
await findShortestPath(pathFrom, pathTo);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async function findShortestPath(from, to) {
|
|
796
|
+
try {
|
|
797
|
+
const res = await fetch(`/api/shortest-path?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
|
798
|
+
const data = await res.json();
|
|
799
|
+
|
|
800
|
+
if (data.distance < 0) {
|
|
801
|
+
document.getElementById("path-status").textContent = "NO PATH FOUND";
|
|
802
|
+
setTimeout(exitPathMode, 2000);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Highlight path
|
|
807
|
+
highlightNodes.clear();
|
|
808
|
+
highlightLinks.clear();
|
|
809
|
+
data.path.forEach(p => highlightNodes.add(p));
|
|
810
|
+
|
|
811
|
+
// Highlight links along the path
|
|
812
|
+
for (let i = 0; i < data.path.length - 1; i++) {
|
|
813
|
+
graphData.links.forEach(l => {
|
|
814
|
+
const src = getSrcId(l), tgt = getTgtId(l);
|
|
815
|
+
if ((src === data.path[i] && tgt === data.path[i+1]) ||
|
|
816
|
+
(src === data.path[i+1] && tgt === data.path[i])) {
|
|
817
|
+
highlightLinks.add(l);
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
selectedNodeId = null;
|
|
823
|
+
refreshGraph();
|
|
824
|
+
document.getElementById("path-status").textContent = `PATH: ${data.distance} hops (${data.path.length} nodes)`;
|
|
825
|
+
|
|
826
|
+
// Show path details in impact panel
|
|
827
|
+
let html = `<div style="margin-bottom:10px;font-size:10px;color:#ffd700;font-weight:600;letter-spacing:2px">SHORTEST PATH (${data.distance} hops)</div>`;
|
|
828
|
+
data.path.forEach((p, i) => {
|
|
829
|
+
const n = graphData.nodes.find(x => x.id === p);
|
|
830
|
+
const c = n ? LAYER_HEX[n.layer] || "#999" : "#999";
|
|
831
|
+
const arrow = i < data.path.length - 1 ? ' <span style="color:#ffd700">→</span>' : '';
|
|
832
|
+
html += `<div class="impact-item" data-file="${p}" style="color:${c}">[${n?n.layer:"?"}] ${p}${arrow}</div>`;
|
|
833
|
+
});
|
|
834
|
+
document.getElementById("impact-content").innerHTML = html;
|
|
835
|
+
|
|
836
|
+
// Focus camera on midpoint
|
|
837
|
+
const midIdx = Math.floor(data.path.length / 2);
|
|
838
|
+
const midNode = graphData.nodes.find(n => n.id === data.path[midIdx]);
|
|
839
|
+
if (midNode) {
|
|
840
|
+
Graph.cameraPosition(
|
|
841
|
+
{ x: (midNode.x||0)+300, y: (midNode.y||0)+200, z: (midNode.z||0)+300 },
|
|
842
|
+
{ x: midNode.x||0, y: midNode.y||0, z: midNode.z||0 }, 1200
|
|
843
|
+
);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// Auto-exit path mode after 10s
|
|
847
|
+
setTimeout(() => {
|
|
848
|
+
if (pathMode) exitPathMode();
|
|
849
|
+
}, 15000);
|
|
850
|
+
} catch(e) {
|
|
851
|
+
document.getElementById("path-status").textContent = "ERROR: " + e.message;
|
|
852
|
+
setTimeout(exitPathMode, 2000);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// ═══════════════════════════════════════════
|
|
857
|
+
// CONTEXT MENU (right-click)
|
|
858
|
+
// ═══════════════════════════════════════════
|
|
859
|
+
function setupContextMenu() {
|
|
860
|
+
document.addEventListener("click", () => hideContextMenu());
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function showContextMenu(node, event) {
|
|
864
|
+
if (!node) return;
|
|
865
|
+
event.preventDefault();
|
|
866
|
+
contextNode = node;
|
|
867
|
+
const menu = document.getElementById("context-menu");
|
|
868
|
+
menu.classList.remove("hidden");
|
|
869
|
+
menu.style.left = event.clientX + "px";
|
|
870
|
+
menu.style.top = event.clientY + "px";
|
|
871
|
+
|
|
872
|
+
// Ensure menu doesn't go off-screen
|
|
873
|
+
const rect = menu.getBoundingClientRect();
|
|
874
|
+
if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 10) + "px";
|
|
875
|
+
if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 10) + "px";
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function hideContextMenu() {
|
|
879
|
+
document.getElementById("context-menu").classList.add("hidden");
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function handleContextAction(action) {
|
|
883
|
+
if (!contextNode) return;
|
|
884
|
+
hideContextMenu();
|
|
885
|
+
|
|
886
|
+
switch(action) {
|
|
887
|
+
case "focus":
|
|
888
|
+
stopUser();
|
|
889
|
+
Graph.cameraPosition(
|
|
890
|
+
{ x: (contextNode.x||0)+120, y: (contextNode.y||0)+80, z: (contextNode.z||0)+120 },
|
|
891
|
+
{ x: contextNode.x||0, y: contextNode.y||0, z: contextNode.z||0 }, 800
|
|
892
|
+
);
|
|
893
|
+
break;
|
|
894
|
+
case "impact":
|
|
895
|
+
handleNodeClick(contextNode);
|
|
896
|
+
switchTab("info");
|
|
897
|
+
break;
|
|
898
|
+
case "ai":
|
|
899
|
+
selectedFile = contextNode.id;
|
|
900
|
+
selectedNodeId = contextNode.id;
|
|
901
|
+
switchTab("info");
|
|
902
|
+
runAIAnalysis();
|
|
903
|
+
break;
|
|
904
|
+
case "code":
|
|
905
|
+
selectedFile = contextNode.id;
|
|
906
|
+
selectedNodeId = contextNode.id;
|
|
907
|
+
loadCodePreview(contextNode.id);
|
|
908
|
+
switchTab("code");
|
|
909
|
+
break;
|
|
910
|
+
case "simulate":
|
|
911
|
+
selectedFile = contextNode.id;
|
|
912
|
+
selectedNodeId = contextNode.id;
|
|
913
|
+
loadSimulation(contextNode.id);
|
|
914
|
+
switchTab("simulate");
|
|
915
|
+
break;
|
|
916
|
+
case "path-from":
|
|
917
|
+
enterPathMode();
|
|
918
|
+
pathFrom = contextNode.id;
|
|
919
|
+
selectedNodeId = contextNode.id;
|
|
920
|
+
highlightNodes.clear();
|
|
921
|
+
highlightNodes.add(contextNode.id);
|
|
922
|
+
refreshGraph();
|
|
923
|
+
document.getElementById("path-status").textContent = `FROM: ${contextNode.label} → SELECT SECOND NODE`;
|
|
924
|
+
break;
|
|
925
|
+
case "path-to":
|
|
926
|
+
if (selectedFile && selectedFile !== contextNode.id) {
|
|
927
|
+
enterPathMode();
|
|
928
|
+
pathFrom = selectedFile;
|
|
929
|
+
pathTo = contextNode.id;
|
|
930
|
+
findShortestPath(pathFrom, pathTo);
|
|
931
|
+
}
|
|
932
|
+
break;
|
|
933
|
+
case "isolate":
|
|
934
|
+
// Show only nodes in same layer
|
|
935
|
+
hiddenLayers.clear();
|
|
936
|
+
LAYER_KEYS.forEach(l => { if (l !== contextNode.layer) hiddenLayers.add(l); });
|
|
937
|
+
document.querySelectorAll(".legend-item").forEach(li => {
|
|
938
|
+
li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
|
|
939
|
+
});
|
|
940
|
+
refreshGraph();
|
|
941
|
+
break;
|
|
942
|
+
case "hide":
|
|
943
|
+
hiddenNodes.add(contextNode.id);
|
|
944
|
+
if (selectedNodeId === contextNode.id) {
|
|
945
|
+
selectedFile = null;
|
|
946
|
+
selectedNodeId = null;
|
|
947
|
+
highlightNodes.clear();
|
|
948
|
+
highlightLinks.clear();
|
|
949
|
+
}
|
|
950
|
+
refreshGraph();
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// ═══════════════════════════════════════════
|
|
956
|
+
// TABS
|
|
957
|
+
// ═══════════════════════════════════════════
|
|
958
|
+
function setupTabs() {
|
|
959
|
+
document.querySelectorAll(".panel-tab").forEach(tab => {
|
|
960
|
+
tab.addEventListener("click", () => switchTab(tab.dataset.tab));
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function switchTab(tabName) {
|
|
965
|
+
document.querySelectorAll(".panel-tab").forEach(t => t.classList.toggle("active", t.dataset.tab === tabName));
|
|
966
|
+
document.querySelectorAll(".tab-content").forEach(c => c.classList.toggle("active", c.id === "tab-" + tabName));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ═══════════════════════════════════════════
|
|
970
|
+
// STATS CHARTS
|
|
971
|
+
// ═══════════════════════════════════════════
|
|
972
|
+
function showStats() {
|
|
973
|
+
document.getElementById("stats-overlay").classList.remove("hidden");
|
|
974
|
+
renderLayerChart();
|
|
975
|
+
renderRiskChart();
|
|
976
|
+
renderLinesChart();
|
|
977
|
+
renderDepsChart();
|
|
978
|
+
renderStatsSummary();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function renderLayerChart() {
|
|
982
|
+
const canvas = document.getElementById("chart-layers");
|
|
983
|
+
const ctx = canvas.getContext("2d");
|
|
984
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
985
|
+
|
|
986
|
+
const counts = {};
|
|
987
|
+
graphData.nodes.forEach(n => { counts[n.layer] = (counts[n.layer] || 0) + 1; });
|
|
988
|
+
|
|
989
|
+
const entries = Object.entries(counts).sort((a,b) => b[1] - a[1]);
|
|
990
|
+
const maxVal = Math.max(...entries.map(e => e[1]));
|
|
991
|
+
const barH = 22;
|
|
992
|
+
const gap = 6;
|
|
993
|
+
const labelW = 55;
|
|
994
|
+
const barMaxW = canvas.width - labelW - 40;
|
|
995
|
+
|
|
996
|
+
entries.forEach(([layer, count], i) => {
|
|
997
|
+
const y = i * (barH + gap) + 10;
|
|
998
|
+
const w = (count / maxVal) * barMaxW;
|
|
999
|
+
const color = LAYER_HEX[layer] || "#999";
|
|
1000
|
+
|
|
1001
|
+
ctx.fillStyle = color;
|
|
1002
|
+
ctx.globalAlpha = 0.3;
|
|
1003
|
+
ctx.fillRect(labelW, y, w, barH);
|
|
1004
|
+
ctx.globalAlpha = 1;
|
|
1005
|
+
ctx.fillStyle = color;
|
|
1006
|
+
ctx.fillRect(labelW, y, 3, barH);
|
|
1007
|
+
|
|
1008
|
+
ctx.fillStyle = color;
|
|
1009
|
+
ctx.font = "bold 10px Consolas";
|
|
1010
|
+
ctx.textAlign = "right";
|
|
1011
|
+
ctx.fillText(layer, labelW - 8, y + 15);
|
|
1012
|
+
|
|
1013
|
+
ctx.fillStyle = "#c8d6e5";
|
|
1014
|
+
ctx.textAlign = "left";
|
|
1015
|
+
ctx.fillText(count.toString(), labelW + w + 6, y + 15);
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function renderRiskChart() {
|
|
1020
|
+
const canvas = document.getElementById("chart-risk");
|
|
1021
|
+
const ctx = canvas.getContext("2d");
|
|
1022
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1023
|
+
|
|
1024
|
+
const counts = { HIGH: 0, MEDIUM: 0, LOW: 0, NONE: 0 };
|
|
1025
|
+
graphData.nodes.forEach(n => { counts[n.riskLevel] = (counts[n.riskLevel] || 0) + 1; });
|
|
1026
|
+
|
|
1027
|
+
const colors = { HIGH: "#ff2d55", MEDIUM: "#ff9f0a", LOW: "#30d158", NONE: "#3a4f6f" };
|
|
1028
|
+
const total = graphData.nodes.length;
|
|
1029
|
+
const cx = 90, cy = 90, r = 70;
|
|
1030
|
+
let startAngle = -Math.PI / 2;
|
|
1031
|
+
|
|
1032
|
+
Object.entries(counts).forEach(([level, count]) => {
|
|
1033
|
+
if (count === 0) return;
|
|
1034
|
+
const sliceAngle = (count / total) * Math.PI * 2;
|
|
1035
|
+
ctx.beginPath();
|
|
1036
|
+
ctx.moveTo(cx, cy);
|
|
1037
|
+
ctx.arc(cx, cy, r, startAngle, startAngle + sliceAngle);
|
|
1038
|
+
ctx.closePath();
|
|
1039
|
+
ctx.fillStyle = colors[level];
|
|
1040
|
+
ctx.globalAlpha = 0.6;
|
|
1041
|
+
ctx.fill();
|
|
1042
|
+
ctx.globalAlpha = 1;
|
|
1043
|
+
ctx.strokeStyle = "#050a18";
|
|
1044
|
+
ctx.lineWidth = 2;
|
|
1045
|
+
ctx.stroke();
|
|
1046
|
+
|
|
1047
|
+
// Label
|
|
1048
|
+
const midAngle = startAngle + sliceAngle / 2;
|
|
1049
|
+
const lx = cx + (r + 20) * Math.cos(midAngle);
|
|
1050
|
+
const ly = cy + (r + 20) * Math.sin(midAngle);
|
|
1051
|
+
ctx.fillStyle = colors[level];
|
|
1052
|
+
ctx.font = "bold 9px Consolas";
|
|
1053
|
+
ctx.textAlign = "center";
|
|
1054
|
+
ctx.fillText(`${level}:${count}`, lx, ly);
|
|
1055
|
+
|
|
1056
|
+
startAngle += sliceAngle;
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function renderLinesChart() {
|
|
1061
|
+
const canvas = document.getElementById("chart-lines");
|
|
1062
|
+
const ctx = canvas.getContext("2d");
|
|
1063
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1064
|
+
|
|
1065
|
+
const sorted = [...graphData.nodes].sort((a,b) => b.lineCount - a.lineCount).slice(0, 10);
|
|
1066
|
+
const maxVal = sorted[0]?.lineCount || 1;
|
|
1067
|
+
const barH = 14;
|
|
1068
|
+
const gap = 4;
|
|
1069
|
+
const barMaxW = canvas.width - 50;
|
|
1070
|
+
|
|
1071
|
+
sorted.forEach((n, i) => {
|
|
1072
|
+
const y = i * (barH + gap) + 4;
|
|
1073
|
+
const w = (n.lineCount / maxVal) * barMaxW;
|
|
1074
|
+
const color = LAYER_HEX[n.layer] || "#999";
|
|
1075
|
+
|
|
1076
|
+
ctx.fillStyle = color;
|
|
1077
|
+
ctx.globalAlpha = 0.25;
|
|
1078
|
+
ctx.fillRect(0, y, w, barH);
|
|
1079
|
+
ctx.globalAlpha = 1;
|
|
1080
|
+
|
|
1081
|
+
ctx.fillStyle = "#c8d6e5";
|
|
1082
|
+
ctx.font = "9px Consolas";
|
|
1083
|
+
ctx.textAlign = "left";
|
|
1084
|
+
const name = n.label.length > 25 ? n.label.slice(0, 22) + "..." : n.label;
|
|
1085
|
+
ctx.fillText(name, 4, y + 11);
|
|
1086
|
+
|
|
1087
|
+
ctx.fillStyle = color;
|
|
1088
|
+
ctx.textAlign = "left";
|
|
1089
|
+
ctx.fillText(n.lineCount.toString(), w + 4, y + 11);
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function renderDepsChart() {
|
|
1094
|
+
const canvas = document.getElementById("chart-deps");
|
|
1095
|
+
const ctx = canvas.getContext("2d");
|
|
1096
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1097
|
+
|
|
1098
|
+
const sorted = [...graphData.nodes].sort((a,b) => b.dependentCount - a.dependentCount).slice(0, 10);
|
|
1099
|
+
const maxVal = sorted[0]?.dependentCount || 1;
|
|
1100
|
+
const barH = 14;
|
|
1101
|
+
const gap = 4;
|
|
1102
|
+
const barMaxW = canvas.width - 50;
|
|
1103
|
+
|
|
1104
|
+
sorted.forEach((n, i) => {
|
|
1105
|
+
const y = i * (barH + gap) + 4;
|
|
1106
|
+
const w = (n.dependentCount / maxVal) * barMaxW;
|
|
1107
|
+
const color = n.riskLevel === "HIGH" ? "#ff2d55" : n.riskLevel === "MEDIUM" ? "#ff9f0a" : "#30d158";
|
|
1108
|
+
|
|
1109
|
+
ctx.fillStyle = color;
|
|
1110
|
+
ctx.globalAlpha = 0.25;
|
|
1111
|
+
ctx.fillRect(0, y, w, barH);
|
|
1112
|
+
ctx.globalAlpha = 1;
|
|
1113
|
+
|
|
1114
|
+
ctx.fillStyle = "#c8d6e5";
|
|
1115
|
+
ctx.font = "9px Consolas";
|
|
1116
|
+
ctx.textAlign = "left";
|
|
1117
|
+
const name = n.label.length > 25 ? n.label.slice(0, 22) + "..." : n.label;
|
|
1118
|
+
ctx.fillText(name, 4, y + 11);
|
|
1119
|
+
|
|
1120
|
+
ctx.fillStyle = color;
|
|
1121
|
+
ctx.textAlign = "left";
|
|
1122
|
+
ctx.fillText(n.dependentCount.toString(), w + 4, y + 11);
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function renderStatsSummary() {
|
|
1127
|
+
const totalLines = graphData.nodes.reduce((s, n) => s + n.lineCount, 0);
|
|
1128
|
+
const avgLines = Math.round(totalLines / graphData.nodes.length);
|
|
1129
|
+
const maxDeps = Math.max(...graphData.nodes.map(n => n.dependentCount));
|
|
1130
|
+
const avgDeps = (graphData.nodes.reduce((s, n) => s + n.dependentCount, 0) / graphData.nodes.length).toFixed(1);
|
|
1131
|
+
const maxDepth = Math.max(...graphData.nodes.map(n => n.depth));
|
|
1132
|
+
const isolated = graphData.nodes.filter(n => n.dependentCount === 0 && n.importsCount === 0).length;
|
|
1133
|
+
|
|
1134
|
+
document.getElementById("stats-summary").innerHTML = `
|
|
1135
|
+
<strong style="color:#00d4ff">TOTAL LINES:</strong> ${totalLines.toLocaleString()} ·
|
|
1136
|
+
<strong style="color:#00d4ff">AVG:</strong> ${avgLines} lines/file ·
|
|
1137
|
+
<strong style="color:#00d4ff">MAX DEPS:</strong> ${maxDeps} ·
|
|
1138
|
+
<strong style="color:#00d4ff">AVG DEPS:</strong> ${avgDeps} ·
|
|
1139
|
+
<strong style="color:#00d4ff">MAX DEPTH:</strong> ${maxDepth} ·
|
|
1140
|
+
<strong style="color:#00d4ff">ISOLATED:</strong> ${isolated} files
|
|
1141
|
+
`;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// ═══════════════════════════════════════════
|
|
1145
|
+
// CYCLES DETECTION
|
|
1146
|
+
// ═══════════════════════════════════════════
|
|
1147
|
+
async function detectCycles() {
|
|
1148
|
+
document.getElementById("cycles-overlay").classList.remove("hidden");
|
|
1149
|
+
document.getElementById("cycles-content").innerHTML = '<div class="loading"><div class="spinner"></div>SCANNING...</div>';
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
const res = await fetch("/api/cycles");
|
|
1153
|
+
const data = await res.json();
|
|
1154
|
+
|
|
1155
|
+
if (data.count === 0) {
|
|
1156
|
+
document.getElementById("cycles-content").innerHTML = '<div class="no-cycles">NO CIRCULAR DEPENDENCIES DETECTED</div>';
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let html = `<p style="margin-bottom:12px;font-size:11px;color:#ff2d55">${data.count} cycle(s) detected:</p>`;
|
|
1161
|
+
data.cycles.forEach((cycle, i) => {
|
|
1162
|
+
html += `<div class="cycle-item"><h4>CYCLE ${i+1}</h4><div class="cycle-path">`;
|
|
1163
|
+
cycle.forEach((file, j) => {
|
|
1164
|
+
if (j > 0) html += '<span class="arrow">→</span>';
|
|
1165
|
+
html += `<span class="file" data-file="${file}">${file}</span>`;
|
|
1166
|
+
});
|
|
1167
|
+
html += '</div></div>';
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
document.getElementById("cycles-content").innerHTML = html;
|
|
1171
|
+
|
|
1172
|
+
// Highlight all cycle nodes
|
|
1173
|
+
highlightNodes.clear();
|
|
1174
|
+
highlightLinks.clear();
|
|
1175
|
+
data.cycles.forEach(cycle => {
|
|
1176
|
+
cycle.forEach(f => highlightNodes.add(f));
|
|
1177
|
+
});
|
|
1178
|
+
refreshGraph();
|
|
1179
|
+
|
|
1180
|
+
// Click to navigate
|
|
1181
|
+
document.querySelectorAll("#cycles-content .file").forEach(el => {
|
|
1182
|
+
el.addEventListener("click", () => {
|
|
1183
|
+
const node = graphData.nodes.find(n => n.id === el.dataset.file);
|
|
1184
|
+
if (node) {
|
|
1185
|
+
stopUser();
|
|
1186
|
+
Graph.cameraPosition(
|
|
1187
|
+
{ x: (node.x||0)+120, y: (node.y||0)+80, z: (node.z||0)+120 },
|
|
1188
|
+
{ x: node.x||0, y: node.y||0, z: node.z||0 }, 800
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
} catch(e) {
|
|
1194
|
+
document.getElementById("cycles-content").innerHTML = `<p class="placeholder">ERROR: ${e.message}</p>`;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ═══════════════════════════════════════════
|
|
1199
|
+
// KEYBOARD SHORTCUTS
|
|
1200
|
+
// ═══════════════════════════════════════════
|
|
1201
|
+
function setupKeyboardShortcuts() {
|
|
1202
|
+
document.addEventListener("keydown", e => {
|
|
1203
|
+
// Don't trigger if typing in search
|
|
1204
|
+
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
|
|
1205
|
+
|
|
1206
|
+
switch(e.key.toLowerCase()) {
|
|
1207
|
+
case "r":
|
|
1208
|
+
resetView();
|
|
1209
|
+
break;
|
|
1210
|
+
case "a":
|
|
1211
|
+
toggleAutoRotate();
|
|
1212
|
+
break;
|
|
1213
|
+
case "p":
|
|
1214
|
+
if (pathMode) exitPathMode();
|
|
1215
|
+
else enterPathMode();
|
|
1216
|
+
break;
|
|
1217
|
+
case "f":
|
|
1218
|
+
if (selectedNodeId) {
|
|
1219
|
+
const node = graphData.nodes.find(n => n.id === selectedNodeId);
|
|
1220
|
+
if (node) {
|
|
1221
|
+
stopUser();
|
|
1222
|
+
Graph.cameraPosition(
|
|
1223
|
+
{ x: (node.x||0)+120, y: (node.y||0)+80, z: (node.z||0)+120 },
|
|
1224
|
+
{ x: node.x||0, y: node.y||0, z: node.z||0 }, 800
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
break;
|
|
1229
|
+
case "c":
|
|
1230
|
+
if (selectedFile) { loadCodePreview(selectedFile); switchTab("code"); }
|
|
1231
|
+
break;
|
|
1232
|
+
case "s":
|
|
1233
|
+
if (selectedFile) { loadSimulation(selectedFile); switchTab("simulate"); }
|
|
1234
|
+
break;
|
|
1235
|
+
case "d":
|
|
1236
|
+
detectCycles();
|
|
1237
|
+
break;
|
|
1238
|
+
case "escape":
|
|
1239
|
+
if (pathMode) { exitPathMode(); return; }
|
|
1240
|
+
if (!document.getElementById("stats-overlay").classList.contains("hidden")) {
|
|
1241
|
+
document.getElementById("stats-overlay").classList.add("hidden"); return;
|
|
1242
|
+
}
|
|
1243
|
+
if (!document.getElementById("shortcuts-overlay").classList.contains("hidden")) {
|
|
1244
|
+
document.getElementById("shortcuts-overlay").classList.add("hidden"); return;
|
|
1245
|
+
}
|
|
1246
|
+
if (!document.getElementById("cycles-overlay").classList.contains("hidden")) {
|
|
1247
|
+
document.getElementById("cycles-overlay").classList.add("hidden");
|
|
1248
|
+
highlightNodes.clear(); highlightLinks.clear();
|
|
1249
|
+
refreshGraph();
|
|
1250
|
+
return;
|
|
1251
|
+
}
|
|
1252
|
+
handleBackgroundClick();
|
|
1253
|
+
break;
|
|
1254
|
+
case "/": case "?":
|
|
1255
|
+
e.preventDefault();
|
|
1256
|
+
document.getElementById("shortcuts-overlay").classList.toggle("hidden");
|
|
1257
|
+
break;
|
|
1258
|
+
case "1": toggleLayerByIndex(0); break;
|
|
1259
|
+
case "2": toggleLayerByIndex(1); break;
|
|
1260
|
+
case "3": toggleLayerByIndex(2); break;
|
|
1261
|
+
case "4": toggleLayerByIndex(3); break;
|
|
1262
|
+
case "5": toggleLayerByIndex(4); break;
|
|
1263
|
+
case "6": toggleLayerByIndex(5); break;
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function toggleLayerByIndex(idx) {
|
|
1269
|
+
const layer = LAYER_KEYS[idx];
|
|
1270
|
+
if (!layer) return;
|
|
1271
|
+
if (hiddenLayers.has(layer)) hiddenLayers.delete(layer);
|
|
1272
|
+
else hiddenLayers.add(layer);
|
|
1273
|
+
document.querySelectorAll(".legend-item").forEach(li => {
|
|
1274
|
+
li.classList.toggle("filtered", hiddenLayers.has(li.dataset.layer));
|
|
1275
|
+
});
|
|
1276
|
+
refreshGraph();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function resetView() {
|
|
1280
|
+
selectedFile = null; selectedNodeId = null;
|
|
1281
|
+
highlightNodes.clear(); highlightLinks.clear();
|
|
1282
|
+
hiddenLayers.clear(); hiddenNodes.clear();
|
|
1283
|
+
document.querySelectorAll(".legend-item").forEach(li => li.classList.remove("filtered"));
|
|
1284
|
+
document.getElementById("file-info-content").innerHTML = '<p class="placeholder">Select a node to identify target</p>';
|
|
1285
|
+
document.getElementById("impact-content").innerHTML = '<p class="placeholder">Select a node to trace impact chain</p>';
|
|
1286
|
+
document.getElementById("ai-content").innerHTML = '<p class="placeholder">Select target, then request AI analysis</p>';
|
|
1287
|
+
document.getElementById("code-content").innerHTML = '<p class="placeholder">Select a node to preview source code</p>';
|
|
1288
|
+
document.getElementById("sim-content").innerHTML = '<p class="placeholder">Select a node, then switch to SIM tab</p>';
|
|
1289
|
+
document.getElementById("btn-ai-analyze").disabled = true;
|
|
1290
|
+
if (pathMode) exitPathMode();
|
|
1291
|
+
stopCodeCrawl();
|
|
1292
|
+
refreshGraph();
|
|
1293
|
+
Graph.cameraPosition({ x:0,y:0,z:1600 }, { x:0,y:0,z:0 }, 1000);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function toggleAutoRotate() {
|
|
1297
|
+
autoRotate = !autoRotate;
|
|
1298
|
+
document.getElementById("btn-auto-rotate").classList.toggle("active", autoRotate);
|
|
1299
|
+
if (autoRotate) startAutoRotate(); else stopAutoRotate();
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ═══════════════════════════════════════════
|
|
1303
|
+
// HUB FILES
|
|
1304
|
+
// ═══════════════════════════════════════════
|
|
1305
|
+
async function loadHubFiles() {
|
|
1306
|
+
try {
|
|
1307
|
+
const r = await fetch("/api/hub-files?top=15"); const d = await r.json();
|
|
1308
|
+
const c = document.getElementById("hub-content"); let h = "";
|
|
1309
|
+
d.hubs.forEach((hub,i) => {
|
|
1310
|
+
h += `<div class="hub-item" data-file="${hub.relativePath}">
|
|
1311
|
+
<span class="hub-rank">${i+1}.</span><span class="hub-name">${hub.relativePath}</span>
|
|
1312
|
+
<span class="hub-count">${hub.dependentCount}</span><span class="risk-badge ${hub.riskLevel}">${hub.riskLevel}</span></div>`;
|
|
1313
|
+
});
|
|
1314
|
+
c.innerHTML = h;
|
|
1315
|
+
c.querySelectorAll(".hub-item").forEach(el => {
|
|
1316
|
+
el.addEventListener("click", () => { const n = graphData.nodes.find(n => n.id === el.dataset.file); if (n) handleNodeClick(n); });
|
|
1317
|
+
});
|
|
1318
|
+
} catch(e) { console.error("Hub:", e); }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// ═══════════════════════════════════════════
|
|
1322
|
+
// EVENT LISTENERS
|
|
1323
|
+
// ═══════════════════════════════════════════
|
|
1324
|
+
function setupEventListeners() {
|
|
1325
|
+
document.getElementById("btn-ai-analyze").addEventListener("click", runAIAnalysis);
|
|
1326
|
+
|
|
1327
|
+
document.getElementById("btn-reset").addEventListener("click", resetView);
|
|
1328
|
+
|
|
1329
|
+
document.getElementById("btn-auto-rotate").addEventListener("click", toggleAutoRotate);
|
|
1330
|
+
|
|
1331
|
+
document.getElementById("btn-path-mode").addEventListener("click", () => {
|
|
1332
|
+
if (pathMode) exitPathMode();
|
|
1333
|
+
else enterPathMode();
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
document.getElementById("btn-cancel-path").addEventListener("click", exitPathMode);
|
|
1337
|
+
|
|
1338
|
+
document.getElementById("search-input").addEventListener("input", e => {
|
|
1339
|
+
const q = e.target.value.toLowerCase();
|
|
1340
|
+
highlightNodes.clear(); highlightLinks.clear(); selectedNodeId = null;
|
|
1341
|
+
if (q) {
|
|
1342
|
+
graphData.nodes.forEach(n => {
|
|
1343
|
+
if (n.fullPath.toLowerCase().includes(q) || n.layer.toLowerCase() === q || n.label.toLowerCase().includes(q)) highlightNodes.add(n.id);
|
|
1344
|
+
});
|
|
1345
|
+
if (highlightNodes.size > 0) {
|
|
1346
|
+
const f = graphData.nodes.find(n => n.id === highlightNodes.values().next().value);
|
|
1347
|
+
if (f && f.x != null) {
|
|
1348
|
+
stopUser();
|
|
1349
|
+
const d = Math.max(1, Math.hypot(f.x,f.y||0,f.z||0));
|
|
1350
|
+
Graph.cameraPosition({x:f.x+200*f.x/d,y:(f.y||0)+200*(f.y||1)/d,z:(f.z||0)+200*(f.z||1)/d},{x:f.x,y:f.y||0,z:f.z||0},600);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
refreshGraph();
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
document.getElementById("btn-toggle-hub").addEventListener("click", () => {
|
|
1358
|
+
document.getElementById("hub-drawer").classList.toggle("collapsed");
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// Stats
|
|
1362
|
+
document.getElementById("btn-stats").addEventListener("click", showStats);
|
|
1363
|
+
document.getElementById("btn-close-stats").addEventListener("click", () => {
|
|
1364
|
+
document.getElementById("stats-overlay").classList.add("hidden");
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
// Shortcuts
|
|
1368
|
+
document.getElementById("btn-shortcuts").addEventListener("click", () => {
|
|
1369
|
+
document.getElementById("shortcuts-overlay").classList.toggle("hidden");
|
|
1370
|
+
});
|
|
1371
|
+
document.getElementById("btn-close-shortcuts").addEventListener("click", () => {
|
|
1372
|
+
document.getElementById("shortcuts-overlay").classList.add("hidden");
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
// Cycles
|
|
1376
|
+
document.getElementById("btn-cycles").addEventListener("click", detectCycles);
|
|
1377
|
+
document.getElementById("btn-close-cycles").addEventListener("click", () => {
|
|
1378
|
+
document.getElementById("cycles-overlay").classList.add("hidden");
|
|
1379
|
+
highlightNodes.clear(); highlightLinks.clear();
|
|
1380
|
+
refreshGraph();
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
// Context menu actions
|
|
1384
|
+
document.querySelectorAll(".ctx-item").forEach(item => {
|
|
1385
|
+
item.addEventListener("click", (e) => {
|
|
1386
|
+
e.stopPropagation();
|
|
1387
|
+
handleContextAction(item.dataset.action);
|
|
1388
|
+
});
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
// Close overlays on background click
|
|
1392
|
+
["stats-overlay", "shortcuts-overlay", "cycles-overlay"].forEach(id => {
|
|
1393
|
+
document.getElementById(id).addEventListener("click", (e) => {
|
|
1394
|
+
if (e.target.id === id) document.getElementById(id).classList.add("hidden");
|
|
1395
|
+
});
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
// ── Resizable Right Panel ──
|
|
1399
|
+
setupResizeHandle();
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ═══════════════════════════════════════════
|
|
1403
|
+
// DART SYNTAX HIGHLIGHTING (VS Code Dark+)
|
|
1404
|
+
// ═══════════════════════════════════════════
|
|
1405
|
+
function highlightDart(line) {
|
|
1406
|
+
// Escape HTML first
|
|
1407
|
+
let s = line.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1408
|
+
|
|
1409
|
+
// Full-line comment
|
|
1410
|
+
if (/^\s*\/\//.test(s)) {
|
|
1411
|
+
return `<span class="cmt">${s}</span>`;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Tokenize with regex replacements (order matters)
|
|
1415
|
+
// Strings (single and double quoted)
|
|
1416
|
+
s = s.replace(/('[^']*'|"[^"]*"|'[^']*'|"[^"]*")/g, '<span class="str">$1</span>');
|
|
1417
|
+
|
|
1418
|
+
// Annotations (@override, @required, etc)
|
|
1419
|
+
s = s.replace(/(@\w+)/g, '<span class="ann">$1</span>');
|
|
1420
|
+
|
|
1421
|
+
// Numbers
|
|
1422
|
+
s = s.replace(/\b(\d+\.?\d*)\b/g, '<span class="num">$1</span>');
|
|
1423
|
+
|
|
1424
|
+
// Dart keywords
|
|
1425
|
+
s = s.replace(/\b(import|export|library|part|show|hide|as|if|else|for|while|do|switch|case|break|continue|return|yield|async|await|try|catch|finally|throw|rethrow|new|const|final|var|late|required|static|abstract|class|extends|implements|with|mixin|enum|typedef|void|dynamic|super|this|is|in|true|false|null|factory|get|set|operator|external|covariant)\b/g, '<span class="kw">$1</span>');
|
|
1426
|
+
|
|
1427
|
+
// Dart types (capitalized words that look like types)
|
|
1428
|
+
s = s.replace(/\b(String|int|double|bool|num|List|Map|Set|Future|Stream|Widget|BuildContext|State|Key|Color|Text|Container|Column|Row|Scaffold|Navigator|Provider|Ref|Notifier|Override|Object|Function|Type|Iterable|Duration|DateTime|File|Directory|Uri)\b/g, '<span class="type">$1</span>');
|
|
1429
|
+
|
|
1430
|
+
// Function calls: word followed by (
|
|
1431
|
+
s = s.replace(/\b([a-z_]\w*)\s*(?=\()/g, '<span class="fn">$1</span>');
|
|
1432
|
+
|
|
1433
|
+
// Punctuation
|
|
1434
|
+
s = s.replace(/([{}()\[\];,])/g, '<span class="punc">$1</span>');
|
|
1435
|
+
|
|
1436
|
+
return s;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// ═══════════════════════════════════════════
|
|
1440
|
+
// CODE CRAWL (auto-scroll + manual scrollbar)
|
|
1441
|
+
// ═══════════════════════════════════════════
|
|
1442
|
+
let crawlAutoScroll = true;
|
|
1443
|
+
let crawlUserTimer = null;
|
|
1444
|
+
|
|
1445
|
+
async function startCodeCrawl(fileId) {
|
|
1446
|
+
stopCodeCrawl();
|
|
1447
|
+
|
|
1448
|
+
const crawlEl = document.getElementById("code-crawl");
|
|
1449
|
+
const contentEl = document.getElementById("crawl-content");
|
|
1450
|
+
const viewport = document.getElementById("crawl-viewport");
|
|
1451
|
+
const headerName = document.getElementById("crawl-file-name");
|
|
1452
|
+
const headerStatus = document.getElementById("crawl-status");
|
|
1453
|
+
|
|
1454
|
+
headerName.textContent = fileId;
|
|
1455
|
+
headerStatus.textContent = "LOADING";
|
|
1456
|
+
headerStatus.className = "";
|
|
1457
|
+
contentEl.innerHTML = "";
|
|
1458
|
+
crawlEl.classList.add("active");
|
|
1459
|
+
crawlAutoScroll = true;
|
|
1460
|
+
|
|
1461
|
+
try {
|
|
1462
|
+
const res = await fetch("/api/connected-code", {
|
|
1463
|
+
method: "POST",
|
|
1464
|
+
headers: { "Content-Type": "application/json" },
|
|
1465
|
+
body: JSON.stringify({ file: fileId, maxFiles: 5, maxLinesPerFile: 40 }),
|
|
1466
|
+
});
|
|
1467
|
+
const data = await res.json();
|
|
1468
|
+
if (!res.ok) { headerStatus.textContent = "ERROR"; return; }
|
|
1469
|
+
|
|
1470
|
+
crawlData = data.files;
|
|
1471
|
+
headerStatus.textContent = `${data.files.length} FILES`;
|
|
1472
|
+
|
|
1473
|
+
// Build HTML with syntax highlighting
|
|
1474
|
+
let html = "";
|
|
1475
|
+
data.files.forEach((file) => {
|
|
1476
|
+
const col = LAYER_HEX[file.layer] || "#00d4ff";
|
|
1477
|
+
html += `<div class="crawl-separator">`;
|
|
1478
|
+
html += `<span class="sep-path" style="color:${col}">${file.path}</span>`;
|
|
1479
|
+
html += `<span class="sep-layer" style="color:${col}">[${file.layer}] ${file.lineCount}L</span>`;
|
|
1480
|
+
html += `</div>`;
|
|
1481
|
+
file.lines.forEach((line, li) => {
|
|
1482
|
+
html += `<div class="crawl-line" data-file="${file.path}" data-line="${li+1}">`;
|
|
1483
|
+
html += `<span class="cl-num">${li+1}</span>${highlightDart(line)}`;
|
|
1484
|
+
html += `</div>`;
|
|
1485
|
+
});
|
|
1486
|
+
});
|
|
1487
|
+
contentEl.innerHTML = html;
|
|
1488
|
+
|
|
1489
|
+
// User scrolls → pause auto, resume after 3s idle
|
|
1490
|
+
viewport.addEventListener("wheel", pauseAutoScroll, { passive: true });
|
|
1491
|
+
viewport.addEventListener("mousedown", pauseAutoScroll);
|
|
1492
|
+
viewport.addEventListener("touchstart", pauseAutoScroll);
|
|
1493
|
+
|
|
1494
|
+
// Start auto-scroll
|
|
1495
|
+
viewport.scrollTop = 0;
|
|
1496
|
+
crawlAnimationId = requestAnimationFrame(crawlLoop);
|
|
1497
|
+
} catch(e) {
|
|
1498
|
+
headerStatus.textContent = "ERROR";
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
function crawlLoop() {
|
|
1503
|
+
if (!crawlAutoScroll) { crawlAnimationId = requestAnimationFrame(crawlLoop); return; }
|
|
1504
|
+
const vp = document.getElementById("crawl-viewport");
|
|
1505
|
+
if (!vp) return;
|
|
1506
|
+
vp.scrollTop += 0.5;
|
|
1507
|
+
// Loop back to top when reached bottom
|
|
1508
|
+
if (vp.scrollTop >= vp.scrollHeight - vp.clientHeight) {
|
|
1509
|
+
vp.scrollTop = 0;
|
|
1510
|
+
}
|
|
1511
|
+
crawlAnimationId = requestAnimationFrame(crawlLoop);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
function pauseAutoScroll() {
|
|
1515
|
+
crawlAutoScroll = false;
|
|
1516
|
+
if (crawlUserTimer) clearTimeout(crawlUserTimer);
|
|
1517
|
+
crawlUserTimer = setTimeout(() => { crawlAutoScroll = true; }, 3000);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
function stopCodeCrawl() {
|
|
1521
|
+
if (crawlAnimationId) { cancelAnimationFrame(crawlAnimationId); crawlAnimationId = null; }
|
|
1522
|
+
if (crawlUserTimer) { clearTimeout(crawlUserTimer); crawlUserTimer = null; }
|
|
1523
|
+
const vp = document.getElementById("crawl-viewport");
|
|
1524
|
+
if (vp) { vp.removeEventListener("wheel", pauseAutoScroll); vp.removeEventListener("mousedown", pauseAutoScroll); vp.removeEventListener("touchstart", pauseAutoScroll); }
|
|
1525
|
+
document.getElementById("code-crawl").classList.remove("active");
|
|
1526
|
+
crawlData = null;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// ═══════════════════════════════════════════
|
|
1530
|
+
// REAL-TIME DIFF VIEWER (SSE-driven)
|
|
1531
|
+
// ═══════════════════════════════════════════
|
|
1532
|
+
function showRealtimeDiff(data) {
|
|
1533
|
+
const crawlEl = document.getElementById("code-crawl");
|
|
1534
|
+
const contentEl = document.getElementById("crawl-content");
|
|
1535
|
+
const viewport = document.getElementById("crawl-viewport");
|
|
1536
|
+
const headerName = document.getElementById("crawl-file-name");
|
|
1537
|
+
const headerStatus = document.getElementById("crawl-status");
|
|
1538
|
+
|
|
1539
|
+
// Stop any existing crawl animation
|
|
1540
|
+
if (crawlAnimationId) { cancelAnimationFrame(crawlAnimationId); crawlAnimationId = null; }
|
|
1541
|
+
|
|
1542
|
+
// Set header
|
|
1543
|
+
const fileName = data.file.split("/").pop();
|
|
1544
|
+
headerName.textContent = data.file;
|
|
1545
|
+
headerStatus.textContent = data.type.toUpperCase();
|
|
1546
|
+
headerStatus.className = "modifying";
|
|
1547
|
+
crawlEl.classList.add("active");
|
|
1548
|
+
|
|
1549
|
+
// Build a set of changed line numbers for highlighting
|
|
1550
|
+
const changedLines = new Map(); // lineNum → {type, old, new}
|
|
1551
|
+
if (data.diff) {
|
|
1552
|
+
data.diff.forEach(d => {
|
|
1553
|
+
changedLines.set(d.line, d);
|
|
1554
|
+
});
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
let html = "";
|
|
1558
|
+
|
|
1559
|
+
if (data.type === "deleted") {
|
|
1560
|
+
// File was deleted — show old content as all red
|
|
1561
|
+
html += `<div class="diff-banner diff-deleted">FILE DELETED</div>`;
|
|
1562
|
+
} else if (data.type === "added") {
|
|
1563
|
+
// New file — show all lines as green
|
|
1564
|
+
html += `<div class="diff-banner diff-added">NEW FILE</div>`;
|
|
1565
|
+
if (data.newContent) {
|
|
1566
|
+
data.newContent.forEach((line, i) => {
|
|
1567
|
+
const lineNum = i + 1;
|
|
1568
|
+
html += `<div class="crawl-line added" data-file="${data.file}" data-line="${lineNum}">`;
|
|
1569
|
+
html += `<span class="cl-num">${lineNum}</span>`;
|
|
1570
|
+
html += `<span class="diff-marker">+</span>`;
|
|
1571
|
+
html += highlightDart(line);
|
|
1572
|
+
html += `</div>`;
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
} else {
|
|
1576
|
+
// Modified — show full file with diff highlights
|
|
1577
|
+
html += `<div class="diff-banner diff-modified">${data.diffCount} LINES CHANGED</div>`;
|
|
1578
|
+
|
|
1579
|
+
if (data.newContent) {
|
|
1580
|
+
data.newContent.forEach((line, i) => {
|
|
1581
|
+
const lineNum = i + 1;
|
|
1582
|
+
const change = changedLines.get(lineNum);
|
|
1583
|
+
let cls = "";
|
|
1584
|
+
let marker = " ";
|
|
1585
|
+
|
|
1586
|
+
if (change) {
|
|
1587
|
+
if (change.type === "added") {
|
|
1588
|
+
cls = "added";
|
|
1589
|
+
marker = "+";
|
|
1590
|
+
} else if (change.type === "removed") {
|
|
1591
|
+
cls = "removed";
|
|
1592
|
+
marker = "-";
|
|
1593
|
+
} else if (change.type === "changed") {
|
|
1594
|
+
cls = "changed";
|
|
1595
|
+
marker = "~";
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
// If this line was changed, also show the old line above it (strikethrough red)
|
|
1600
|
+
if (change && change.type === "changed" && change.old !== undefined) {
|
|
1601
|
+
html += `<div class="crawl-line removed" data-file="${data.file}" data-line="${lineNum}">`;
|
|
1602
|
+
html += `<span class="cl-num">${lineNum}</span>`;
|
|
1603
|
+
html += `<span class="diff-marker">-</span>`;
|
|
1604
|
+
html += `<span class="old-code">${highlightDart(change.old)}</span>`;
|
|
1605
|
+
html += `</div>`;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
html += `<div class="crawl-line ${cls}" data-file="${data.file}" data-line="${lineNum}">`;
|
|
1609
|
+
html += `<span class="cl-num">${lineNum}</span>`;
|
|
1610
|
+
if (marker !== " ") html += `<span class="diff-marker">${marker}</span>`;
|
|
1611
|
+
html += highlightDart(line);
|
|
1612
|
+
html += `</div>`;
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// Show removed lines that are beyond the new content length
|
|
1617
|
+
data.diff?.forEach(d => {
|
|
1618
|
+
if (d.type === "removed" && d.line > (data.newContent?.length || 0)) {
|
|
1619
|
+
html += `<div class="crawl-line removed" data-file="${data.file}" data-line="${d.line}">`;
|
|
1620
|
+
html += `<span class="cl-num">${d.line}</span>`;
|
|
1621
|
+
html += `<span class="diff-marker">-</span>`;
|
|
1622
|
+
html += `<span class="old-code">${highlightDart(d.old || "")}</span>`;
|
|
1623
|
+
html += `</div>`;
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
contentEl.innerHTML = html;
|
|
1629
|
+
|
|
1630
|
+
// ── Animated diff scroll: sweep through file, pause on changes ──
|
|
1631
|
+
if (diffScrollAnim) cancelAnimationFrame(diffScrollAnim);
|
|
1632
|
+
viewport.scrollTop = 0;
|
|
1633
|
+
|
|
1634
|
+
const changedEls = contentEl.querySelectorAll(".crawl-line.added, .crawl-line.changed, .crawl-line.removed");
|
|
1635
|
+
if (changedEls.length > 0) {
|
|
1636
|
+
let currentIdx = 0;
|
|
1637
|
+
let pauseUntil = 0;
|
|
1638
|
+
let userPaused = false;
|
|
1639
|
+
|
|
1640
|
+
function diffScrollLoop() {
|
|
1641
|
+
if (userPaused) { diffScrollAnim = requestAnimationFrame(diffScrollLoop); return; }
|
|
1642
|
+
const now = Date.now();
|
|
1643
|
+
if (now < pauseUntil) { diffScrollAnim = requestAnimationFrame(diffScrollLoop); return; }
|
|
1644
|
+
|
|
1645
|
+
if (currentIdx < changedEls.length) {
|
|
1646
|
+
const el = changedEls[currentIdx];
|
|
1647
|
+
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
1648
|
+
// Flash effect on the line
|
|
1649
|
+
el.classList.add("diff-flash-active");
|
|
1650
|
+
setTimeout(() => el.classList.remove("diff-flash-active"), 800);
|
|
1651
|
+
currentIdx++;
|
|
1652
|
+
pauseUntil = now + 1500; // pause 1.5s on each change
|
|
1653
|
+
} else {
|
|
1654
|
+
// Loop: go back to first change after a longer pause
|
|
1655
|
+
currentIdx = 0;
|
|
1656
|
+
pauseUntil = now + 3000;
|
|
1657
|
+
}
|
|
1658
|
+
diffScrollAnim = requestAnimationFrame(diffScrollLoop);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// Start after brief initial delay
|
|
1662
|
+
setTimeout(() => { diffScrollAnim = requestAnimationFrame(diffScrollLoop); }, 500);
|
|
1663
|
+
|
|
1664
|
+
// User scroll pauses the auto-animation
|
|
1665
|
+
const pauseHandler = () => {
|
|
1666
|
+
userPaused = true;
|
|
1667
|
+
if (crawlUserTimer) clearTimeout(crawlUserTimer);
|
|
1668
|
+
crawlUserTimer = setTimeout(() => { userPaused = false; }, 4000);
|
|
1669
|
+
};
|
|
1670
|
+
viewport.addEventListener("wheel", pauseHandler, { passive: true });
|
|
1671
|
+
viewport.addEventListener("mousedown", pauseHandler);
|
|
1672
|
+
} else {
|
|
1673
|
+
viewport.scrollTop = 0;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function updateDiffWithAnalysis(analysis) {
|
|
1678
|
+
const headerStatus = document.getElementById("crawl-status");
|
|
1679
|
+
if (!headerStatus) return;
|
|
1680
|
+
|
|
1681
|
+
const riskColors = {
|
|
1682
|
+
CRITICAL: "#ff0040",
|
|
1683
|
+
HIGH: "#ff2d55",
|
|
1684
|
+
MEDIUM: "#ff9f0a",
|
|
1685
|
+
LOW: "#30d158",
|
|
1686
|
+
SAFE: "#30d158",
|
|
1687
|
+
};
|
|
1688
|
+
const color = riskColors[analysis.riskLevel] || "#c8d6e5";
|
|
1689
|
+
headerStatus.textContent = `${analysis.riskLevel} — ${analysis.summary || "분석 완료"}`;
|
|
1690
|
+
headerStatus.style.color = color;
|
|
1691
|
+
headerStatus.className = "";
|
|
1692
|
+
|
|
1693
|
+
// Add analysis summary at top of diff
|
|
1694
|
+
const contentEl = document.getElementById("crawl-content");
|
|
1695
|
+
if (contentEl && analysis.summary) {
|
|
1696
|
+
const banner = document.createElement("div");
|
|
1697
|
+
banner.className = `diff-banner diff-analysis`;
|
|
1698
|
+
banner.style.borderColor = color;
|
|
1699
|
+
banner.style.color = color;
|
|
1700
|
+
let text = `AI: ${analysis.summary}`;
|
|
1701
|
+
if (analysis.suggestion) text += ` | ${analysis.suggestion}`;
|
|
1702
|
+
banner.textContent = text;
|
|
1703
|
+
|
|
1704
|
+
// Insert after the first banner
|
|
1705
|
+
const firstBanner = contentEl.querySelector(".diff-banner");
|
|
1706
|
+
if (firstBanner && firstBanner.nextSibling) {
|
|
1707
|
+
contentEl.insertBefore(banner, firstBanner.nextSibling);
|
|
1708
|
+
} else {
|
|
1709
|
+
contentEl.prepend(banner);
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
// ═══════════════════════════════════════════
|
|
1715
|
+
// ═══════════════════════════════════════════
|
|
1716
|
+
// AUDIT RESULT (shown after analysis completes)
|
|
1717
|
+
// ═══════════════════════════════════════════
|
|
1718
|
+
function showAuditResult(analysis) {
|
|
1719
|
+
const crawlEl = document.getElementById("code-crawl");
|
|
1720
|
+
const contentEl = document.getElementById("crawl-content");
|
|
1721
|
+
const headerName = document.getElementById("crawl-file-name");
|
|
1722
|
+
const headerStatus = document.getElementById("crawl-status");
|
|
1723
|
+
|
|
1724
|
+
// Stop any diff scroll animation
|
|
1725
|
+
if (diffScrollAnim) { cancelAnimationFrame(diffScrollAnim); diffScrollAnim = null; }
|
|
1726
|
+
|
|
1727
|
+
const isSafe = analysis.riskLevel === "SAFE" || analysis.riskLevel === "LOW";
|
|
1728
|
+
|
|
1729
|
+
if (isSafe) {
|
|
1730
|
+
// ── ALL CLEAR: clean audit passed ──
|
|
1731
|
+
headerName.textContent = "AUDIT COMPLETE";
|
|
1732
|
+
headerStatus.textContent = "ALL CLEAR";
|
|
1733
|
+
headerStatus.style.color = "#30d158";
|
|
1734
|
+
headerStatus.className = "";
|
|
1735
|
+
|
|
1736
|
+
contentEl.innerHTML = `
|
|
1737
|
+
<div class="audit-result audit-clear">
|
|
1738
|
+
<div class="audit-icon">✓</div>
|
|
1739
|
+
<div class="audit-title">안전하게 수정되었습니다</div>
|
|
1740
|
+
<div class="audit-detail">파일 간, DB 간, 구조 간 아무런 문제 없습니다.</div>
|
|
1741
|
+
<div class="audit-file">${analysis.file}</div>
|
|
1742
|
+
<div class="audit-meta">${analysis.affectedNodes?.length || 0} files checked · ${analysis.analysisMs}ms</div>
|
|
1743
|
+
</div>`;
|
|
1744
|
+
|
|
1745
|
+
// Fade out after 8s
|
|
1746
|
+
setTimeout(() => {
|
|
1747
|
+
crawlEl.classList.remove("active");
|
|
1748
|
+
contentEl.innerHTML = "";
|
|
1749
|
+
headerName.textContent = "";
|
|
1750
|
+
headerStatus.textContent = "";
|
|
1751
|
+
headerStatus.style.color = "";
|
|
1752
|
+
}, 8000);
|
|
1753
|
+
} else {
|
|
1754
|
+
// ── WARNING: issues found — keep showing ──
|
|
1755
|
+
headerName.textContent = analysis.file;
|
|
1756
|
+
const riskColors = { CRITICAL: "#ff0040", HIGH: "#ff2d55", MEDIUM: "#ff9f0a" };
|
|
1757
|
+
headerStatus.textContent = `${analysis.riskLevel} — 확인 필요`;
|
|
1758
|
+
headerStatus.style.color = riskColors[analysis.riskLevel] || "#ff9f0a";
|
|
1759
|
+
headerStatus.className = "";
|
|
1760
|
+
|
|
1761
|
+
// Keep existing diff content but prepend a warning summary
|
|
1762
|
+
const banner = document.createElement("div");
|
|
1763
|
+
banner.className = "audit-result audit-warning";
|
|
1764
|
+
banner.innerHTML = `
|
|
1765
|
+
<div class="audit-icon">!</div>
|
|
1766
|
+
<div class="audit-title">${analysis.riskLevel} — Attention Required</div>
|
|
1767
|
+
<div class="audit-detail">${analysis.summary}</div>
|
|
1768
|
+
${analysis.suggestion ? `<div class="audit-suggestion">${analysis.suggestion}</div>` : ""}
|
|
1769
|
+
<div class="audit-wait-notice">Security warnings are expected during active modifications. Please wait until all changes are complete before taking action.</div>
|
|
1770
|
+
`;
|
|
1771
|
+
contentEl.prepend(banner);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
// REAL-TIME AI CODE MODIFICATION API
|
|
1776
|
+
// ═══════════════════════════════════════════
|
|
1777
|
+
|
|
1778
|
+
window.syke = {
|
|
1779
|
+
// Mark a node as "being modified" — pulses orange
|
|
1780
|
+
startModifying(fileId) {
|
|
1781
|
+
modifyingNodes.add(fileId);
|
|
1782
|
+
const statusEl = document.getElementById("crawl-status");
|
|
1783
|
+
statusEl.textContent = "AI MODIFYING";
|
|
1784
|
+
statusEl.className = "modifying";
|
|
1785
|
+
// Continuous color refresh for pulsing effect
|
|
1786
|
+
if (!this._pulseInterval) {
|
|
1787
|
+
this._pulseInterval = setInterval(() => {
|
|
1788
|
+
if (modifyingNodes.size > 0 && Graph) {
|
|
1789
|
+
Graph.nodeColor(Graph.nodeColor());
|
|
1790
|
+
}
|
|
1791
|
+
}, 100);
|
|
1792
|
+
}
|
|
1793
|
+
// Dim non-protagonist nodes/particles immediately
|
|
1794
|
+
refreshGraph();
|
|
1795
|
+
},
|
|
1796
|
+
|
|
1797
|
+
// Mark a node as done modifying
|
|
1798
|
+
stopModifying(fileId) {
|
|
1799
|
+
modifyingNodes.delete(fileId);
|
|
1800
|
+
if (modifyingNodes.size === 0) {
|
|
1801
|
+
const statusEl = document.getElementById("crawl-status");
|
|
1802
|
+
statusEl.textContent = "IDLE";
|
|
1803
|
+
statusEl.className = "";
|
|
1804
|
+
if (this._pulseInterval) {
|
|
1805
|
+
clearInterval(this._pulseInterval);
|
|
1806
|
+
this._pulseInterval = null;
|
|
1807
|
+
}
|
|
1808
|
+
// Restore all nodes/particles to normal
|
|
1809
|
+
refreshGraph();
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
|
|
1813
|
+
// Push a code change into the crawl
|
|
1814
|
+
// type: "modified" | "added" | "deleted" | "error-line"
|
|
1815
|
+
pushCodeChange({ file, line, type, content }) {
|
|
1816
|
+
const contentEl = document.getElementById("crawl-content");
|
|
1817
|
+
if (!contentEl) return;
|
|
1818
|
+
|
|
1819
|
+
// Find existing line and highlight it
|
|
1820
|
+
const existing = contentEl.querySelector(
|
|
1821
|
+
`.crawl-line[data-file="${file}"][data-line="${line}"]`
|
|
1822
|
+
);
|
|
1823
|
+
if (existing) {
|
|
1824
|
+
existing.className = `crawl-line ${type}`;
|
|
1825
|
+
if (content !== undefined) {
|
|
1826
|
+
const escaped = content.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1827
|
+
existing.innerHTML = `<span class="cl-num">${line}</span>${escaped}`;
|
|
1828
|
+
}
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// If line not found in current crawl, inject it at the top
|
|
1833
|
+
const col = LAYER_HEX[this._getLayer(file)] || "#00d4ff";
|
|
1834
|
+
const escaped = (content || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1835
|
+
const div = document.createElement("div");
|
|
1836
|
+
div.className = `crawl-line ${type}`;
|
|
1837
|
+
div.dataset.file = file;
|
|
1838
|
+
div.dataset.line = line;
|
|
1839
|
+
div.innerHTML = `<span class="cl-num">${line}</span><span style="color:${col};opacity:0.4;margin-right:4px">[${file.split("/").pop()}]</span>${escaped}`;
|
|
1840
|
+
|
|
1841
|
+
// Insert at the beginning of crawl
|
|
1842
|
+
if (contentEl.firstChild) {
|
|
1843
|
+
contentEl.insertBefore(div, contentEl.firstChild);
|
|
1844
|
+
} else {
|
|
1845
|
+
contentEl.appendChild(div);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// Update total height
|
|
1849
|
+
crawlTotalHeight = contentEl.scrollHeight;
|
|
1850
|
+
},
|
|
1851
|
+
|
|
1852
|
+
// Push an error notification
|
|
1853
|
+
pushError({ file, line, message }) {
|
|
1854
|
+
this.pushCodeChange({
|
|
1855
|
+
file,
|
|
1856
|
+
line: line || 0,
|
|
1857
|
+
type: "error-line",
|
|
1858
|
+
content: `// ERROR: ${message}`,
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
// Also flash the node
|
|
1862
|
+
const node = graphData?.nodes.find(n => n.id === file);
|
|
1863
|
+
if (node) {
|
|
1864
|
+
modifyingNodes.add(file);
|
|
1865
|
+
setTimeout(() => {
|
|
1866
|
+
modifyingNodes.delete(file);
|
|
1867
|
+
if (Graph) Graph.nodeColor(Graph.nodeColor());
|
|
1868
|
+
}, 3000);
|
|
1869
|
+
}
|
|
1870
|
+
},
|
|
1871
|
+
|
|
1872
|
+
// Helper
|
|
1873
|
+
_getLayer(fileId) {
|
|
1874
|
+
const node = graphData?.nodes.find(n => n.id === fileId);
|
|
1875
|
+
return node ? node.layer : "UTIL";
|
|
1876
|
+
},
|
|
1877
|
+
|
|
1878
|
+
_pulseInterval: null,
|
|
1879
|
+
};
|
|
1880
|
+
|
|
1881
|
+
// ═══════════════════════════════════════════
|
|
1882
|
+
// SSE: REAL-TIME FILE MONITORING
|
|
1883
|
+
// ═══════════════════════════════════════════
|
|
1884
|
+
let sseSource = null;
|
|
1885
|
+
let sseReconnectTimer = null;
|
|
1886
|
+
const realtimeLog = []; // recent events for panel
|
|
1887
|
+
|
|
1888
|
+
function initSSE() {
|
|
1889
|
+
if (sseSource) { sseSource.close(); sseSource = null; }
|
|
1890
|
+
|
|
1891
|
+
sseSource = new EventSource("/api/events");
|
|
1892
|
+
|
|
1893
|
+
sseSource.addEventListener("connected", (e) => {
|
|
1894
|
+
const data = JSON.parse(e.data);
|
|
1895
|
+
console.log("[SYKE:SSE] Connected, cache:", data.cacheSize, "files");
|
|
1896
|
+
updateSSEStatus("LIVE", "connected");
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
sseSource.addEventListener("file-change", (e) => {
|
|
1900
|
+
const data = JSON.parse(e.data);
|
|
1901
|
+
console.log("[SYKE:SSE] File change:", data.file, data.type, data.diffCount, "diffs");
|
|
1902
|
+
|
|
1903
|
+
// ── 1. Auto-select the modified node in 3D graph ──
|
|
1904
|
+
selectedFile = data.file;
|
|
1905
|
+
selectedNodeId = data.file;
|
|
1906
|
+
highlightNodes.clear();
|
|
1907
|
+
highlightNodes.add(data.file);
|
|
1908
|
+
if (data.connectedNodes) {
|
|
1909
|
+
data.connectedNodes.forEach(id => highlightNodes.add(id));
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// ── 2. Focus camera on the modified node ──
|
|
1913
|
+
focusCameraOnNode(data.file);
|
|
1914
|
+
|
|
1915
|
+
// ── 3. Pulse the modified node (bright orange) ──
|
|
1916
|
+
window.syke.startModifying(data.file);
|
|
1917
|
+
|
|
1918
|
+
// ── 4. Start heartbeat on connected nodes (risk TBD, will upgrade after analysis) ──
|
|
1919
|
+
if (data.connectedNodes && data.connectedNodes.length > 0) {
|
|
1920
|
+
startHeartbeat(data.connectedNodes, "MEDIUM"); // temporary risk, upgraded after analysis
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
// ── 5. Show real-time diff with animated scroll ──
|
|
1924
|
+
showRealtimeDiff(data);
|
|
1925
|
+
|
|
1926
|
+
// ── 6. Highlight connected links ──
|
|
1927
|
+
highlightLinks.clear();
|
|
1928
|
+
graphData.links.forEach(l => {
|
|
1929
|
+
if (highlightNodes.has(getSrcId(l)) && highlightNodes.has(getTgtId(l))) highlightLinks.add(l);
|
|
1930
|
+
});
|
|
1931
|
+
refreshGraph();
|
|
1932
|
+
|
|
1933
|
+
addRealtimeEvent({
|
|
1934
|
+
type: "change",
|
|
1935
|
+
file: data.file,
|
|
1936
|
+
changeType: data.type,
|
|
1937
|
+
diffCount: data.diffCount,
|
|
1938
|
+
timestamp: data.timestamp,
|
|
1939
|
+
});
|
|
1940
|
+
|
|
1941
|
+
updateSSEStatus("CHANGE DETECTED", "warning");
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
sseSource.addEventListener("analysis-start", (e) => {
|
|
1945
|
+
const data = JSON.parse(e.data);
|
|
1946
|
+
console.log("[SYKE:SSE] AI analyzing:", data.file);
|
|
1947
|
+
updateSSEStatus("AI ANALYZING...", "analyzing");
|
|
1948
|
+
|
|
1949
|
+
addRealtimeEvent({
|
|
1950
|
+
type: "analyzing",
|
|
1951
|
+
file: data.file,
|
|
1952
|
+
timestamp: Date.now(),
|
|
1953
|
+
});
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
sseSource.addEventListener("analysis-result", (e) => {
|
|
1957
|
+
const analysis = JSON.parse(e.data);
|
|
1958
|
+
console.log("[SYKE:SSE] Analysis result:", analysis.file, analysis.riskLevel);
|
|
1959
|
+
|
|
1960
|
+
// ── Stop modifying pulse on the changed file ──
|
|
1961
|
+
setTimeout(() => window.syke.stopModifying(analysis.file), 2000);
|
|
1962
|
+
|
|
1963
|
+
// ── Upgrade heartbeat to real risk level from Gemini ──
|
|
1964
|
+
// Stop the temporary MEDIUM heartbeat and restart with actual risk
|
|
1965
|
+
const connectedIds = analysis.affectedNodes || [];
|
|
1966
|
+
stopHeartbeat(connectedIds);
|
|
1967
|
+
|
|
1968
|
+
if (connectedIds.length > 0 && analysis.riskLevel !== "SAFE") {
|
|
1969
|
+
startHeartbeat(connectedIds, analysis.riskLevel);
|
|
1970
|
+
|
|
1971
|
+
// Duration based on severity
|
|
1972
|
+
const duration = analysis.riskLevel === "CRITICAL" ? 15000
|
|
1973
|
+
: analysis.riskLevel === "HIGH" ? 10000
|
|
1974
|
+
: analysis.riskLevel === "MEDIUM" ? 6000
|
|
1975
|
+
: 3000;
|
|
1976
|
+
|
|
1977
|
+
setTimeout(() => stopHeartbeat(connectedIds), duration);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Show warnings in code crawl
|
|
1981
|
+
if (analysis.warnings && analysis.warnings.length > 0) {
|
|
1982
|
+
analysis.warnings.forEach(w => {
|
|
1983
|
+
window.syke.pushError({ file: analysis.file, line: 0, message: w });
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Update diff view with analysis risk badge
|
|
1988
|
+
updateDiffWithAnalysis(analysis);
|
|
1989
|
+
|
|
1990
|
+
addRealtimeEvent({
|
|
1991
|
+
type: "result",
|
|
1992
|
+
file: analysis.file,
|
|
1993
|
+
riskLevel: analysis.riskLevel,
|
|
1994
|
+
summary: analysis.summary,
|
|
1995
|
+
brokenImports: analysis.brokenImports,
|
|
1996
|
+
sideEffects: analysis.sideEffects,
|
|
1997
|
+
warnings: analysis.warnings,
|
|
1998
|
+
suggestion: analysis.suggestion,
|
|
1999
|
+
affectedCount: analysis.affectedNodes?.length || 0,
|
|
2000
|
+
analysisMs: analysis.analysisMs,
|
|
2001
|
+
timestamp: analysis.timestamp,
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
// Update status with risk level
|
|
2005
|
+
const statusMap = {
|
|
2006
|
+
CRITICAL: ["CRITICAL RISK", "critical"],
|
|
2007
|
+
HIGH: ["HIGH RISK", "danger"],
|
|
2008
|
+
MEDIUM: ["MEDIUM RISK", "warning"],
|
|
2009
|
+
LOW: ["LOW RISK", "safe"],
|
|
2010
|
+
SAFE: ["SAFE", "connected"],
|
|
2011
|
+
};
|
|
2012
|
+
const [text, cls] = statusMap[analysis.riskLevel] || ["ANALYZED", "connected"];
|
|
2013
|
+
updateSSEStatus(text, cls);
|
|
2014
|
+
|
|
2015
|
+
// ── Auto-clear code crawl + show ALL CLEAR after analysis ──
|
|
2016
|
+
const isSafe = analysis.riskLevel === "SAFE" || analysis.riskLevel === "LOW";
|
|
2017
|
+
const clearDelay = analysis.riskLevel === "CRITICAL" ? 15000
|
|
2018
|
+
: analysis.riskLevel === "HIGH" ? 10000
|
|
2019
|
+
: analysis.riskLevel === "MEDIUM" ? 8000
|
|
2020
|
+
: 5000; // SAFE/LOW: 5s then clear
|
|
2021
|
+
|
|
2022
|
+
setTimeout(() => {
|
|
2023
|
+
// Clear code crawl panel — show ALL CLEAR or warning summary
|
|
2024
|
+
showAuditResult(analysis);
|
|
2025
|
+
// Clear selected node highlight
|
|
2026
|
+
if (isSafe) {
|
|
2027
|
+
highlightNodes.clear();
|
|
2028
|
+
highlightLinks.clear();
|
|
2029
|
+
selectedFile = null;
|
|
2030
|
+
selectedNodeId = null;
|
|
2031
|
+
refreshGraph();
|
|
2032
|
+
}
|
|
2033
|
+
updateSSEStatus("LIVE", "connected");
|
|
2034
|
+
}, clearDelay);
|
|
2035
|
+
|
|
2036
|
+
// Update the realtime panel
|
|
2037
|
+
renderRealtimePanel();
|
|
2038
|
+
});
|
|
2039
|
+
|
|
2040
|
+
sseSource.addEventListener("analysis-error", (e) => {
|
|
2041
|
+
const data = JSON.parse(e.data);
|
|
2042
|
+
console.error("[SYKE:SSE] Analysis error:", data.file, data.error);
|
|
2043
|
+
window.syke.stopModifying(data.file);
|
|
2044
|
+
updateSSEStatus("AI ERROR", "critical");
|
|
2045
|
+
setTimeout(() => updateSSEStatus("LIVE", "connected"), 5000);
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
sseSource.addEventListener("graph-rebuild", (e) => {
|
|
2049
|
+
const data = JSON.parse(e.data);
|
|
2050
|
+
console.log("[SYKE:SSE] Graph rebuild triggered:", data.reason, data.file);
|
|
2051
|
+
// Reload graph data after a short delay
|
|
2052
|
+
setTimeout(async () => {
|
|
2053
|
+
await loadGraph();
|
|
2054
|
+
await loadHubFiles();
|
|
2055
|
+
}, 1000);
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
sseSource.addEventListener("project-switched", (e) => {
|
|
2059
|
+
const data = JSON.parse(e.data);
|
|
2060
|
+
console.log("[SYKE:SSE] Project switched:", data.projectRoot);
|
|
2061
|
+
loadProjectInfo();
|
|
2062
|
+
loadGraph();
|
|
2063
|
+
loadHubFiles();
|
|
2064
|
+
updateSSEStatus("PROJECT LOADED", "connected");
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
sseSource.onerror = () => {
|
|
2068
|
+
console.warn("[SYKE:SSE] Connection error, reconnecting...");
|
|
2069
|
+
updateSSEStatus("OFFLINE", "offline");
|
|
2070
|
+
sseSource.close();
|
|
2071
|
+
sseSource = null;
|
|
2072
|
+
if (sseReconnectTimer) clearTimeout(sseReconnectTimer);
|
|
2073
|
+
sseReconnectTimer = setTimeout(initSSE, 3000);
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function updateSSEStatus(text, className) {
|
|
2078
|
+
const dot = document.querySelector(".pulse-dot");
|
|
2079
|
+
const indicator = document.getElementById("sse-status");
|
|
2080
|
+
if (dot) {
|
|
2081
|
+
dot.className = "pulse-dot " + (className || "");
|
|
2082
|
+
}
|
|
2083
|
+
if (indicator) {
|
|
2084
|
+
indicator.textContent = text;
|
|
2085
|
+
indicator.className = "sse-indicator " + (className || "");
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function addRealtimeEvent(event) {
|
|
2090
|
+
realtimeLog.unshift(event);
|
|
2091
|
+
if (realtimeLog.length > 50) realtimeLog.pop();
|
|
2092
|
+
renderRealtimePanel();
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function renderRealtimePanel() {
|
|
2096
|
+
const panel = document.getElementById("realtime-log");
|
|
2097
|
+
if (!panel) return;
|
|
2098
|
+
|
|
2099
|
+
if (realtimeLog.length === 0) {
|
|
2100
|
+
panel.innerHTML = '<p class="placeholder">Waiting for file changes...</p>';
|
|
2101
|
+
return;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
let html = "";
|
|
2105
|
+
for (const evt of realtimeLog.slice(0, 20)) {
|
|
2106
|
+
const time = new Date(evt.timestamp).toLocaleTimeString();
|
|
2107
|
+
|
|
2108
|
+
if (evt.type === "change") {
|
|
2109
|
+
const icon = evt.changeType === "added" ? "+" : evt.changeType === "deleted" ? "x" : "~";
|
|
2110
|
+
html += `<div class="rt-event rt-${evt.changeType}">
|
|
2111
|
+
<span class="rt-time">${time}</span>
|
|
2112
|
+
<span class="rt-icon">${icon}</span>
|
|
2113
|
+
<span class="rt-file">${evt.file}</span>
|
|
2114
|
+
<span class="rt-diff">${evt.diffCount} changes</span>
|
|
2115
|
+
</div>`;
|
|
2116
|
+
} else if (evt.type === "analyzing") {
|
|
2117
|
+
html += `<div class="rt-event rt-analyzing">
|
|
2118
|
+
<span class="rt-time">${time}</span>
|
|
2119
|
+
<span class="rt-icon">⚙</span>
|
|
2120
|
+
<span class="rt-msg">AI analyzing ${evt.file}...</span>
|
|
2121
|
+
</div>`;
|
|
2122
|
+
} else if (evt.type === "result") {
|
|
2123
|
+
const riskCls = evt.riskLevel === "CRITICAL" ? "critical" : evt.riskLevel === "HIGH" ? "danger" : evt.riskLevel === "MEDIUM" ? "warning" : "safe";
|
|
2124
|
+
html += `<div class="rt-event rt-result rt-${riskCls}">
|
|
2125
|
+
<span class="rt-time">${time}</span>
|
|
2126
|
+
<span class="rt-risk ${riskCls}">${evt.riskLevel}</span>
|
|
2127
|
+
<span class="rt-file">${evt.file}</span>
|
|
2128
|
+
<span class="rt-ms">${evt.analysisMs}ms</span>
|
|
2129
|
+
</div>`;
|
|
2130
|
+
if (evt.summary) {
|
|
2131
|
+
html += `<div class="rt-detail">${evt.summary}</div>`;
|
|
2132
|
+
}
|
|
2133
|
+
if (evt.brokenImports && evt.brokenImports.length > 0) {
|
|
2134
|
+
html += `<div class="rt-detail rt-broken">BROKEN: ${evt.brokenImports.join(", ")}</div>`;
|
|
2135
|
+
}
|
|
2136
|
+
if (evt.warnings && evt.warnings.length > 0) {
|
|
2137
|
+
evt.warnings.forEach(w => {
|
|
2138
|
+
html += `<div class="rt-detail rt-warn">${w}</div>`;
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
if (evt.suggestion) {
|
|
2142
|
+
html += `<div class="rt-detail rt-suggestion">${evt.suggestion}</div>`;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
panel.innerHTML = html;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// ═══════════════════════════════════════════
|
|
2151
|
+
// RESIZABLE RIGHT PANEL
|
|
2152
|
+
// ═══════════════════════════════════════════
|
|
2153
|
+
function setupResizeHandle() {
|
|
2154
|
+
const handle = document.getElementById("resize-handle");
|
|
2155
|
+
const panel = document.getElementById("right-panel");
|
|
2156
|
+
const hub = document.getElementById("hub-drawer");
|
|
2157
|
+
if (!handle || !panel) return;
|
|
2158
|
+
|
|
2159
|
+
let startX = 0;
|
|
2160
|
+
let startW = 0;
|
|
2161
|
+
|
|
2162
|
+
function onMove(e) {
|
|
2163
|
+
e.preventDefault();
|
|
2164
|
+
const dx = startX - e.clientX;
|
|
2165
|
+
const newW = Math.max(250, Math.min(900, startW + dx));
|
|
2166
|
+
panel.style.flex = "0 0 " + newW + "px";
|
|
2167
|
+
panel.style.width = newW + "px";
|
|
2168
|
+
if (hub) hub.style.right = newW + "px";
|
|
2169
|
+
if (Graph) {
|
|
2170
|
+
const gp = document.getElementById("graph-panel");
|
|
2171
|
+
if (gp) Graph.width(gp.clientWidth);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
function onUp() {
|
|
2176
|
+
window.removeEventListener("mousemove", onMove, true);
|
|
2177
|
+
window.removeEventListener("mouseup", onUp, true);
|
|
2178
|
+
handle.classList.remove("dragging");
|
|
2179
|
+
document.body.style.cursor = "";
|
|
2180
|
+
document.body.style.userSelect = "";
|
|
2181
|
+
document.body.style.pointerEvents = "";
|
|
2182
|
+
const overlay = document.getElementById("resize-overlay");
|
|
2183
|
+
if (overlay) overlay.remove();
|
|
2184
|
+
if (Graph) {
|
|
2185
|
+
const gp = document.getElementById("graph-panel");
|
|
2186
|
+
if (gp) Graph.width(gp.clientWidth);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
handle.addEventListener("mousedown", (e) => {
|
|
2191
|
+
e.preventDefault();
|
|
2192
|
+
e.stopPropagation();
|
|
2193
|
+
startX = e.clientX;
|
|
2194
|
+
startW = panel.offsetWidth;
|
|
2195
|
+
handle.classList.add("dragging");
|
|
2196
|
+
document.body.style.cursor = "col-resize";
|
|
2197
|
+
document.body.style.userSelect = "none";
|
|
2198
|
+
// Full-screen overlay prevents canvas from stealing events
|
|
2199
|
+
const overlay = document.createElement("div");
|
|
2200
|
+
overlay.id = "resize-overlay";
|
|
2201
|
+
overlay.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;cursor:col-resize;background:transparent;";
|
|
2202
|
+
document.body.appendChild(overlay);
|
|
2203
|
+
// Capture-phase listeners on window — nothing can intercept
|
|
2204
|
+
window.addEventListener("mousemove", onMove, true);
|
|
2205
|
+
window.addEventListener("mouseup", onUp, true);
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// ═══════════════════════════════════════════
|
|
2210
|
+
// PROJECT SELECTOR
|
|
2211
|
+
// ═══════════════════════════════════════════
|
|
2212
|
+
async function loadProjectInfo() {
|
|
2213
|
+
try {
|
|
2214
|
+
const res = await fetch("/api/project-info");
|
|
2215
|
+
const info = await res.json();
|
|
2216
|
+
const el = document.getElementById("current-project");
|
|
2217
|
+
if (el) {
|
|
2218
|
+
const short = info.projectRoot.length > 50
|
|
2219
|
+
? "..." + info.projectRoot.slice(-47)
|
|
2220
|
+
: info.projectRoot;
|
|
2221
|
+
el.textContent = short;
|
|
2222
|
+
el.title = info.projectRoot + " | " + info.languages.join(", ") + " | " + info.fileCount + " files";
|
|
2223
|
+
}
|
|
2224
|
+
} catch (e) {
|
|
2225
|
+
console.warn("[SYKE] Failed to load project info:", e);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
let browsePath = null; // current path in folder browser
|
|
2230
|
+
|
|
2231
|
+
async function browseDir(dirPath) {
|
|
2232
|
+
const listEl = document.getElementById("browse-dir-list");
|
|
2233
|
+
const pathEl = document.getElementById("browse-current-path");
|
|
2234
|
+
const infoEl = document.getElementById("project-detect-info");
|
|
2235
|
+
const loadBtn = document.getElementById("btn-project-load");
|
|
2236
|
+
const upBtn = document.getElementById("btn-browse-up");
|
|
2237
|
+
|
|
2238
|
+
if (listEl) listEl.innerHTML = '<div class="browse-empty"><div class="spinner"></div> SCANNING...</div>';
|
|
2239
|
+
|
|
2240
|
+
try {
|
|
2241
|
+
const url = dirPath ? `/api/browse-dirs?path=${encodeURIComponent(dirPath)}` : "/api/browse-dirs";
|
|
2242
|
+
const res = await fetch(url);
|
|
2243
|
+
const data = await res.json();
|
|
2244
|
+
|
|
2245
|
+
if (!res.ok) {
|
|
2246
|
+
if (listEl) listEl.innerHTML = `<div class="browse-empty">ERROR: ${data.error}</div>`;
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
browsePath = data.current;
|
|
2251
|
+
if (pathEl) {
|
|
2252
|
+
pathEl.textContent = data.current;
|
|
2253
|
+
pathEl.title = data.current;
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// Up button
|
|
2257
|
+
if (upBtn) upBtn.disabled = !data.parent;
|
|
2258
|
+
|
|
2259
|
+
// Always allow selection, show project detection as hint
|
|
2260
|
+
if (loadBtn) loadBtn.disabled = false;
|
|
2261
|
+
if (data.isProject) {
|
|
2262
|
+
if (infoEl) {
|
|
2263
|
+
infoEl.className = "project-detected";
|
|
2264
|
+
infoEl.textContent = "PROJECT DETECTED (" + data.detectedMarker + ")";
|
|
2265
|
+
}
|
|
2266
|
+
} else {
|
|
2267
|
+
if (infoEl) {
|
|
2268
|
+
infoEl.className = "";
|
|
2269
|
+
infoEl.textContent = "";
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// Render directory list
|
|
2274
|
+
if (!data.dirs || data.dirs.length === 0) {
|
|
2275
|
+
if (listEl) listEl.innerHTML = '<div class="browse-empty">NO SUBDIRECTORIES</div>';
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
let html = "";
|
|
2280
|
+
for (const dir of data.dirs) {
|
|
2281
|
+
const fullPath = data.current.replace(/\\/g, "/").replace(/\/$/, "") + "/" + dir;
|
|
2282
|
+
html += '<div class="browse-dir-item" data-path="' + fullPath.replace(/"/g, """) + '">' +
|
|
2283
|
+
'<span class="dir-icon">📁</span>' +
|
|
2284
|
+
'<span class="dir-name">' + dir + '</span></div>';
|
|
2285
|
+
}
|
|
2286
|
+
if (listEl) {
|
|
2287
|
+
listEl.innerHTML = html;
|
|
2288
|
+
listEl.querySelectorAll(".browse-dir-item").forEach(function(item) {
|
|
2289
|
+
item.addEventListener("click", function() { browseDir(item.dataset.path); });
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
} catch (e) {
|
|
2293
|
+
if (listEl) listEl.innerHTML = '<div class="browse-empty">NETWORK ERROR</div>';
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
async function switchProject(projectPath) {
|
|
2298
|
+
const infoEl = document.getElementById("project-detect-info");
|
|
2299
|
+
const loadBtn = document.getElementById("btn-project-load");
|
|
2300
|
+
|
|
2301
|
+
if (infoEl) {
|
|
2302
|
+
infoEl.className = "";
|
|
2303
|
+
infoEl.innerHTML = '<div class="project-loading"><div class="spinner"></div>LOADING PROJECT...</div>';
|
|
2304
|
+
}
|
|
2305
|
+
if (loadBtn) loadBtn.disabled = true;
|
|
2306
|
+
|
|
2307
|
+
try {
|
|
2308
|
+
const res = await fetch("/api/switch-project", {
|
|
2309
|
+
method: "POST",
|
|
2310
|
+
headers: { "Content-Type": "application/json" },
|
|
2311
|
+
body: JSON.stringify({ projectRoot: projectPath }),
|
|
2312
|
+
});
|
|
2313
|
+
const data = await res.json();
|
|
2314
|
+
|
|
2315
|
+
if (!res.ok) {
|
|
2316
|
+
if (infoEl) {
|
|
2317
|
+
infoEl.className = "error";
|
|
2318
|
+
infoEl.textContent = data.error || "Failed to load project";
|
|
2319
|
+
}
|
|
2320
|
+
if (loadBtn) loadBtn.disabled = false;
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
if (infoEl) {
|
|
2325
|
+
infoEl.className = "success";
|
|
2326
|
+
infoEl.textContent = "LOADED: " + data.languages.join(", ") + " | " + data.fileCount + " files | " + data.edgeCount + " edges";
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
setTimeout(function() {
|
|
2330
|
+
document.getElementById("project-modal").classList.add("hidden");
|
|
2331
|
+
if (loadBtn) loadBtn.disabled = false;
|
|
2332
|
+
}, 800);
|
|
2333
|
+
|
|
2334
|
+
await loadProjectInfo();
|
|
2335
|
+
await loadGraph();
|
|
2336
|
+
await loadHubFiles();
|
|
2337
|
+
handleBackgroundClick();
|
|
2338
|
+
|
|
2339
|
+
} catch (e) {
|
|
2340
|
+
if (infoEl) {
|
|
2341
|
+
infoEl.className = "error";
|
|
2342
|
+
infoEl.textContent = "NETWORK ERROR: " + e.message;
|
|
2343
|
+
}
|
|
2344
|
+
if (loadBtn) loadBtn.disabled = false;
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function setupProjectModal() {
|
|
2349
|
+
const openBtn = document.getElementById("btn-change-project");
|
|
2350
|
+
const modal = document.getElementById("project-modal");
|
|
2351
|
+
const loadBtn = document.getElementById("btn-project-load");
|
|
2352
|
+
const cancelBtn = document.getElementById("btn-project-cancel");
|
|
2353
|
+
const upBtn = document.getElementById("btn-browse-up");
|
|
2354
|
+
|
|
2355
|
+
if (!openBtn || !modal) return;
|
|
2356
|
+
|
|
2357
|
+
openBtn.addEventListener("click", async function() {
|
|
2358
|
+
modal.classList.remove("hidden");
|
|
2359
|
+
// Start from current project's parent dir
|
|
2360
|
+
try {
|
|
2361
|
+
const res = await fetch("/api/project-info");
|
|
2362
|
+
const info = await res.json();
|
|
2363
|
+
const startPath = info.projectRoot.replace(/[/\\][^/\\]+$/, "");
|
|
2364
|
+
browseDir(startPath);
|
|
2365
|
+
} catch (_) {
|
|
2366
|
+
browseDir(null);
|
|
2367
|
+
}
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
if (cancelBtn) {
|
|
2371
|
+
cancelBtn.addEventListener("click", function() { modal.classList.add("hidden"); });
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
if (upBtn) {
|
|
2375
|
+
upBtn.addEventListener("click", function() {
|
|
2376
|
+
if (browsePath) {
|
|
2377
|
+
const parent = browsePath.replace(/[/\\][^/\\]+$/, "");
|
|
2378
|
+
if (parent && parent !== browsePath) browseDir(parent);
|
|
2379
|
+
}
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
if (loadBtn) {
|
|
2384
|
+
loadBtn.addEventListener("click", function() {
|
|
2385
|
+
if (browsePath) switchProject(browsePath);
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
document.addEventListener("keydown", function(e) {
|
|
2390
|
+
if (e.key === "Escape" && !modal.classList.contains("hidden")) {
|
|
2391
|
+
modal.classList.add("hidden");
|
|
2392
|
+
}
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
modal.addEventListener("click", function(e) {
|
|
2396
|
+
if (e.target === modal) modal.classList.add("hidden");
|
|
2397
|
+
});
|
|
2398
|
+
}
|