@syke1/mcp-server 1.3.20 → 1.4.1

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.
@@ -20,12 +20,424 @@ let heartbeatNodes = new Map(); // nodeId → { riskLevel, startTime, interval }
20
20
  let diffScrollAnim = null; // animation for diff scroll
21
21
  let knownNodeIds = new Set(); // track existing nodes for star-birth detection
22
22
  let birthAnimations = new Map(); // nodeId → { startTime, spawnPos, targetPos }
23
-
24
- const LAYER_HEX = {
23
+ let searchActive = false; // true when search input has text
24
+ let _searchRAF = null; // RAF loop for search glow animation
25
+
26
+ // ── File Tree state ──
27
+ let fileTreeData = null; // built tree structure
28
+ let fileTreeExpanded = new Set(); // open folder paths
29
+ let fileTreeFilter = ''; // search filter
30
+ let fileTreeSort = 'name'; // sort mode
31
+ let fileTreeModified = new Map(); // path → {type, timestamp}
32
+ let fileTreeVisible = true; // panel visibility
33
+ let _fileTreeRenderTimer = null; // debounce timer
34
+ let _treeScrollLock = false; // manual scroll lock
35
+ let _treeScrollLockTimer = null; // scroll lock timer
36
+
37
+ let LAYER_HEX = {
25
38
  FE: "#00d4ff", BE: "#c084fc", DB: "#ff6b35",
26
39
  API: "#00ffaa", CONFIG: "#ffd700", UTIL: "#ff69b4",
27
40
  };
28
41
 
42
+ // ═══════════════════════════════════════════
43
+ // SETTINGS SYSTEM
44
+ // ═══════════════════════════════════════════
45
+ const SETTINGS_DEFAULTS = {
46
+ nodes: {
47
+ sizeMin: 30,
48
+ sizeMultiplier: 3,
49
+ opacity: 1.0,
50
+ resolution: 16,
51
+ selectedColor: "#ffffff",
52
+ },
53
+ links: {
54
+ normalWidth: 1.5,
55
+ highlightWidth: 4.0,
56
+ opacity: 0.9,
57
+ curvatureBase: 0.12,
58
+ normalAlpha: 0.25,
59
+ highlightColor: "#ff2d55",
60
+ },
61
+ particles: {
62
+ normalCount: 6,
63
+ highlightCount: 14,
64
+ normalWidth: 2.0,
65
+ highlightWidth: 4.5,
66
+ normalSpeed: 0.006,
67
+ highlightSpeed: 0.004,
68
+ highlightColor: "#ffffff",
69
+ },
70
+ colors: {
71
+ FE: "#00d4ff",
72
+ BE: "#c084fc",
73
+ DB: "#ff6b35",
74
+ API: "#00ffaa",
75
+ CONFIG: "#ffd700",
76
+ UTIL: "#ff69b4",
77
+ },
78
+ arrows: {
79
+ length: 4,
80
+ position: 1,
81
+ },
82
+ scene: {
83
+ background: "#050a18",
84
+ ambientIntensity: 8,
85
+ pointIntensity: 3,
86
+ pointDistance: 5000,
87
+ fogDensity: 0.00012,
88
+ scanlineOpacity: 1.0,
89
+ },
90
+ camera: {
91
+ initialZ: 3500,
92
+ autoRotateSpeed: 0.0005,
93
+ autoRotateRadius: 1600,
94
+ resetDistance: 1600,
95
+ },
96
+ animation: {
97
+ birthDuration: 2500,
98
+ birthScale: 3,
99
+ spawnX: 5000,
100
+ spawnY: 4000,
101
+ spawnZ: -2000,
102
+ },
103
+ physics: {
104
+ alphaDecay: 0.008,
105
+ velocityDecay: 0.3,
106
+ chargeStrength: -800,
107
+ sameLayerDistance: 250,
108
+ crossLayerDistance: 900,
109
+ clusterStrength: 0.015,
110
+ },
111
+ fileTree: {
112
+ panelWidth: 280,
113
+ fontSize: 11,
114
+ indentSize: 14,
115
+ pulseDuration: 5,
116
+ showLineCount: false,
117
+ showDeps: true,
118
+ showRisk: true,
119
+ autoScrollOnChange: true,
120
+ compactMode: false,
121
+ },
122
+ };
123
+
124
+ function deepMerge(defaults, override) {
125
+ const result = {};
126
+ for (const key of Object.keys(defaults)) {
127
+ if (typeof defaults[key] === "object" && defaults[key] !== null && !Array.isArray(defaults[key])) {
128
+ result[key] = deepMerge(defaults[key], override?.[key] || {});
129
+ } else {
130
+ result[key] = override?.[key] !== undefined ? override[key] : defaults[key];
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+
136
+ function loadSettings() {
137
+ try {
138
+ const stored = JSON.parse(localStorage.getItem("syke-dashboard-settings") || "{}");
139
+ return deepMerge(SETTINGS_DEFAULTS, stored);
140
+ } catch (e) {
141
+ console.warn("[SYKE] Failed to load settings, using defaults", e);
142
+ return deepMerge(SETTINGS_DEFAULTS, {});
143
+ }
144
+ }
145
+
146
+ const SETTINGS = loadSettings();
147
+
148
+ // Apply colors from settings on startup
149
+ Object.assign(LAYER_HEX, SETTINGS.colors);
150
+
151
+ function saveSettings() {
152
+ try {
153
+ localStorage.setItem("syke-dashboard-settings", JSON.stringify(SETTINGS));
154
+ } catch (e) {
155
+ console.warn("[SYKE] Failed to save settings", e);
156
+ }
157
+ }
158
+
159
+ function resetSettingsGroup(group) {
160
+ if (SETTINGS_DEFAULTS[group]) {
161
+ SETTINGS[group] = deepMerge(SETTINGS_DEFAULTS[group], {});
162
+ saveSettings();
163
+ }
164
+ }
165
+
166
+ function resetAllSettings() {
167
+ for (const group of Object.keys(SETTINGS_DEFAULTS)) {
168
+ SETTINGS[group] = deepMerge(SETTINGS_DEFAULTS[group], {});
169
+ }
170
+ Object.assign(LAYER_HEX, SETTINGS.colors);
171
+ saveSettings();
172
+ }
173
+
174
+ function applySettings(group) {
175
+ if (!Graph) return;
176
+ switch (group) {
177
+ case "nodes":
178
+ refreshGraph();
179
+ break;
180
+ case "links":
181
+ Graph.linkCurvature(link => SETTINGS.links.curvatureBase + (hc(getSrcId(link) + getTgtId(link)) % 20) * 0.01);
182
+ Graph.linkOpacity(SETTINGS.links.opacity);
183
+ refreshGraph();
184
+ break;
185
+ case "particles":
186
+ refreshGraph();
187
+ break;
188
+ case "colors":
189
+ Object.assign(LAYER_HEX, SETTINGS.colors);
190
+ if (graphData) {
191
+ const layerCounts = {};
192
+ graphData.nodes.forEach(n => { layerCounts[n.layer] = (layerCounts[n.layer] || 0) + 1; });
193
+ buildLegend(layerCounts);
194
+ }
195
+ refreshGraph();
196
+ break;
197
+ case "arrows":
198
+ Graph.linkDirectionalArrowLength(SETTINGS.arrows.length);
199
+ Graph.linkDirectionalArrowRelPos(SETTINGS.arrows.position);
200
+ break;
201
+ case "scene":
202
+ Graph.backgroundColor(SETTINGS.scene.background);
203
+ try {
204
+ const scene = Graph.scene();
205
+ if (scene) {
206
+ scene.children.forEach(c => {
207
+ if (c.isAmbientLight) c.intensity = SETTINGS.scene.ambientIntensity;
208
+ if (c.isPointLight) { c.intensity = SETTINGS.scene.pointIntensity; c.distance = SETTINGS.scene.pointDistance; }
209
+ });
210
+ if (scene.fog) scene.fog.density = SETTINGS.scene.fogDensity;
211
+ }
212
+ } catch(e) {}
213
+ const scanline = document.getElementById("scanline");
214
+ if (scanline) scanline.style.opacity = SETTINGS.scene.scanlineOpacity;
215
+ break;
216
+ case "physics":
217
+ Graph.d3AlphaDecay(SETTINGS.physics.alphaDecay);
218
+ Graph.d3VelocityDecay(SETTINGS.physics.velocityDecay);
219
+ try {
220
+ Graph.d3Force("charge").strength(SETTINGS.physics.chargeStrength);
221
+ Graph.d3Force("link")
222
+ .distance(l => srcLayer(l) === tgtLayer(l) ? SETTINGS.physics.sameLayerDistance : SETTINGS.physics.crossLayerDistance);
223
+ Graph.d3Force("cluster", clusterForce(SETTINGS.physics.clusterStrength));
224
+ } catch(e) {}
225
+ Graph.d3ReheatSimulation();
226
+ break;
227
+ case "camera":
228
+ // Camera values are read live in autoRotate loop, no action needed
229
+ break;
230
+ case "animation":
231
+ // Animation values are read when new nodes appear, no action needed
232
+ break;
233
+ case "fileTree":
234
+ applyFileTreeSettings();
235
+ break;
236
+ }
237
+ }
238
+
239
+ function setupSettings() {
240
+ const CTRL_MAP = [
241
+ // [group, key, selector, type]
242
+ ["nodes", "sizeMin", "#set-node-sizeMin", "range"],
243
+ ["nodes", "sizeMultiplier", "#set-node-sizeMultiplier", "range"],
244
+ ["nodes", "opacity", "#set-node-opacity", "range"],
245
+ ["nodes", "resolution", "#set-node-resolution", "range"],
246
+ ["nodes", "selectedColor", "#set-node-selectedColor", "color"],
247
+ ["links", "normalWidth", "#set-link-normalWidth", "range"],
248
+ ["links", "highlightWidth", "#set-link-highlightWidth", "range"],
249
+ ["links", "opacity", "#set-link-opacity", "range"],
250
+ ["links", "curvatureBase", "#set-link-curvatureBase", "range"],
251
+ ["links", "normalAlpha", "#set-link-normalAlpha", "range"],
252
+ ["links", "highlightColor", "#set-link-highlightColor", "color"],
253
+ ["particles", "normalCount", "#set-part-normalCount", "range"],
254
+ ["particles", "highlightCount", "#set-part-highlightCount", "range"],
255
+ ["particles", "normalWidth", "#set-part-normalWidth", "range"],
256
+ ["particles", "highlightWidth", "#set-part-highlightWidth", "range"],
257
+ ["particles", "normalSpeed", "#set-part-normalSpeed", "range"],
258
+ ["particles", "highlightSpeed", "#set-part-highlightSpeed", "range"],
259
+ ["particles", "highlightColor", "#set-part-highlightColor", "color"],
260
+ ["colors", "FE", "#set-color-FE", "color"],
261
+ ["colors", "BE", "#set-color-BE", "color"],
262
+ ["colors", "DB", "#set-color-DB", "color"],
263
+ ["colors", "API", "#set-color-API", "color"],
264
+ ["colors", "CONFIG", "#set-color-CONFIG", "color"],
265
+ ["colors", "UTIL", "#set-color-UTIL", "color"],
266
+ ["arrows", "length", "#set-arrow-length", "range"],
267
+ ["arrows", "position", "#set-arrow-position", "range"],
268
+ ["scene", "background", "#set-scene-background", "color"],
269
+ ["scene", "ambientIntensity", "#set-scene-ambientIntensity", "range"],
270
+ ["scene", "pointIntensity", "#set-scene-pointIntensity", "range"],
271
+ ["scene", "pointDistance", "#set-scene-pointDistance", "range"],
272
+ ["scene", "fogDensity", "#set-scene-fogDensity", "range"],
273
+ ["scene", "scanlineOpacity", "#set-scene-scanlineOpacity", "range"],
274
+ ["camera", "initialZ", "#set-cam-initialZ", "range"],
275
+ ["camera", "autoRotateSpeed", "#set-cam-autoRotateSpeed", "range"],
276
+ ["camera", "autoRotateRadius", "#set-cam-autoRotateRadius", "range"],
277
+ ["camera", "resetDistance", "#set-cam-resetDistance", "range"],
278
+ ["physics", "alphaDecay", "#set-phys-alphaDecay", "range"],
279
+ ["physics", "velocityDecay", "#set-phys-velocityDecay", "range"],
280
+ ["physics", "chargeStrength", "#set-phys-chargeStrength", "range"],
281
+ ["physics", "sameLayerDistance", "#set-phys-sameLayerDistance", "range"],
282
+ ["physics", "crossLayerDistance", "#set-phys-crossLayerDistance", "range"],
283
+ ["physics", "clusterStrength", "#set-phys-clusterStrength", "range"],
284
+ ["animation", "birthDuration", "#set-anim-birthDuration", "range"],
285
+ ["animation", "birthScale", "#set-anim-birthScale", "range"],
286
+ ["animation", "spawnX", "#set-anim-spawnX", "range"],
287
+ ["animation", "spawnY", "#set-anim-spawnY", "range"],
288
+ ["animation", "spawnZ", "#set-anim-spawnZ", "range"],
289
+ ["fileTree", "panelWidth", "#set-ft-panelWidth", "range"],
290
+ ["fileTree", "fontSize", "#set-ft-fontSize", "range"],
291
+ ["fileTree", "indentSize", "#set-ft-indentSize", "range"],
292
+ ["fileTree", "pulseDuration", "#set-ft-pulseDuration", "range"],
293
+ ["fileTree", "showLineCount", "#set-ft-showLineCount", "checkbox"],
294
+ ["fileTree", "showDeps", "#set-ft-showDeps", "checkbox"],
295
+ ["fileTree", "showRisk", "#set-ft-showRisk", "checkbox"],
296
+ ["fileTree", "autoScrollOnChange", "#set-ft-autoScrollOnChange", "checkbox"],
297
+ ["fileTree", "compactMode", "#set-ft-compactMode", "checkbox"],
298
+ ];
299
+
300
+ // Init values and bind events
301
+ for (const [group, key, selector, type] of CTRL_MAP) {
302
+ const el = document.querySelector(selector);
303
+ if (!el) continue;
304
+ const valEl = el.parentElement?.querySelector(".set-val");
305
+
306
+ if (type === "checkbox") {
307
+ el.checked = !!SETTINGS[group][key];
308
+ el.addEventListener("change", () => {
309
+ SETTINGS[group][key] = el.checked;
310
+ applySettings(group);
311
+ saveSettings();
312
+ });
313
+ } else {
314
+ el.value = SETTINGS[group][key];
315
+ if (valEl) valEl.textContent = formatSetVal(SETTINGS[group][key], type);
316
+ el.addEventListener("input", () => {
317
+ const v = type === "color" ? el.value : parseFloat(el.value);
318
+ SETTINGS[group][key] = v;
319
+ if (valEl) valEl.textContent = formatSetVal(v, type);
320
+ applySettings(group);
321
+ saveSettings();
322
+ });
323
+ }
324
+ }
325
+
326
+ // Collapsible sections
327
+ document.querySelectorAll(".set-section-hdr").forEach(hdr => {
328
+ hdr.addEventListener("click", () => {
329
+ const body = hdr.nextElementSibling;
330
+ const arrow = hdr.querySelector(".set-arrow");
331
+ if (body) body.classList.toggle("collapsed");
332
+ if (arrow) arrow.classList.toggle("open");
333
+ });
334
+ });
335
+
336
+ // Reset per group
337
+ document.querySelectorAll(".set-rst-btn").forEach(btn => {
338
+ btn.addEventListener("click", (e) => {
339
+ e.stopPropagation();
340
+ const group = btn.dataset.group;
341
+ resetSettingsGroup(group);
342
+ applySettings(group);
343
+ // Refresh UI inputs
344
+ for (const [g, key, selector, type] of CTRL_MAP) {
345
+ if (g !== group) continue;
346
+ const el = document.querySelector(selector);
347
+ if (!el) continue;
348
+ if (type === "checkbox") { el.checked = !!SETTINGS[group][key]; }
349
+ else { el.value = SETTINGS[group][key]; }
350
+ const valEl = el.parentElement?.querySelector(".set-val");
351
+ if (valEl && type !== "checkbox") valEl.textContent = formatSetVal(SETTINGS[group][key], type);
352
+ }
353
+ });
354
+ });
355
+
356
+ // Reset all
357
+ const resetAllBtn = document.getElementById("set-reset-all");
358
+ if (resetAllBtn) {
359
+ resetAllBtn.addEventListener("click", () => {
360
+ resetAllSettings();
361
+ for (const group of Object.keys(SETTINGS_DEFAULTS)) applySettings(group);
362
+ // Refresh all UI inputs
363
+ for (const [group, key, selector, type] of CTRL_MAP) {
364
+ const el = document.querySelector(selector);
365
+ if (!el) continue;
366
+ if (type === "checkbox") { el.checked = !!SETTINGS[group][key]; }
367
+ else { el.value = SETTINGS[group][key]; }
368
+ const valEl = el.parentElement?.querySelector(".set-val");
369
+ if (valEl && type !== "checkbox") valEl.textContent = formatSetVal(SETTINGS[group][key], type);
370
+ }
371
+ });
372
+ }
373
+
374
+ // Export
375
+ const exportBtn = document.getElementById("set-export");
376
+ if (exportBtn) {
377
+ exportBtn.addEventListener("click", () => {
378
+ const blob = new Blob([JSON.stringify(SETTINGS, null, 2)], { type: "application/json" });
379
+ const url = URL.createObjectURL(blob);
380
+ const a = document.createElement("a");
381
+ a.href = url;
382
+ a.download = "syke-settings.json";
383
+ a.click();
384
+ URL.revokeObjectURL(url);
385
+ });
386
+ }
387
+
388
+ // Import
389
+ const importBtn = document.getElementById("set-import");
390
+ if (importBtn) {
391
+ importBtn.addEventListener("click", () => {
392
+ const input = document.createElement("input");
393
+ input.type = "file";
394
+ input.accept = ".json";
395
+ input.addEventListener("change", () => {
396
+ const file = input.files[0];
397
+ if (!file) return;
398
+ const reader = new FileReader();
399
+ reader.onload = () => {
400
+ try {
401
+ const imported = JSON.parse(reader.result);
402
+ const merged = deepMerge(SETTINGS_DEFAULTS, imported);
403
+ for (const group of Object.keys(merged)) {
404
+ SETTINGS[group] = merged[group];
405
+ }
406
+ Object.assign(LAYER_HEX, SETTINGS.colors);
407
+ saveSettings();
408
+ for (const group of Object.keys(SETTINGS_DEFAULTS)) applySettings(group);
409
+ // Refresh all UI inputs
410
+ for (const [group, key, selector, type] of CTRL_MAP) {
411
+ const el = document.querySelector(selector);
412
+ if (!el) continue;
413
+ if (type === "checkbox") { el.checked = !!SETTINGS[group][key]; }
414
+ else { el.value = SETTINGS[group][key]; }
415
+ const valEl = el.parentElement?.querySelector(".set-val");
416
+ if (valEl && type !== "checkbox") valEl.textContent = formatSetVal(SETTINGS[group][key], type);
417
+ }
418
+ } catch(e) {
419
+ console.error("[SYKE] Import failed:", e);
420
+ }
421
+ };
422
+ reader.readAsText(file);
423
+ });
424
+ input.click();
425
+ });
426
+ }
427
+
428
+ // Apply scanline opacity on startup
429
+ const scanline = document.getElementById("scanline");
430
+ if (scanline) scanline.style.opacity = SETTINGS.scene.scanlineOpacity;
431
+ }
432
+
433
+ function formatSetVal(v, type) {
434
+ if (type === "color") return v;
435
+ if (Number.isInteger(v)) return v.toString();
436
+ if (Math.abs(v) < 0.01) return v.toFixed(5);
437
+ if (Math.abs(v) < 1) return v.toFixed(3);
438
+ return v.toFixed(1);
439
+ }
440
+
29
441
  const LAYER_KEYS = ["FE", "BE", "DB", "API", "CONFIG", "UTIL"];
30
442
 
31
443
  const LAYER_CENTERS = {
@@ -49,7 +461,9 @@ document.addEventListener("DOMContentLoaded", async () => {
49
461
  setupKeyboardShortcuts();
50
462
  setupContextMenu();
51
463
  setupTabs();
464
+ setupSettings();
52
465
  setupProjectModal();
466
+ setupFileTree();
53
467
  initSSE();
54
468
  startHealthCheck();
55
469
  });
@@ -113,7 +527,7 @@ async function loadGraph() {
113
527
  const isReload = Graph !== null;
114
528
 
115
529
  // Spawn point for new nodes (top-right corner of 3D space)
116
- const SPAWN = { x: 5000, y: 4000, z: -2000 };
530
+ const SPAWN = { x: SETTINGS.animation.spawnX, y: SETTINGS.animation.spawnY, z: SETTINGS.animation.spawnZ };
117
531
 
118
532
  // Preserve existing node positions on reload
119
533
  const currentPositions = {};
@@ -143,7 +557,7 @@ async function loadGraph() {
143
557
  startTime: Date.now(),
144
558
  spawnPos: { ...SPAWN },
145
559
  targetPos: { ...targetPos },
146
- duration: 2500, // 2.5s flight time
560
+ duration: SETTINGS.animation.birthDuration,
147
561
  });
148
562
  }
149
563
 
@@ -181,6 +595,7 @@ async function loadGraph() {
181
595
  if (isReload) {
182
596
  Graph.graphData(graphData);
183
597
  buildLegend(layerCounts);
598
+ renderFileTreeDebounced();
184
599
  console.log("[SYKE] Graph updated (reload), birth animations:", birthAnimations.size);
185
600
 
186
601
  // ── Star birth camera choreography ──
@@ -209,7 +624,7 @@ async function loadGraph() {
209
624
  // Phase 3: Zoom out to overview (at 3.5s, 1.5s transition)
210
625
  setTimeout(() => {
211
626
  Graph.cameraPosition(
212
- { x: 0, y: 0, z: 3500 },
627
+ { x: 0, y: 0, z: SETTINGS.camera.initialZ },
213
628
  { x: 0, y: 0, z: 0 },
214
629
  1500
215
630
  );
@@ -228,16 +643,22 @@ async function loadGraph() {
228
643
  const container = document.getElementById("3d-graph");
229
644
 
230
645
  Graph = ForceGraph3D()(container)
231
- .width(window.innerWidth - 380)
646
+ .width(getGraphPanelWidth())
232
647
  .height(window.innerHeight - 100)
233
648
  .graphData(graphData)
234
- .backgroundColor("#050a18")
649
+ .backgroundColor(SETTINGS.scene.background)
235
650
  .showNavInfo(false)
236
651
 
237
652
  .nodeColor(node => getNodeColor(node))
238
653
  .nodeVal(node => {
239
654
  if (!isNodeVisible(node)) return 0.001;
240
- const base = Math.max(30, Math.sqrt(node.lineCount) * 3);
655
+ const base = Math.max(SETTINGS.nodes.sizeMin, Math.sqrt(node.lineCount) * SETTINGS.nodes.sizeMultiplier);
656
+ // Search mode: shrink non-matches, boost matches
657
+ if (searchActive) {
658
+ if (!highlightNodes.has(node.id)) return base * 0.3;
659
+ const pulse = 0.9 + 0.1 * Math.sin(Date.now() / 400);
660
+ return base * 1.4 * pulse;
661
+ }
241
662
  const hb = heartbeatNodes.get(node.id);
242
663
  if (hb) {
243
664
  const elapsed = Date.now() - hb.startTime;
@@ -255,13 +676,13 @@ async function loadGraph() {
255
676
  if (birth) {
256
677
  const t = Math.min(1, (Date.now() - birth.startTime) / birth.duration);
257
678
  if (t >= 1) birthAnimations.delete(node.id);
258
- const scale = 1 + (3 - 1) * (1 - t) * (1 - t); // ease-out: 3x → 1x
679
+ const scale = 1 + (SETTINGS.animation.birthScale - 1) * (1 - t) * (1 - t); // ease-out: Nx → 1x
259
680
  return base * scale;
260
681
  }
261
682
  return base;
262
683
  })
263
- .nodeOpacity(1.0)
264
- .nodeResolution(16)
684
+ .nodeOpacity(SETTINGS.nodes.opacity)
685
+ .nodeResolution(SETTINGS.nodes.resolution)
265
686
  .nodeVisibility(node => isNodeVisible(node))
266
687
  .nodeLabel(node => {
267
688
  const c = LAYER_HEX[node.layer] || "#ccc";
@@ -272,34 +693,36 @@ async function loadGraph() {
272
693
  })
273
694
 
274
695
  .linkColor(link => getLinkColor(link))
275
- .linkWidth(link => highlightLinks.has(link) ? 4.0 : 1.5)
276
- .linkOpacity(0.9)
696
+ .linkWidth(link => highlightLinks.has(link) ? SETTINGS.links.highlightWidth : SETTINGS.links.normalWidth)
697
+ .linkOpacity(SETTINGS.links.opacity)
277
698
  .linkVisibility(link => isLinkVisible(link))
278
- .linkCurvature(link => 0.12 + (hc(getSrcId(link) + getTgtId(link)) % 20) * 0.01)
699
+ .linkCurvature(link => SETTINGS.links.curvatureBase + (hc(getSrcId(link) + getTgtId(link)) % 20) * 0.01)
279
700
  .linkCurveRotation(link => (hc(getTgtId(link) + getSrcId(link)) % 628) / 100)
280
701
 
281
702
  .linkDirectionalParticles(link => {
282
- if (highlightLinks.has(link)) return 14;
703
+ if (searchActive) return highlightLinks.has(link) ? SETTINGS.particles.highlightCount : 0;
704
+ if (highlightLinks.has(link)) return SETTINGS.particles.highlightCount;
283
705
  const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
284
- return isActive ? 4 : 6;
706
+ return isActive ? Math.round(SETTINGS.particles.normalCount * 0.67) : SETTINGS.particles.normalCount;
285
707
  })
286
708
  .linkDirectionalParticleWidth(link => {
287
- if (highlightLinks.has(link)) return 4.5;
709
+ if (highlightLinks.has(link)) return SETTINGS.particles.highlightWidth;
288
710
  const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
289
- return isActive ? 1.5 : 2.0;
711
+ return isActive ? SETTINGS.particles.normalWidth * 0.75 : SETTINGS.particles.normalWidth;
290
712
  })
291
- .linkDirectionalParticleSpeed(link => highlightLinks.has(link) ? 0.004 : 0.006)
713
+ .linkDirectionalParticleSpeed(link => highlightLinks.has(link) ? SETTINGS.particles.highlightSpeed : SETTINGS.particles.normalSpeed)
292
714
  .linkDirectionalParticleColor(link => {
293
- if (highlightLinks.has(link)) return "#ffffff";
715
+ if (searchActive && highlightLinks.has(link)) return LAYER_HEX[srcLayer(link)] || "#00d4ff";
716
+ if (highlightLinks.has(link)) return SETTINGS.particles.highlightColor;
294
717
  const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
295
718
  if (isActive) return "rgba(150,180,220,0.6)";
296
719
  return LAYER_HEX[srcLayer(link)] || "#ff69b4";
297
720
  })
298
721
 
299
- .linkDirectionalArrowLength(4)
300
- .linkDirectionalArrowRelPos(1)
722
+ .linkDirectionalArrowLength(SETTINGS.arrows.length)
723
+ .linkDirectionalArrowRelPos(SETTINGS.arrows.position)
301
724
  .linkDirectionalArrowColor(link => {
302
- if (highlightLinks.has(link)) return "#ff2d55";
725
+ if (highlightLinks.has(link)) return SETTINGS.links.highlightColor;
303
726
  return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.4);
304
727
  })
305
728
 
@@ -345,27 +768,28 @@ async function loadGraph() {
345
768
  }
346
769
  })
347
770
 
348
- .d3AlphaDecay(0.008)
349
- .d3VelocityDecay(0.3)
771
+ .d3AlphaDecay(SETTINGS.physics.alphaDecay)
772
+ .d3VelocityDecay(SETTINGS.physics.velocityDecay)
350
773
  .warmupTicks(300)
351
774
  .cooldownTicks(800)
352
775
  .enablePointerInteraction(true);
353
776
 
354
- Graph.d3Force("cluster", clusterForce(0.015));
355
- Graph.d3Force("charge").strength(-800);
777
+ Graph.d3Force("cluster", clusterForce(SETTINGS.physics.clusterStrength));
778
+ Graph.d3Force("charge").strength(SETTINGS.physics.chargeStrength);
356
779
  Graph.d3Force("link")
357
- .distance(l => srcLayer(l) === tgtLayer(l) ? 250 : 900)
780
+ .distance(l => srcLayer(l) === tgtLayer(l) ? SETTINGS.physics.sameLayerDistance : SETTINGS.physics.crossLayerDistance)
358
781
  .strength(l => srcLayer(l) === tgtLayer(l) ? 0.2 : 0.05);
359
782
 
360
- Graph.cameraPosition({ x: 0, y: 0, z: 3500 });
783
+ Graph.cameraPosition({ x: 0, y: 0, z: SETTINGS.camera.initialZ });
361
784
 
362
785
  setTimeout(() => {
363
786
  try {
364
787
  const scene = Graph.scene();
365
788
  if (!scene) return;
366
- scene.add(new THREE.AmbientLight(0xffffff, 8));
367
- scene.add(new THREE.PointLight(0xffffff, 3, 5000));
368
- scene.fog = new THREE.FogExp2(0x050a18, 0.00012);
789
+ scene.add(new THREE.AmbientLight(0xffffff, SETTINGS.scene.ambientIntensity));
790
+ const ptLight = new THREE.PointLight(0xffffff, SETTINGS.scene.pointIntensity, SETTINGS.scene.pointDistance);
791
+ scene.add(ptLight);
792
+ scene.fog = new THREE.FogExp2(parseInt(SETTINGS.scene.background.replace("#",""), 16), SETTINGS.scene.fogDensity);
369
793
  console.log("[SYKE] Scene ready");
370
794
  } catch(e) { console.warn(e); }
371
795
  }, 500);
@@ -381,10 +805,11 @@ async function loadGraph() {
381
805
  container.addEventListener("touchstart", stopUser);
382
806
 
383
807
  window.addEventListener("resize", () => {
384
- if (Graph) Graph.width(window.innerWidth - 380).height(window.innerHeight - 100);
808
+ if (Graph) Graph.width(getGraphPanelWidth()).height(window.innerHeight - 100);
385
809
  });
386
810
 
387
811
  buildLegend(layerCounts);
812
+ renderFileTreeDebounced();
388
813
  createNodeLabels();
389
814
  updateLabelsLoop();
390
815
 
@@ -528,13 +953,24 @@ function getNodeColor(node) {
528
953
  const pulse = 0.5 + 0.5 * Math.sin(t);
529
954
  return `rgb(255,${Math.round(180 + pulse * 75)},${Math.round(50 + pulse * 50)})`;
530
955
  }
531
- if (node.id === selectedNodeId) return "#ffffff";
956
+ if (node.id === selectedNodeId) return SETTINGS.nodes.selectedColor;
532
957
  const base = LAYER_HEX[node.layer] || "#ff69b4";
533
958
  if (highlightNodes.size > 0 && !highlightNodes.has(node.id)) {
534
- // When actively modifying → dim harder so protagonists stand out
959
+ if (searchActive) {
960
+ // Search mode: near-invisible ghost nodes
961
+ return dimHex(base, 0.06);
962
+ }
535
963
  const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
536
964
  return dimHex(base, isActive ? 0.12 : 0.35);
537
965
  }
966
+ // Search match: pulsing bright glow
967
+ if (searchActive && highlightNodes.has(node.id)) {
968
+ const pulse = 0.85 + 0.15 * Math.sin(Date.now() / 300);
969
+ const r = Math.min(255, Math.round(parseInt(base.slice(1,3),16) * pulse + 40));
970
+ const g = Math.min(255, Math.round(parseInt(base.slice(3,5),16) * pulse + 40));
971
+ const b = Math.min(255, Math.round(parseInt(base.slice(5,7),16) * pulse + 40));
972
+ return `rgb(${r},${g},${b})`;
973
+ }
538
974
  return base;
539
975
  }
540
976
 
@@ -599,12 +1035,17 @@ function dimHex(hex, factor) {
599
1035
  }
600
1036
 
601
1037
  function getLinkColor(link) {
602
- if (highlightLinks.has(link)) return "#ff2d55";
1038
+ if (searchActive && highlightLinks.has(link)) {
1039
+ // Search mode: matched links glow with source layer color
1040
+ return LAYER_HEX[srcLayer(link)] || "#00d4ff";
1041
+ }
1042
+ if (highlightLinks.has(link)) return SETTINGS.links.highlightColor;
603
1043
  if (highlightNodes.size > 0 && !highlightLinks.has(link)) {
1044
+ if (searchActive) return "rgba(30,40,60,0.02)"; // near-invisible
604
1045
  const isActive = modifyingNodes.size > 0 || heartbeatNodes.size > 0;
605
1046
  return isActive ? "rgba(60,70,90,0.03)" : "rgba(100,120,150,0.06)";
606
1047
  }
607
- return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", 0.25);
1048
+ return rgba(LAYER_HEX[srcLayer(link)] || "#ff69b4", SETTINGS.links.normalAlpha);
608
1049
  }
609
1050
 
610
1051
  // ═══════════════════════════════════════════
@@ -689,11 +1130,11 @@ function buildLegend(counts) {
689
1130
  let rotAngle = 0, rotRAF = null;
690
1131
  function startAutoRotate() {
691
1132
  if (!autoRotate || !Graph) return;
692
- rotAngle += 0.0005;
1133
+ rotAngle += SETTINGS.camera.autoRotateSpeed;
693
1134
  Graph.cameraPosition({
694
- x: 1600 * Math.sin(rotAngle),
1135
+ x: SETTINGS.camera.autoRotateRadius * Math.sin(rotAngle),
695
1136
  y: 200 + Math.sin(rotAngle * 0.3) * 100,
696
- z: 1600 * Math.cos(rotAngle),
1137
+ z: SETTINGS.camera.autoRotateRadius * Math.cos(rotAngle),
697
1138
  });
698
1139
  rotRAF = requestAnimationFrame(startAutoRotate);
699
1140
  }
@@ -749,6 +1190,8 @@ async function handleNodeClick(node) {
749
1190
  loadSimulation(node.id);
750
1191
  // Start Star Wars code crawl
751
1192
  startCodeCrawl(node.id);
1193
+ // Sync file tree selection
1194
+ treeScrollToFile(node.id);
752
1195
  }
753
1196
 
754
1197
  function handleBackgroundClick() {
@@ -783,7 +1226,12 @@ async function showImpact(fileId, nd) {
783
1226
  document.getElementById("impact-content").innerHTML = '<div class="loading"><div class="spinner"></div>TRACING...</div>';
784
1227
 
785
1228
  try {
786
- const res = await fetch("/api/impact/" + fileId);
1229
+ const res = await fetch("/api/impact/" + fileId.split("/").map(encodeURIComponent).join("/"));
1230
+ const ct = res.headers.get("content-type") || "";
1231
+ if (!ct.includes("application/json")) {
1232
+ document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: Server returned non-JSON (${res.status})</p>`;
1233
+ return;
1234
+ }
787
1235
  const impact = await res.json();
788
1236
  if (!res.ok) { document.getElementById("impact-content").innerHTML = `<p class="placeholder">ERROR: ${impact.error}</p>`; return; }
789
1237
 
@@ -876,7 +1324,12 @@ async function loadCodePreview(fileId) {
876
1324
  const el = document.getElementById("code-content");
877
1325
  el.innerHTML = '<div class="loading"><div class="spinner"></div>LOADING...</div>';
878
1326
  try {
879
- const res = await fetch("/api/file-content/" + fileId);
1327
+ const res = await fetch("/api/file-content/" + fileId.split("/").map(encodeURIComponent).join("/"));
1328
+ const ct = res.headers.get("content-type") || "";
1329
+ if (!ct.includes("application/json")) {
1330
+ el.innerHTML = `<p class="placeholder">ERROR: Server returned non-JSON (${res.status}). File: ${fileId}</p>`;
1331
+ return;
1332
+ }
880
1333
  const data = await res.json();
881
1334
  if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
882
1335
 
@@ -901,7 +1354,12 @@ async function loadSimulation(fileId) {
901
1354
  const el = document.getElementById("sim-content");
902
1355
  el.innerHTML = '<div class="loading"><div class="spinner"></div>SIMULATING...</div>';
903
1356
  try {
904
- const res = await fetch("/api/simulate-delete/" + fileId);
1357
+ const res = await fetch("/api/simulate-delete/" + fileId.split("/").map(encodeURIComponent).join("/"));
1358
+ const ct = res.headers.get("content-type") || "";
1359
+ if (!ct.includes("application/json")) {
1360
+ el.innerHTML = `<p class="placeholder">ERROR: Server returned non-JSON (${res.status})</p>`;
1361
+ return;
1362
+ }
905
1363
  const data = await res.json();
906
1364
  if (!res.ok) { el.innerHTML = `<p class="placeholder">ERROR: ${data.error}</p>`; return; }
907
1365
 
@@ -1437,6 +1895,9 @@ function setupKeyboardShortcuts() {
1437
1895
  case "s":
1438
1896
  if (selectedFile) { loadSimulation(selectedFile); switchTab("simulate"); }
1439
1897
  break;
1898
+ case "t":
1899
+ toggleFileTreePanel();
1900
+ break;
1440
1901
  case "d":
1441
1902
  detectCycles();
1442
1903
  break;
@@ -1495,7 +1956,7 @@ function resetView() {
1495
1956
  if (pathMode) exitPathMode();
1496
1957
  stopCodeCrawl();
1497
1958
  refreshGraph();
1498
- Graph.cameraPosition({ x:0,y:0,z:1600 }, { x:0,y:0,z:0 }, 1000);
1959
+ Graph.cameraPosition({ x:0,y:0,z:SETTINGS.camera.resetDistance }, { x:0,y:0,z:0 }, 1000);
1499
1960
  }
1500
1961
 
1501
1962
  function toggleAutoRotate() {
@@ -1548,22 +2009,67 @@ function setupEventListeners() {
1548
2009
  document.getElementById("btn-cancel-path").addEventListener("click", exitPathMode);
1549
2010
 
1550
2011
  document.getElementById("search-input").addEventListener("input", e => {
1551
- const q = e.target.value.toLowerCase();
2012
+ const q = e.target.value.toLowerCase().trim();
1552
2013
  highlightNodes.clear(); highlightLinks.clear(); selectedNodeId = null;
1553
- if (q) {
1554
- graphData.nodes.forEach(n => {
1555
- if (n.fullPath.toLowerCase().includes(q) || n.layer.toLowerCase() === q || n.label.toLowerCase().includes(q)) highlightNodes.add(n.id);
2014
+
2015
+ if (!q) {
2016
+ // ── Clear search: restore full view ──
2017
+ searchActive = false;
2018
+ if (_searchRAF) { cancelAnimationFrame(_searchRAF); _searchRAF = null; }
2019
+ refreshGraph();
2020
+ // Smooth zoom-out to overview
2021
+ if (Graph) {
2022
+ Graph.cameraPosition(
2023
+ { x: 0, y: 0, z: SETTINGS.camera.resetDistance },
2024
+ { x: 0, y: 0, z: 0 }, 800
2025
+ );
2026
+ }
2027
+ return;
2028
+ }
2029
+
2030
+ // ── Active search: find matching nodes ──
2031
+ searchActive = true;
2032
+ graphData.nodes.forEach(n => {
2033
+ if (n.fullPath.toLowerCase().includes(q) || n.layer.toLowerCase() === q || n.label.toLowerCase().includes(q)) {
2034
+ highlightNodes.add(n.id);
2035
+ }
2036
+ });
2037
+
2038
+ // Also highlight links between matched nodes
2039
+ if (highlightNodes.size > 0) {
2040
+ graphData.links.forEach(l => {
2041
+ const sid = getSrcId(l), tid = getTgtId(l);
2042
+ if (highlightNodes.has(sid) || highlightNodes.has(tid)) highlightLinks.add(l);
1556
2043
  });
1557
- if (highlightNodes.size > 0) {
1558
- const f = graphData.nodes.find(n => n.id === highlightNodes.values().next().value);
1559
- if (f && f.x != null) {
1560
- stopUser();
1561
- const d = Math.max(1, Math.hypot(f.x,f.y||0,f.z||0));
1562
- 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);
2044
+
2045
+ // Camera: frame all matched nodes (centroid)
2046
+ let cx = 0, cy = 0, cz = 0, cnt = 0;
2047
+ graphData.nodes.forEach(n => {
2048
+ if (highlightNodes.has(n.id) && n.x != null) {
2049
+ cx += n.x; cy += (n.y || 0); cz += (n.z || 0); cnt++;
1563
2050
  }
2051
+ });
2052
+ if (cnt > 0) {
2053
+ cx /= cnt; cy /= cnt; cz /= cnt;
2054
+ stopUser();
2055
+ const dist = cnt === 1 ? 600 : Math.min(2500, 800 + cnt * 80);
2056
+ Graph.cameraPosition(
2057
+ { x: cx + dist * 0.3, y: cy + dist * 0.2, z: cz + dist },
2058
+ { x: cx, y: cy, z: cz }, 600
2059
+ );
1564
2060
  }
1565
2061
  }
2062
+
1566
2063
  refreshGraph();
2064
+ // Start glow animation loop for search matches
2065
+ if (!_searchRAF && highlightNodes.size > 0) {
2066
+ function searchGlow() {
2067
+ if (!searchActive) { _searchRAF = null; return; }
2068
+ if (Graph) Graph.nodeColor(Graph.nodeColor());
2069
+ _searchRAF = requestAnimationFrame(searchGlow);
2070
+ }
2071
+ _searchRAF = requestAnimationFrame(searchGlow);
2072
+ }
1567
2073
  });
1568
2074
 
1569
2075
  document.getElementById("btn-toggle-hub").addEventListener("click", () => {
@@ -1607,8 +2113,9 @@ function setupEventListeners() {
1607
2113
  });
1608
2114
  });
1609
2115
 
1610
- // ── Resizable Right Panel ──
2116
+ // ── Resizable Panels ──
1611
2117
  setupResizeHandle();
2118
+ setupTreeResizeHandle();
1612
2119
  }
1613
2120
 
1614
2121
  // ═══════════════════════════════════════════
@@ -2255,6 +2762,16 @@ async function initSSE() {
2255
2762
  const data = JSON.parse(e.data);
2256
2763
  console.log("[SYKE:SSE] File change:", data.file, data.type, data.diffCount, "diffs");
2257
2764
 
2765
+ // ── 0. Update file tree modification tracking ──
2766
+ fileTreeModified.set(data.file, { type: data.type, timestamp: Date.now() });
2767
+ renderFileTreeDebounced();
2768
+ // Auto-clear pulse after pulseDuration
2769
+ const pulseDur = (SETTINGS.fileTree.pulseDuration || 5) * 1000;
2770
+ setTimeout(() => {
2771
+ fileTreeModified.delete(data.file);
2772
+ renderFileTreeDebounced();
2773
+ }, pulseDur);
2774
+
2258
2775
  // ── 1. Auto-select the modified node in 3D graph ──
2259
2776
  selectedFile = data.file;
2260
2777
  selectedNodeId = data.file;
@@ -2409,6 +2926,7 @@ async function initSSE() {
2409
2926
  setTimeout(async () => {
2410
2927
  await loadGraph();
2411
2928
  await loadHubFiles();
2929
+ renderFileTreeDebounced();
2412
2930
  }, 1000);
2413
2931
  });
2414
2932
 
@@ -2528,6 +3046,489 @@ function renderRealtimePanel() {
2528
3046
  // ═══════════════════════════════════════════
2529
3047
  // RESIZABLE RIGHT PANEL
2530
3048
  // ═══════════════════════════════════════════
3049
+ // ═══════════════════════════════════════════
3050
+ // FILE TREE PANEL
3051
+ // ═══════════════════════════════════════════
3052
+
3053
+ function getGraphPanelWidth() {
3054
+ const rightPanel = document.getElementById("right-panel");
3055
+ const treePanel = document.getElementById("file-tree-panel");
3056
+ const rw = rightPanel ? rightPanel.offsetWidth : 380;
3057
+ const tw = (treePanel && !treePanel.classList.contains("hidden")) ? treePanel.offsetWidth : 0;
3058
+ // 8px per resize handle (tree + right)
3059
+ return window.innerWidth - rw - tw - 16;
3060
+ }
3061
+
3062
+ function updateDynamicRightOffsets() {
3063
+ const rightPanel = document.getElementById("right-panel");
3064
+ const treePanel = document.getElementById("file-tree-panel");
3065
+ const rw = rightPanel ? rightPanel.offsetWidth : 380;
3066
+ const tw = (treePanel && !treePanel.classList.contains("hidden")) ? treePanel.offsetWidth : 0;
3067
+ const total = rw + tw + 16; // 2 resize handles × 8px
3068
+ document.documentElement.style.setProperty("--right-offset", total + "px");
3069
+ }
3070
+
3071
+ function toggleFileTreePanel() {
3072
+ const panel = document.getElementById("file-tree-panel");
3073
+ const treeResize = document.getElementById("tree-resize-handle");
3074
+ if (!panel) return;
3075
+ fileTreeVisible = !fileTreeVisible;
3076
+ panel.classList.toggle("hidden", !fileTreeVisible);
3077
+ if (treeResize) treeResize.style.display = fileTreeVisible ? "" : "none";
3078
+ updateDynamicRightOffsets();
3079
+ if (Graph) {
3080
+ const gp = document.getElementById("graph-panel");
3081
+ if (gp) Graph.width(gp.clientWidth);
3082
+ }
3083
+ }
3084
+
3085
+ function buildFileTree(nodes) {
3086
+ if (!nodes || !nodes.length) { fileTreeData = null; return; }
3087
+ const root = { name: "", children: {}, files: [], path: "" };
3088
+
3089
+ nodes.forEach(n => {
3090
+ const parts = (n.fullPath || n.id).split("/").filter(Boolean);
3091
+ let current = root;
3092
+ for (let i = 0; i < parts.length - 1; i++) {
3093
+ const folderName = parts[i];
3094
+ const folderPath = parts.slice(0, i + 1).join("/");
3095
+ if (!current.children[folderName]) {
3096
+ current.children[folderName] = { name: folderName, children: {}, files: [], path: folderPath };
3097
+ }
3098
+ current = current.children[folderName];
3099
+ }
3100
+ current.files.push({
3101
+ name: parts[parts.length - 1] || n.label,
3102
+ node: n,
3103
+ path: n.fullPath || n.id,
3104
+ });
3105
+ });
3106
+
3107
+ fileTreeData = root;
3108
+ }
3109
+
3110
+ function countFilesRecursive(folder) {
3111
+ let count = folder.files.length;
3112
+ for (const child of Object.values(folder.children)) {
3113
+ count += countFilesRecursive(child);
3114
+ }
3115
+ return count;
3116
+ }
3117
+
3118
+ function getAggregateRisk(folder) {
3119
+ const order = ["HIGH", "MEDIUM", "LOW", "NONE"];
3120
+ let highest = 3; // NONE
3121
+ for (const f of folder.files) {
3122
+ const idx = order.indexOf(f.node.riskLevel);
3123
+ if (idx >= 0 && idx < highest) highest = idx;
3124
+ }
3125
+ for (const child of Object.values(folder.children)) {
3126
+ const childRisk = getAggregateRisk(child);
3127
+ const idx = order.indexOf(childRisk);
3128
+ if (idx >= 0 && idx < highest) highest = idx;
3129
+ }
3130
+ return order[highest];
3131
+ }
3132
+
3133
+ function folderHasModifiedChild(folder) {
3134
+ for (const f of folder.files) {
3135
+ if (fileTreeModified.has(f.path)) return true;
3136
+ }
3137
+ for (const child of Object.values(folder.children)) {
3138
+ if (folderHasModifiedChild(child)) return true;
3139
+ }
3140
+ return false;
3141
+ }
3142
+
3143
+ function folderMatchesFilter(folder, filter) {
3144
+ if (!filter) return true;
3145
+ const fl = filter.toLowerCase();
3146
+ for (const f of folder.files) {
3147
+ if (f.name.toLowerCase().includes(fl) || f.path.toLowerCase().includes(fl)) return true;
3148
+ }
3149
+ for (const child of Object.values(folder.children)) {
3150
+ if (child.name.toLowerCase().includes(fl) || folderMatchesFilter(child, filter)) return true;
3151
+ }
3152
+ return false;
3153
+ }
3154
+
3155
+ function sortTreeItems(items, sortType) {
3156
+ return [...items].sort((a, b) => {
3157
+ switch (sortType) {
3158
+ case "layer": {
3159
+ const la = a.node ? (a.node.layer || "UTIL") : "";
3160
+ const lb = b.node ? (b.node.layer || "UTIL") : "";
3161
+ return la.localeCompare(lb) || (a.name || "").localeCompare(b.name || "");
3162
+ }
3163
+ case "risk": {
3164
+ const order = { HIGH: 0, MEDIUM: 1, LOW: 2, NONE: 3 };
3165
+ const ra = a.node ? (order[a.node.riskLevel] ?? 3) : 3;
3166
+ const rb = b.node ? (order[b.node.riskLevel] ?? 3) : 3;
3167
+ return ra - rb || (a.name || "").localeCompare(b.name || "");
3168
+ }
3169
+ case "deps": {
3170
+ const da = a.node ? (a.node.dependentCount || 0) : 0;
3171
+ const db = b.node ? (b.node.dependentCount || 0) : 0;
3172
+ return db - da || (a.name || "").localeCompare(b.name || "");
3173
+ }
3174
+ case "modified": {
3175
+ const ma = a.path && fileTreeModified.has(a.path) ? fileTreeModified.get(a.path).timestamp : 0;
3176
+ const mb = b.path && fileTreeModified.has(b.path) ? fileTreeModified.get(b.path).timestamp : 0;
3177
+ return mb - ma || (a.name || "").localeCompare(b.name || "");
3178
+ }
3179
+ default: // name
3180
+ return (a.name || "").localeCompare(b.name || "");
3181
+ }
3182
+ });
3183
+ }
3184
+
3185
+ function renderFileTree() {
3186
+ if (!graphData || !graphData.nodes) return;
3187
+ buildFileTree(graphData.nodes);
3188
+ if (!fileTreeData) return;
3189
+
3190
+ const container = document.getElementById("tree-content");
3191
+ const countEl = document.getElementById("tree-file-count");
3192
+ if (!container) return;
3193
+
3194
+ const s = SETTINGS.fileTree;
3195
+ const filter = fileTreeFilter.toLowerCase();
3196
+ let totalFiles = 0;
3197
+
3198
+ // Nested rendering: each folder level produces a <div class="tree-group">
3199
+ function renderGroup(folder) {
3200
+ // Collect visible items (folders first, then files)
3201
+ const visibleFolders = Object.values(folder.children)
3202
+ .filter(c => !filter || folderMatchesFilter(c, filter))
3203
+ .sort((a, b) => a.name.localeCompare(b.name));
3204
+
3205
+ const visibleFiles = sortTreeItems(
3206
+ folder.files.filter(f => !filter || f.name.toLowerCase().includes(filter) || f.path.toLowerCase().includes(filter)),
3207
+ fileTreeSort
3208
+ );
3209
+
3210
+ const itemCount = visibleFolders.length + visibleFiles.length;
3211
+ if (itemCount === 0) return "";
3212
+
3213
+ const hasModified = folderHasModifiedChild(folder);
3214
+ const singleClass = itemCount === 1 ? " single-child" : "";
3215
+ const modGroupClass = hasModified ? " has-modified" : "";
3216
+ let html = `<div class="tree-group${singleClass}${modGroupClass}">`;
3217
+
3218
+ // ── Folders ──
3219
+ for (const child of visibleFolders) {
3220
+ const isOpen = fileTreeExpanded.has(child.path);
3221
+ const fileCount = countFilesRecursive(child);
3222
+ const hasModChild = folderHasModifiedChild(child);
3223
+ const compactClass = s.compactMode ? " compact" : "";
3224
+ const glowClass = hasModChild ? " folder-glow" : "";
3225
+ const col = "var(--accent)";
3226
+
3227
+ html += `<div class="tree-node${glowClass}${compactClass}" data-path="${child.path}" data-type="folder">`;
3228
+ html += `<div class="tree-dot" style="border-color:${col};box-shadow:0 0 8px rgba(0,212,255,0.4)"></div>`;
3229
+ html += `<span class="tree-toggle${isOpen ? " open" : ""}" data-path="${child.path}">&#9654;</span>`;
3230
+ html += `<span class="tree-name folder-name">${escHtml(child.name)}</span>`;
3231
+ html += `<span class="tree-folder-count">${fileCount}</span>`;
3232
+ html += `</div>`;
3233
+
3234
+ // Render expanded children as nested tree-group
3235
+ if (isOpen) {
3236
+ html += renderGroup(child);
3237
+ }
3238
+ }
3239
+
3240
+ // ── Files ──
3241
+ for (const f of visibleFiles) {
3242
+ totalFiles++;
3243
+ const isSelected = selectedNodeId === f.node.id;
3244
+ const isModified = fileTreeModified.has(f.path);
3245
+ const modData = isModified ? fileTreeModified.get(f.path) : null;
3246
+ const col = LAYER_HEX[f.node.layer] || "#999";
3247
+ const compactClass = s.compactMode ? " compact" : "";
3248
+ const selClass = isSelected ? " selected" : "";
3249
+ const modClass = isModified ? " modified" : "";
3250
+
3251
+ html += `<div class="tree-node${selClass}${modClass}${compactClass}" style="font-size:${s.fontSize}px" data-path="${f.path}" data-node-id="${f.node.id}" data-type="file">`;
3252
+ html += `<div class="tree-dot" style="background:${col};border-color:${col};box-shadow:0 0 8px ${col}"></div>`;
3253
+ html += `<span class="tree-name">${escHtml(f.name)}</span>`;
3254
+
3255
+ // Badges
3256
+ if (isModified && modData) {
3257
+ const modLabel = modData.type === "added" ? "A" : modData.type === "deleted" ? "D" : "M";
3258
+ const modCls = modData.type === "added" ? "mod-A" : modData.type === "deleted" ? "mod-D" : "mod-M";
3259
+ html += `<span class="tree-mod-badge ${modCls}">${modLabel}</span>`;
3260
+ }
3261
+ if (s.showRisk && f.node.riskLevel && f.node.riskLevel !== "NONE") {
3262
+ html += `<span class="tree-badge badge-risk-${f.node.riskLevel}">${f.node.riskLevel[0]}</span>`;
3263
+ }
3264
+ if (s.showDeps && f.node.dependentCount > 0) {
3265
+ html += `<span class="tree-badge badge-deps">D${f.node.dependentCount}</span>`;
3266
+ }
3267
+ if (s.showLineCount && f.node.lineCount > 0) {
3268
+ html += `<span class="tree-badge badge-lines">L${f.node.lineCount}</span>`;
3269
+ }
3270
+ html += `</div>`;
3271
+ }
3272
+
3273
+ html += `</div>`; // close tree-group
3274
+ return html;
3275
+ }
3276
+
3277
+ container.innerHTML = renderGroup(fileTreeData);
3278
+ if (countEl) countEl.textContent = totalFiles;
3279
+ }
3280
+
3281
+ function escHtml(s) {
3282
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
3283
+ }
3284
+
3285
+ function renderFileTreeDebounced() {
3286
+ if (_fileTreeRenderTimer) clearTimeout(_fileTreeRenderTimer);
3287
+ _fileTreeRenderTimer = setTimeout(() => {
3288
+ renderFileTree();
3289
+ _fileTreeRenderTimer = null;
3290
+ }, 100);
3291
+ }
3292
+
3293
+ function toggleFolder(path) {
3294
+ if (fileTreeExpanded.has(path)) fileTreeExpanded.delete(path);
3295
+ else fileTreeExpanded.add(path);
3296
+ renderFileTree();
3297
+ }
3298
+
3299
+ function expandAllFolders() {
3300
+ if (!fileTreeData) return;
3301
+ function collectPaths(folder) {
3302
+ for (const child of Object.values(folder.children)) {
3303
+ fileTreeExpanded.add(child.path);
3304
+ collectPaths(child);
3305
+ }
3306
+ }
3307
+ collectPaths(fileTreeData);
3308
+ renderFileTree();
3309
+ }
3310
+
3311
+ function collapseAllFolders() {
3312
+ fileTreeExpanded.clear();
3313
+ renderFileTree();
3314
+ }
3315
+
3316
+ function treeScrollToFile(nodeId) {
3317
+ if (!fileTreeData || !nodeId || _treeScrollLock) return;
3318
+ // Find file path and expand parent folders
3319
+ const node = graphData?.nodes.find(n => n.id === nodeId);
3320
+ if (!node) return;
3321
+ const fullPath = node.fullPath || nodeId;
3322
+ const parts = fullPath.split("/").filter(Boolean);
3323
+
3324
+ // Expand all parent folders
3325
+ for (let i = 1; i < parts.length; i++) {
3326
+ const folderPath = parts.slice(0, i).join("/");
3327
+ fileTreeExpanded.add(folderPath);
3328
+ }
3329
+
3330
+ renderFileTree();
3331
+
3332
+ // Scroll to the file element
3333
+ requestAnimationFrame(() => {
3334
+ const container = document.getElementById("tree-scroll-container");
3335
+ const el = document.querySelector(`[data-node-id="${CSS.escape(nodeId)}"]`);
3336
+ if (el && container && SETTINGS.fileTree.autoScrollOnChange) {
3337
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
3338
+ }
3339
+ });
3340
+ }
3341
+
3342
+ function applyFileTreeSettings() {
3343
+ const panel = document.getElementById("file-tree-panel");
3344
+ if (panel) {
3345
+ const w = SETTINGS.fileTree.panelWidth;
3346
+ panel.style.flex = `0 0 ${w}px`;
3347
+ panel.style.width = `${w}px`;
3348
+ }
3349
+ updateDynamicRightOffsets();
3350
+ renderFileTree();
3351
+ if (Graph) {
3352
+ const gp = document.getElementById("graph-panel");
3353
+ if (gp) Graph.width(gp.clientWidth);
3354
+ }
3355
+ }
3356
+
3357
+ function setupFileTree() {
3358
+ const container = document.getElementById("tree-content");
3359
+ const searchInput = document.getElementById("tree-search-input");
3360
+ const sortBtn = document.getElementById("tree-sort-btn");
3361
+ const sortMenu = document.getElementById("tree-sort-menu");
3362
+ const collapseBtn = document.getElementById("tree-collapse-all");
3363
+ const scrollContainer = document.getElementById("tree-scroll-container");
3364
+
3365
+ if (!container) return;
3366
+
3367
+ // Click delegation for tree nodes
3368
+ container.addEventListener("click", (e) => {
3369
+ const node = e.target.closest(".tree-node");
3370
+ if (!node) return;
3371
+
3372
+ if (node.dataset.type === "folder") {
3373
+ toggleFolder(node.dataset.path);
3374
+ return;
3375
+ }
3376
+
3377
+ // File click → select in 3D graph
3378
+ const nodeId = node.dataset.nodeId;
3379
+ if (nodeId && graphData) {
3380
+ const gNode = graphData.nodes.find(n => n.id === nodeId);
3381
+ if (gNode) {
3382
+ handleNodeClick(gNode);
3383
+ }
3384
+ }
3385
+ });
3386
+
3387
+ // Toggle arrow click
3388
+ container.addEventListener("click", (e) => {
3389
+ const toggle = e.target.closest(".tree-toggle");
3390
+ if (toggle) {
3391
+ e.stopPropagation();
3392
+ toggleFolder(toggle.dataset.path);
3393
+ }
3394
+ });
3395
+
3396
+ // Hover → highlight 3D node
3397
+ container.addEventListener("mouseenter", (e) => {
3398
+ const node = e.target.closest(".tree-node[data-type='file']");
3399
+ if (!node || !Graph) return;
3400
+ const nodeId = node.dataset.nodeId;
3401
+ if (nodeId) {
3402
+ highlightNodes.add(nodeId);
3403
+ refreshGraph();
3404
+ }
3405
+ }, true);
3406
+
3407
+ container.addEventListener("mouseleave", (e) => {
3408
+ const node = e.target.closest(".tree-node[data-type='file']");
3409
+ if (!node || !Graph) return;
3410
+ const nodeId = node.dataset.nodeId;
3411
+ if (nodeId && nodeId !== selectedNodeId) {
3412
+ highlightNodes.delete(nodeId);
3413
+ refreshGraph();
3414
+ }
3415
+ }, true);
3416
+
3417
+ // Search filter
3418
+ if (searchInput) {
3419
+ searchInput.addEventListener("input", () => {
3420
+ fileTreeFilter = searchInput.value.trim();
3421
+ // Auto-expand when filtering
3422
+ if (fileTreeFilter && fileTreeData) expandAllFolders();
3423
+ else renderFileTree();
3424
+ });
3425
+ }
3426
+
3427
+ // Sort button + menu
3428
+ if (sortBtn && sortMenu) {
3429
+ sortBtn.addEventListener("click", (e) => {
3430
+ e.stopPropagation();
3431
+ sortMenu.classList.toggle("hidden");
3432
+ });
3433
+
3434
+ sortMenu.querySelectorAll(".tree-sort-option").forEach(opt => {
3435
+ opt.addEventListener("click", (e) => {
3436
+ e.stopPropagation();
3437
+ fileTreeSort = opt.dataset.sort;
3438
+ sortMenu.querySelectorAll(".tree-sort-option").forEach(o => o.classList.remove("active"));
3439
+ opt.classList.add("active");
3440
+ sortMenu.classList.add("hidden");
3441
+ renderFileTree();
3442
+ });
3443
+ });
3444
+
3445
+ // Close sort menu on outside click
3446
+ document.addEventListener("click", () => {
3447
+ if (sortMenu) sortMenu.classList.add("hidden");
3448
+ });
3449
+ }
3450
+
3451
+ // Collapse/expand all toggle
3452
+ let allExpanded = false;
3453
+ if (collapseBtn) {
3454
+ collapseBtn.addEventListener("click", () => {
3455
+ if (allExpanded) { collapseAllFolders(); collapseBtn.innerHTML = "&#9660;"; }
3456
+ else { expandAllFolders(); collapseBtn.innerHTML = "&#9650;"; }
3457
+ allExpanded = !allExpanded;
3458
+ });
3459
+ }
3460
+
3461
+ // Scroll lock: disable auto-scroll when user manually scrolls
3462
+ if (scrollContainer) {
3463
+ scrollContainer.addEventListener("scroll", () => {
3464
+ _treeScrollLock = true;
3465
+ if (_treeScrollLockTimer) clearTimeout(_treeScrollLockTimer);
3466
+ _treeScrollLockTimer = setTimeout(() => { _treeScrollLock = false; }, 3000);
3467
+ }, { passive: true });
3468
+ }
3469
+
3470
+ // Apply initial settings
3471
+ applyFileTreeSettings();
3472
+ updateDynamicRightOffsets();
3473
+ }
3474
+
3475
+ function setupTreeResizeHandle() {
3476
+ const handle = document.getElementById("tree-resize-handle");
3477
+ const panel = document.getElementById("file-tree-panel");
3478
+ if (!handle || !panel) return;
3479
+
3480
+ let startX = 0;
3481
+ let startW = 0;
3482
+
3483
+ function onMove(e) {
3484
+ e.preventDefault();
3485
+ const dx = e.clientX - startX;
3486
+ const newW = Math.max(200, Math.min(500, startW + dx));
3487
+ panel.style.flex = "0 0 " + newW + "px";
3488
+ panel.style.width = newW + "px";
3489
+ SETTINGS.fileTree.panelWidth = newW;
3490
+ updateDynamicRightOffsets();
3491
+ if (Graph) {
3492
+ const gp = document.getElementById("graph-panel");
3493
+ if (gp) Graph.width(gp.clientWidth);
3494
+ }
3495
+ }
3496
+
3497
+ function onUp() {
3498
+ window.removeEventListener("mousemove", onMove, true);
3499
+ window.removeEventListener("mouseup", onUp, true);
3500
+ handle.classList.remove("dragging");
3501
+ document.body.style.cursor = "";
3502
+ document.body.style.userSelect = "";
3503
+ const overlay = document.getElementById("tree-resize-overlay");
3504
+ if (overlay) overlay.remove();
3505
+ saveSettings();
3506
+ if (Graph) {
3507
+ const gp = document.getElementById("graph-panel");
3508
+ if (gp) Graph.width(gp.clientWidth);
3509
+ }
3510
+ }
3511
+
3512
+ handle.addEventListener("mousedown", (e) => {
3513
+ e.preventDefault();
3514
+ e.stopPropagation();
3515
+ startX = e.clientX;
3516
+ startW = panel.offsetWidth;
3517
+ handle.classList.add("dragging");
3518
+ document.body.style.cursor = "col-resize";
3519
+ document.body.style.userSelect = "none";
3520
+ const overlay = document.createElement("div");
3521
+ overlay.id = "tree-resize-overlay";
3522
+ overlay.style.cssText = "position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;cursor:col-resize;background:transparent;";
3523
+ document.body.appendChild(overlay);
3524
+ window.addEventListener("mousemove", onMove, true);
3525
+ window.addEventListener("mouseup", onUp, true);
3526
+ });
3527
+ }
3528
+
3529
+ // ═══════════════════════════════════════════
3530
+ // RESIZE HANDLE (Right Panel)
3531
+ // ═══════════════════════════════════════════
2531
3532
  function setupResizeHandle() {
2532
3533
  const handle = document.getElementById("resize-handle");
2533
3534
  const panel = document.getElementById("right-panel");
@@ -2543,7 +3544,7 @@ function setupResizeHandle() {
2543
3544
  const newW = Math.max(250, Math.min(900, startW + dx));
2544
3545
  panel.style.flex = "0 0 " + newW + "px";
2545
3546
  panel.style.width = newW + "px";
2546
- if (hub) hub.style.right = newW + "px";
3547
+ updateDynamicRightOffsets();
2547
3548
  if (Graph) {
2548
3549
  const gp = document.getElementById("graph-panel");
2549
3550
  if (gp) Graph.width(gp.clientWidth);
@@ -2587,82 +3588,23 @@ function setupResizeHandle() {
2587
3588
  // ═══════════════════════════════════════════
2588
3589
  // PROJECT SELECTOR
2589
3590
  // ═══════════════════════════════════════════
2590
- let licenseTimerInterval = null;
2591
-
2592
3591
  function updateLicenseBadge(plan, expiresAt) {
2593
3592
  const badge = document.getElementById("license-badge");
2594
3593
  if (!badge) return;
2595
3594
 
2596
- const planEl = badge.querySelector(".license-plan");
2597
- const timerEl = badge.querySelector(".license-timer");
2598
-
2599
- // Clear previous timer
2600
- if (licenseTimerInterval) {
2601
- clearInterval(licenseTimerInterval);
2602
- licenseTimerInterval = null;
2603
- }
2604
-
2605
3595
  badge.className = "license-badge";
2606
3596
 
2607
- if (plan === "pro" && expiresAt) {
2608
- const expiry = new Date(expiresAt);
2609
- const now = new Date();
2610
- const diffMs = expiry - now;
2611
-
2612
- if (diffMs <= 0) {
2613
- // Expired
3597
+ if (plan === "pro") {
3598
+ if (expiresAt && new Date(expiresAt) < new Date()) {
2614
3599
  badge.classList.add("expired");
2615
- planEl.textContent = "EXPIRED";
2616
- timerEl.textContent = "";
3600
+ badge.textContent = "EXPIRED";
2617
3601
  } else {
2618
- const totalDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
2619
-
2620
- if (totalDays <= 3) {
2621
- // Trial or near-expiry
2622
- badge.classList.add("trial-urgent");
2623
- planEl.textContent = "TRIAL";
2624
- } else if (totalDays <= 14) {
2625
- badge.classList.add("trial");
2626
- planEl.textContent = "TRIAL";
2627
- } else {
2628
- badge.classList.add("pro");
2629
- planEl.textContent = "PRO";
2630
- }
2631
-
2632
- // Live countdown
2633
- function tick() {
2634
- const remaining = new Date(expiresAt) - new Date();
2635
- if (remaining <= 0) {
2636
- timerEl.textContent = "EXPIRED";
2637
- badge.className = "license-badge expired";
2638
- planEl.textContent = "EXPIRED";
2639
- clearInterval(licenseTimerInterval);
2640
- return;
2641
- }
2642
- const d = Math.floor(remaining / (1000 * 60 * 60 * 24));
2643
- const h = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
2644
- const m = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
2645
- const s = Math.floor((remaining % (1000 * 60)) / 1000);
2646
-
2647
- if (d > 0) {
2648
- timerEl.textContent = `D-${d} ${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
2649
- } else {
2650
- timerEl.textContent = `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")}:${String(s).padStart(2,"0")}`;
2651
- }
2652
- }
2653
- tick();
2654
- licenseTimerInterval = setInterval(tick, 1000);
2655
- }
2656
- } else if (plan === "pro") {
2657
- // Pro without expiry (permanent or subscription)
2658
- badge.classList.add("pro");
2659
- planEl.textContent = "PRO";
2660
- timerEl.textContent = "";
3602
+ badge.classList.add("pro");
3603
+ badge.textContent = "PRO";
3604
+ }
2661
3605
  } else {
2662
- // Free
2663
3606
  badge.classList.add("free");
2664
- planEl.textContent = "FREE";
2665
- timerEl.textContent = "";
3607
+ badge.textContent = "FREE";
2666
3608
  }
2667
3609
  }
2668
3610