gopeak 2.1.0 → 2.2.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.
@@ -0,0 +1,814 @@
1
+ /**
2
+ * Event handlers for mouse, keyboard, and search
3
+ */
4
+ import { nodes, edges, camera, W, H, defaultZoom, dragging, setDragging, hoveredNode, setHoveredNode, searchTerm, setSearchTerm, currentView, expandedScene, expandedSceneHierarchy, sceneData, setExpandedScene, setExpandedSceneHierarchy, setSelectedSceneNode, setHoveredSceneNode, selectedSceneNode, scenePositions, setScenePosition, categories, activeCategories, toggleCategory, setAllCategories, categoryGroupMode, setCategoryGroupMode, changesVisible, setChangesVisible, gitChangeSummary, changesFilter, setChangesFilter } from './state.js';
5
+ import { getCanvas, screenToWorld, hitTest, groupBoxHitTest, draw, resize, updateZoomIndicator, centerOnNodes, savePositions, sceneHitTest, SCENE_CARD_W, SCENE_CARD_H, clearPositions, fitToView } from './canvas.js';
6
+ import { initLayout, initGroupedLayout } from './layout.js';
7
+ import { openPanel, closePanel, openSceneNodePanel, closeSceneNodePanel } from './panel.js';
8
+ import { sendCommand } from './websocket.js';
9
+ const DRAG_THRESHOLD = 5; // pixels - minimum movement to count as drag
10
+ export function initEvents() {
11
+ const canvas = getCanvas();
12
+ const searchInput = document.getElementById('search');
13
+ const statsEl = document.getElementById('stats');
14
+ // Mouse events
15
+ canvas.addEventListener('mousedown', (e) => {
16
+ const w = screenToWorld(e.clientX, e.clientY);
17
+ if (currentView === 'scenes') {
18
+ handleSceneMouseDown(e, w);
19
+ }
20
+ else {
21
+ handleScriptsMouseDown(e, w);
22
+ }
23
+ });
24
+ canvas.addEventListener('mousemove', (e) => {
25
+ if (currentView === 'scenes') {
26
+ handleSceneMouseMove(e);
27
+ }
28
+ else {
29
+ handleScriptsMouseMove(e);
30
+ }
31
+ });
32
+ canvas.addEventListener('mouseup', (e) => {
33
+ if (currentView === 'scenes') {
34
+ handleSceneMouseUp(e);
35
+ }
36
+ else {
37
+ handleScriptsMouseUp(e);
38
+ }
39
+ });
40
+ // Prevent click from also opening panel (mouseup already handles it)
41
+ canvas.addEventListener('click', (e) => {
42
+ // Only handle clicks on empty space (not nodes) - nodes are handled by mouseup
43
+ });
44
+ canvas.addEventListener('wheel', (e) => {
45
+ e.preventDefault();
46
+ // Smaller zoom increments for finer control
47
+ const zoomFactor = e.deltaY > 0 ? 0.95 : 1.05;
48
+ const newZoom = Math.max(0.1, Math.min(5, camera.zoom * zoomFactor));
49
+ const wx = (e.clientX - W / 2) / camera.zoom + camera.x;
50
+ const wy = (e.clientY - H / 2) / camera.zoom + camera.y;
51
+ camera.zoom = newZoom;
52
+ camera.x = wx - (e.clientX - W / 2) / camera.zoom;
53
+ camera.y = wy - (e.clientY - H / 2) / camera.zoom;
54
+ updateZoomIndicator();
55
+ draw();
56
+ }, { passive: false });
57
+ // Double-click to rename
58
+ canvas.addEventListener('dblclick', (e) => {
59
+ if (currentView === 'scenes' && expandedScene) {
60
+ const w = screenToWorld(e.clientX, e.clientY);
61
+ const hit = sceneHitTest(w.x, w.y);
62
+ if (hit && hit.type === 'sceneNode') {
63
+ e.preventDefault();
64
+ startInlineRename(e.clientX, e.clientY, hit.node, hit.scenePath);
65
+ }
66
+ }
67
+ });
68
+ // Right-click context menu
69
+ canvas.addEventListener('contextmenu', (e) => {
70
+ e.preventDefault();
71
+ if (currentView === 'scenes' && expandedScene) {
72
+ const w = screenToWorld(e.clientX, e.clientY);
73
+ const hit = sceneHitTest(w.x, w.y);
74
+ if (hit && hit.type === 'sceneNode') {
75
+ showSceneContextMenu(e.clientX, e.clientY, hit.node, hit.scenePath);
76
+ return;
77
+ }
78
+ }
79
+ // Hide scene context menu if clicking elsewhere
80
+ hideSceneContextMenu();
81
+ });
82
+ // Close context menus when clicking elsewhere
83
+ document.addEventListener('click', (e) => {
84
+ if (!e.target.closest('#scene-context-menu')) {
85
+ hideSceneContextMenu();
86
+ }
87
+ });
88
+ // Search
89
+ searchInput.addEventListener('input', () => {
90
+ const term = searchInput.value.toLowerCase().trim();
91
+ setSearchTerm(term);
92
+ if (currentView === 'scripts') {
93
+ nodes.forEach(n => {
94
+ if (!term) {
95
+ n.highlighted = true;
96
+ n.visible = true;
97
+ return;
98
+ }
99
+ const matches = n.filename.toLowerCase().includes(term) ||
100
+ (n.class_name && n.class_name.toLowerCase().includes(term)) ||
101
+ (n.description && n.description.toLowerCase().includes(term)) ||
102
+ (n.path && n.path.toLowerCase().includes(term));
103
+ n.highlighted = matches;
104
+ n.visible = matches;
105
+ });
106
+ const matchingNodes = nodes.filter(n => n.highlighted);
107
+ const count = matchingNodes.length;
108
+ statsEl.textContent = term
109
+ ? `${count}/${nodes.length}`
110
+ : `${nodes.length} scripts · ${edges.length} connections`;
111
+ // If there are matching results, center the view on them
112
+ if (term && matchingNodes.length > 0) {
113
+ centerOnNodes(matchingNodes);
114
+ // Adjust zoom if needed to fit all matching nodes
115
+ if (matchingNodes.length === 1) {
116
+ camera.zoom = Math.max(defaultZoom, 1);
117
+ }
118
+ updateZoomIndicator();
119
+ }
120
+ }
121
+ // Scene search
122
+ if (currentView === 'scenes') {
123
+ if (expandedScene && expandedSceneHierarchy) {
124
+ // Search within expanded scene - highlight matching nodes
125
+ const matchingPaths = [];
126
+ function searchNode(node) {
127
+ const matches = !term ||
128
+ node.name.toLowerCase().includes(term) ||
129
+ (node.type && node.type.toLowerCase().includes(term));
130
+ node.highlighted = matches;
131
+ if (matches && term)
132
+ matchingPaths.push(node.path);
133
+ if (node.children) {
134
+ for (const child of node.children) {
135
+ searchNode(child);
136
+ }
137
+ }
138
+ }
139
+ searchNode(expandedSceneHierarchy);
140
+ const totalNodes = countNodes(expandedSceneHierarchy);
141
+ statsEl.textContent = term
142
+ ? `${matchingPaths.length}/${totalNodes} nodes`
143
+ : `${totalNodes} nodes`;
144
+ }
145
+ else if (sceneData && sceneData.scenes) {
146
+ // Search in scene overview
147
+ let matchCount = 0;
148
+ for (const scene of sceneData.scenes) {
149
+ const sceneName = scene.name || scene.path.split('/').pop().replace('.tscn', '');
150
+ scene.highlighted = !term ||
151
+ sceneName.toLowerCase().includes(term) ||
152
+ (scene.root_type && scene.root_type.toLowerCase().includes(term));
153
+ if (scene.highlighted)
154
+ matchCount++;
155
+ }
156
+ statsEl.textContent = term
157
+ ? `${matchCount}/${sceneData.scenes.length} scenes`
158
+ : `${sceneData.scenes.length} scenes`;
159
+ }
160
+ }
161
+ draw();
162
+ });
163
+ function countNodes(node) {
164
+ let count = 1;
165
+ if (node.children) {
166
+ for (const child of node.children) {
167
+ count += countNodes(child);
168
+ }
169
+ }
170
+ return count;
171
+ }
172
+ // Keyboard shortcuts
173
+ document.addEventListener('keydown', (e) => {
174
+ if (e.key === 'Escape') {
175
+ // Also close context menus
176
+ hideSceneContextMenu();
177
+ if (currentView === 'scenes') {
178
+ if (selectedSceneNode) {
179
+ setSelectedSceneNode(null);
180
+ closeSceneNodePanel();
181
+ draw();
182
+ }
183
+ else if (expandedScene) {
184
+ goBackToSceneOverview();
185
+ }
186
+ }
187
+ else {
188
+ closePanel();
189
+ }
190
+ }
191
+ // Focus search with /
192
+ if (e.key === '/' && document.activeElement !== searchInput) {
193
+ e.preventDefault();
194
+ searchInput.focus();
195
+ }
196
+ // Delete key to delete selected scene node
197
+ if ((e.key === 'Delete' || e.key === 'Backspace') && currentView === 'scenes' && selectedSceneNode && !e.target.matches('input, textarea')) {
198
+ e.preventDefault();
199
+ sceneNodeAction('delete');
200
+ }
201
+ // Enter to open properties panel for selected node
202
+ if (e.key === 'Enter' && currentView === 'scenes' && expandedScene && !e.target.matches('input, textarea')) {
203
+ // If no node selected, select root
204
+ // If node selected, this could toggle the panel (already handled by re-click)
205
+ }
206
+ });
207
+ // Window resize
208
+ window.addEventListener('resize', () => {
209
+ resize();
210
+ draw();
211
+ });
212
+ }
213
+ // ---- Scripts view event handlers ----
214
+ function handleScriptsMouseDown(e, w) {
215
+ const canvas = getCanvas();
216
+ const hit = hitTest(w.x, w.y);
217
+ if (hit && e.button === 0) {
218
+ setDragging({
219
+ type: 'node',
220
+ node: hit,
221
+ offX: hit.x - w.x,
222
+ offY: hit.y - w.y,
223
+ startScreenX: e.clientX,
224
+ startScreenY: e.clientY,
225
+ moved: false
226
+ });
227
+ canvas.classList.add('dragging');
228
+ }
229
+ else if (categoryGroupMode === 'grouped' && e.button === 0) {
230
+ const box = groupBoxHitTest(w.x, w.y);
231
+ if (box) {
232
+ // Collect all nodes belonging to this category
233
+ const groupNodes = nodes.filter(n => n.category === box.category);
234
+ setDragging({
235
+ type: 'group',
236
+ box: box,
237
+ groupNodes: groupNodes,
238
+ offX: box.x - w.x,
239
+ offY: box.y - w.y,
240
+ startScreenX: e.clientX,
241
+ startScreenY: e.clientY,
242
+ moved: false
243
+ });
244
+ canvas.classList.add('dragging');
245
+ }
246
+ else {
247
+ setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y });
248
+ canvas.classList.add('dragging');
249
+ }
250
+ }
251
+ else {
252
+ setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y });
253
+ canvas.classList.add('dragging');
254
+ }
255
+ }
256
+ function handleScriptsMouseMove(e) {
257
+ const canvas = getCanvas();
258
+ if (dragging) {
259
+ if (dragging.type === 'node') {
260
+ const w = screenToWorld(e.clientX, e.clientY);
261
+ dragging.node.x = w.x + dragging.offX;
262
+ dragging.node.y = w.y + dragging.offY;
263
+ // Check if moved past threshold
264
+ const dx = Math.abs(e.clientX - dragging.startScreenX);
265
+ const dy = Math.abs(e.clientY - dragging.startScreenY);
266
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
267
+ dragging.moved = true;
268
+ }
269
+ }
270
+ else if (dragging.type === 'group') {
271
+ const w = screenToWorld(e.clientX, e.clientY);
272
+ const newBoxX = w.x + dragging.offX;
273
+ const newBoxY = w.y + dragging.offY;
274
+ const deltaX = newBoxX - dragging.box.x;
275
+ const deltaY = newBoxY - dragging.box.y;
276
+ // Move the group box
277
+ dragging.box.x = newBoxX;
278
+ dragging.box.y = newBoxY;
279
+ // Move all nodes in this category by the same delta
280
+ for (const n of dragging.groupNodes) {
281
+ n.x += deltaX;
282
+ n.y += deltaY;
283
+ }
284
+ const dx = Math.abs(e.clientX - dragging.startScreenX);
285
+ const dy = Math.abs(e.clientY - dragging.startScreenY);
286
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
287
+ dragging.moved = true;
288
+ }
289
+ }
290
+ else {
291
+ const dx = (e.clientX - dragging.startX) / camera.zoom;
292
+ const dy = (e.clientY - dragging.startY) / camera.zoom;
293
+ camera.x = dragging.camX - dx;
294
+ camera.y = dragging.camY - dy;
295
+ }
296
+ draw();
297
+ }
298
+ else {
299
+ const w = screenToWorld(e.clientX, e.clientY);
300
+ const prev = hoveredNode;
301
+ setHoveredNode(hitTest(w.x, w.y));
302
+ if (hoveredNode !== prev) {
303
+ canvas.style.cursor = hoveredNode ? 'pointer' : 'grab';
304
+ draw();
305
+ }
306
+ else if (!hoveredNode && categoryGroupMode === 'grouped') {
307
+ const box = groupBoxHitTest(w.x, w.y);
308
+ canvas.style.cursor = box ? 'grab' : 'grab';
309
+ }
310
+ }
311
+ }
312
+ function handleScriptsMouseUp(e) {
313
+ const canvas = getCanvas();
314
+ if (dragging && dragging.type === 'node') {
315
+ if (dragging.moved) {
316
+ savePositions();
317
+ }
318
+ else {
319
+ openPanel(dragging.node);
320
+ }
321
+ }
322
+ else if (dragging && dragging.type === 'group') {
323
+ if (dragging.moved) {
324
+ savePositions();
325
+ }
326
+ }
327
+ canvas.classList.remove('dragging');
328
+ setDragging(null);
329
+ }
330
+ // ---- Scene view event handlers ----
331
+ function handleSceneMouseDown(e, w) {
332
+ const canvas = getCanvas();
333
+ const hit = sceneHitTest(w.x, w.y);
334
+ if (hit && e.button === 0) {
335
+ if (hit.type === 'sceneCard') {
336
+ // Scene card - prepare for possible drag or click
337
+ const pos = scenePositions[hit.scenePath];
338
+ setDragging({
339
+ type: 'sceneCard',
340
+ scene: hit.scene,
341
+ scenePath: hit.scenePath,
342
+ offX: pos.x - w.x,
343
+ offY: pos.y - w.y,
344
+ startScreenX: e.clientX,
345
+ startScreenY: e.clientY,
346
+ moved: false
347
+ });
348
+ canvas.classList.add('dragging');
349
+ }
350
+ else if (hit.type === 'sceneNode') {
351
+ // Scene node in expanded view - click to select
352
+ setDragging({
353
+ type: 'sceneNode',
354
+ node: hit.node,
355
+ scenePath: hit.scenePath,
356
+ startScreenX: e.clientX,
357
+ startScreenY: e.clientY,
358
+ moved: false
359
+ });
360
+ }
361
+ }
362
+ else {
363
+ setDragging({ type: 'pan', startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y });
364
+ canvas.classList.add('dragging');
365
+ }
366
+ }
367
+ function handleSceneMouseMove(e) {
368
+ const canvas = getCanvas();
369
+ if (dragging) {
370
+ if (dragging.type === 'sceneCard') {
371
+ const w = screenToWorld(e.clientX, e.clientY);
372
+ const newX = w.x + dragging.offX;
373
+ const newY = w.y + dragging.offY;
374
+ setScenePosition(dragging.scenePath, newX, newY);
375
+ // Check if moved past threshold
376
+ const dx = Math.abs(e.clientX - dragging.startScreenX);
377
+ const dy = Math.abs(e.clientY - dragging.startScreenY);
378
+ if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) {
379
+ dragging.moved = true;
380
+ }
381
+ draw();
382
+ }
383
+ else if (dragging.type === 'pan') {
384
+ const dx = (e.clientX - dragging.startX) / camera.zoom;
385
+ const dy = (e.clientY - dragging.startY) / camera.zoom;
386
+ camera.x = dragging.camX - dx;
387
+ camera.y = dragging.camY - dy;
388
+ draw();
389
+ }
390
+ }
391
+ else {
392
+ const w = screenToWorld(e.clientX, e.clientY);
393
+ const hit = sceneHitTest(w.x, w.y);
394
+ if (hit) {
395
+ if (hit.type === 'sceneCard') {
396
+ setHoveredSceneNode({ scenePath: hit.scenePath, nodePath: null });
397
+ }
398
+ else if (hit.type === 'sceneNode') {
399
+ setHoveredSceneNode({ scenePath: hit.scenePath, nodePath: hit.node.path });
400
+ }
401
+ canvas.style.cursor = 'pointer';
402
+ }
403
+ else {
404
+ setHoveredSceneNode(null);
405
+ canvas.style.cursor = 'grab';
406
+ }
407
+ draw();
408
+ }
409
+ }
410
+ function handleSceneMouseUp(e) {
411
+ const canvas = getCanvas();
412
+ if (dragging) {
413
+ if (dragging.type === 'sceneCard' && !dragging.moved) {
414
+ // Scene card was clicked - expand the scene
415
+ expandScene(dragging.scenePath);
416
+ }
417
+ else if (dragging.type === 'sceneNode' && !dragging.moved) {
418
+ // Scene node was clicked - select it and open properties panel
419
+ selectSceneNode(dragging.node, dragging.scenePath);
420
+ }
421
+ }
422
+ canvas.classList.remove('dragging');
423
+ setDragging(null);
424
+ }
425
+ // ---- Scene expansion and navigation ----
426
+ async function expandScene(scenePath) {
427
+ console.log('Expanding scene:', scenePath);
428
+ try {
429
+ // Fetch the scene hierarchy
430
+ const result = await sendCommand('get_scene_hierarchy', { scene_path: scenePath });
431
+ if (result.ok) {
432
+ setExpandedScene(scenePath);
433
+ setExpandedSceneHierarchy(result.hierarchy);
434
+ // Reset camera position but keep user's zoom level
435
+ camera.x = 0;
436
+ camera.y = 100;
437
+ // Don't change zoom - keep user's preference
438
+ // Update UI
439
+ updateSceneBackButton(true, scenePath);
440
+ draw();
441
+ }
442
+ else {
443
+ console.error('Failed to get scene hierarchy:', result.error);
444
+ alert('Failed to load scene: ' + (result.error || 'Unknown error'));
445
+ }
446
+ }
447
+ catch (err) {
448
+ console.error('Failed to expand scene:', err);
449
+ alert('Failed to load scene: ' + err.message);
450
+ }
451
+ }
452
+ async function selectSceneNode(node, scenePath) {
453
+ console.log('Selected scene node:', node.name, 'in', scenePath);
454
+ // If clicking the same node that's already selected, close the panel
455
+ if (selectedSceneNode && selectedSceneNode.path === node.path) {
456
+ setSelectedSceneNode(null);
457
+ closeSceneNodePanel();
458
+ draw();
459
+ return;
460
+ }
461
+ setSelectedSceneNode(node);
462
+ // Open the properties panel for this node
463
+ await openSceneNodePanel(scenePath, node);
464
+ draw();
465
+ }
466
+ export function goBackToSceneOverview() {
467
+ setExpandedScene(null);
468
+ setExpandedSceneHierarchy(null);
469
+ setSelectedSceneNode(null);
470
+ setHoveredSceneNode(null);
471
+ closeSceneNodePanel();
472
+ updateSceneBackButton(false);
473
+ draw();
474
+ }
475
+ function updateSceneBackButton(show, scenePath = '') {
476
+ const backBtn = document.getElementById('scene-back-btn');
477
+ const legend = document.getElementById('legend');
478
+ if (backBtn) {
479
+ backBtn.style.display = show ? 'flex' : 'none';
480
+ if (show) {
481
+ const sceneName = scenePath.split('/').pop().replace('.tscn', '');
482
+ backBtn.querySelector('.scene-name').textContent = sceneName;
483
+ }
484
+ }
485
+ // Hide legend when in expanded scene view (it's not relevant there)
486
+ if (legend) {
487
+ legend.classList.toggle('hidden', show);
488
+ }
489
+ }
490
+ // Expose for global access
491
+ window.goBackToSceneOverview = goBackToSceneOverview;
492
+ window.expandSceneFromPanel = expandScene;
493
+ export function buildChangesPanel() {
494
+ const modifiedCountEl = document.getElementById('changes-modified-count');
495
+ const addedCountEl = document.getElementById('changes-added-count');
496
+ const untrackedCountEl = document.getElementById('changes-untracked-count');
497
+ const newCountEl = document.getElementById('changes-new-count');
498
+ const totalEl = document.getElementById('changes-total');
499
+ const toggleInput = document.getElementById('changes-toggle-input');
500
+ if (modifiedCountEl)
501
+ modifiedCountEl.textContent = String(gitChangeSummary.modified);
502
+ if (addedCountEl)
503
+ addedCountEl.textContent = String(gitChangeSummary.added);
504
+ if (untrackedCountEl)
505
+ untrackedCountEl.textContent = String(gitChangeSummary.untracked);
506
+ if (newCountEl)
507
+ newCountEl.textContent = String(gitChangeSummary.new || 0);
508
+ const totalChanges = gitChangeSummary.modified + gitChangeSummary.added + gitChangeSummary.untracked;
509
+ if (totalEl)
510
+ totalEl.textContent = `${totalChanges} changed files`;
511
+ if (toggleInput) {
512
+ toggleInput.checked = changesVisible;
513
+ }
514
+ const rows = document.querySelectorAll('#changes-stats .changes-stat-row');
515
+ rows.forEach((row) => {
516
+ row.classList.toggle('active', row.dataset.type === changesFilter);
517
+ });
518
+ }
519
+ // ---- Category filter functions ----
520
+ export function buildCategoryList() {
521
+ const catList = document.getElementById('cat-list');
522
+ if (!catList || !categories.length)
523
+ return;
524
+ catList.innerHTML = '';
525
+ categories.forEach(cat => {
526
+ const item = document.createElement('div');
527
+ item.className = 'cat-item' + (activeCategories.has(cat.id) ? '' : ' inactive');
528
+ item.dataset.catId = cat.id;
529
+ item.innerHTML = `
530
+ <div class="cat-dot" style="background:${cat.color}"></div>
531
+ <span class="cat-label">${cat.label}</span>
532
+ <span class="cat-count">${cat.count}</span>
533
+ `;
534
+ item.addEventListener('click', () => {
535
+ toggleCategory(cat.id);
536
+ item.classList.toggle('inactive', !activeCategories.has(cat.id));
537
+ draw();
538
+ });
539
+ catList.appendChild(item);
540
+ });
541
+ }
542
+ window.toggleGroupMode = function () {
543
+ const btn = document.getElementById('cat-mode-btn');
544
+ if (categoryGroupMode === 'free') {
545
+ setCategoryGroupMode('grouped');
546
+ btn.classList.add('active');
547
+ clearPositions();
548
+ initGroupedLayout();
549
+ fitToView(nodes);
550
+ }
551
+ else {
552
+ setCategoryGroupMode('free');
553
+ btn.classList.remove('active');
554
+ window.__categoryGroupBoxes = null;
555
+ clearPositions();
556
+ initLayout();
557
+ fitToView(nodes);
558
+ }
559
+ draw();
560
+ };
561
+ window.toggleAllCategories = function () {
562
+ const allActive = activeCategories.size === categories.length;
563
+ setAllCategories(!allActive);
564
+ buildCategoryList();
565
+ draw();
566
+ };
567
+ window.toggleChangesVisible = function () {
568
+ const toggleInput = document.getElementById('changes-toggle-input');
569
+ const checked = !!(toggleInput && toggleInput.checked);
570
+ setChangesVisible(checked);
571
+ draw();
572
+ };
573
+ window.filterByChangeType = function (type) {
574
+ const nextFilter = changesFilter === type ? null : type;
575
+ setChangesFilter(nextFilter);
576
+ const matchingNodes = [];
577
+ nodes.forEach((node) => {
578
+ if (!nextFilter) {
579
+ node.visible = true;
580
+ node.highlighted = true;
581
+ return;
582
+ }
583
+ const matches = type === 'new' ? node.isNew : node.gitStatus === nextFilter;
584
+ node.visible = matches;
585
+ node.highlighted = matches;
586
+ if (matches)
587
+ matchingNodes.push(node);
588
+ });
589
+ if (nextFilter && matchingNodes.length > 0) {
590
+ centerOnNodes(matchingNodes);
591
+ }
592
+ buildChangesPanel();
593
+ draw();
594
+ };
595
+ export function updateStats() {
596
+ const statsEl = document.getElementById('stats');
597
+ if (currentView === 'scripts') {
598
+ statsEl.textContent = `${nodes.length} scripts · ${edges.length} connections`;
599
+ }
600
+ else if (sceneData && sceneData.scenes) {
601
+ statsEl.textContent = `${sceneData.scenes.length} scenes`;
602
+ }
603
+ }
604
+ // ---- Scene Node Context Menu ----
605
+ let contextMenuNode = null;
606
+ let contextMenuScenePath = null;
607
+ function showSceneContextMenu(x, y, node, scenePath) {
608
+ const menu = document.getElementById('scene-context-menu');
609
+ contextMenuNode = node;
610
+ contextMenuScenePath = scenePath;
611
+ menu.style.left = x + 'px';
612
+ menu.style.top = y + 'px';
613
+ menu.classList.add('visible');
614
+ }
615
+ function hideSceneContextMenu() {
616
+ const menu = document.getElementById('scene-context-menu');
617
+ menu.classList.remove('visible');
618
+ contextMenuNode = null;
619
+ contextMenuScenePath = null;
620
+ }
621
+ async function sceneNodeAction(action) {
622
+ // Save node info BEFORE hiding menu (which clears these variables)
623
+ const node = contextMenuNode;
624
+ const scenePath = contextMenuScenePath;
625
+ hideSceneContextMenu();
626
+ if (!node || !scenePath)
627
+ return;
628
+ try {
629
+ switch (action) {
630
+ case 'add_child': {
631
+ const nodeType = prompt('Enter node type (e.g., Node2D, Sprite2D, Label):', 'Node2D');
632
+ if (!nodeType)
633
+ return;
634
+ const nodeName = prompt('Enter node name:', 'NewNode');
635
+ if (!nodeName)
636
+ return;
637
+ const result = await sendCommand('add_node', {
638
+ scene_path: scenePath,
639
+ parent_path: node.path,
640
+ node_type: nodeType,
641
+ node_name: nodeName
642
+ });
643
+ if (result.ok) {
644
+ await refreshExpandedScene(scenePath);
645
+ }
646
+ else {
647
+ alert('Failed to add node: ' + (result.error || 'Unknown error'));
648
+ }
649
+ break;
650
+ }
651
+ case 'rename': {
652
+ const newName = prompt('Enter new name:', node.name);
653
+ if (!newName || newName === node.name)
654
+ return;
655
+ const result = await sendCommand('rename_node', {
656
+ scene_path: scenePath,
657
+ node_path: node.path,
658
+ new_name: newName
659
+ });
660
+ if (result.ok) {
661
+ await refreshExpandedScene(scenePath);
662
+ }
663
+ else {
664
+ alert('Failed to rename: ' + (result.error || 'Unknown error'));
665
+ }
666
+ break;
667
+ }
668
+ case 'duplicate': {
669
+ const result = await sendCommand('duplicate_node', {
670
+ scene_path: scenePath,
671
+ node_path: node.path
672
+ });
673
+ if (result.ok) {
674
+ await refreshExpandedScene(scenePath);
675
+ }
676
+ else {
677
+ alert('Failed to duplicate: ' + (result.error || 'Unknown error'));
678
+ }
679
+ break;
680
+ }
681
+ case 'move_up': {
682
+ if (node.index === undefined || node.index <= 0) {
683
+ alert('Cannot move node up - already at top');
684
+ return;
685
+ }
686
+ const result = await sendCommand('reorder_node', {
687
+ scene_path: scenePath,
688
+ node_path: node.path,
689
+ new_index: node.index - 1
690
+ });
691
+ if (result.ok) {
692
+ await refreshExpandedScene(scenePath);
693
+ }
694
+ else {
695
+ alert('Failed to move: ' + (result.error || 'Unknown error'));
696
+ }
697
+ break;
698
+ }
699
+ case 'move_down': {
700
+ const result = await sendCommand('reorder_node', {
701
+ scene_path: scenePath,
702
+ node_path: node.path,
703
+ new_index: (node.index || 0) + 1
704
+ });
705
+ if (result.ok) {
706
+ await refreshExpandedScene(scenePath);
707
+ }
708
+ else {
709
+ alert('Failed to move: ' + (result.error || 'Unknown error'));
710
+ }
711
+ break;
712
+ }
713
+ case 'delete': {
714
+ if (node.path === '.') {
715
+ alert('Cannot delete root node');
716
+ return;
717
+ }
718
+ if (!confirm(`Delete node "${node.name}" and all its children?`))
719
+ return;
720
+ const result = await sendCommand('remove_node', {
721
+ scene_path: scenePath,
722
+ node_path: node.path
723
+ });
724
+ if (result.ok) {
725
+ closeSceneNodePanel();
726
+ setSelectedSceneNode(null);
727
+ await refreshExpandedScene(scenePath);
728
+ }
729
+ else {
730
+ alert('Failed to delete: ' + (result.error || 'Unknown error'));
731
+ }
732
+ break;
733
+ }
734
+ }
735
+ }
736
+ catch (err) {
737
+ console.error('Scene action failed:', err);
738
+ alert('Action failed: ' + err.message);
739
+ }
740
+ }
741
+ async function refreshExpandedScene(scenePath) {
742
+ // Re-fetch the scene hierarchy
743
+ const result = await sendCommand('get_scene_hierarchy', { scene_path: scenePath });
744
+ if (result.ok) {
745
+ setExpandedSceneHierarchy(result.hierarchy);
746
+ draw();
747
+ }
748
+ }
749
+ // ---- Inline Rename ----
750
+ function startInlineRename(screenX, screenY, node, scenePath) {
751
+ // Create an input overlay at the node position
752
+ const existingInput = document.getElementById('inline-rename-input');
753
+ if (existingInput)
754
+ existingInput.remove();
755
+ const input = document.createElement('input');
756
+ input.id = 'inline-rename-input';
757
+ input.type = 'text';
758
+ input.value = node.name;
759
+ input.style.cssText = `
760
+ position: fixed;
761
+ left: ${screenX - 50}px;
762
+ top: ${screenY - 12}px;
763
+ width: 120px;
764
+ padding: 4px 8px;
765
+ font-size: 12px;
766
+ font-family: -apple-system, system-ui, sans-serif;
767
+ font-weight: 600;
768
+ background: #0f1014;
769
+ border: 2px solid #7aa2f7;
770
+ border-radius: 4px;
771
+ color: #f0f0f5;
772
+ z-index: 1000;
773
+ outline: none;
774
+ `;
775
+ document.body.appendChild(input);
776
+ input.focus();
777
+ input.select();
778
+ async function finishRename() {
779
+ const newName = input.value.trim();
780
+ input.remove();
781
+ if (newName && newName !== node.name) {
782
+ try {
783
+ const result = await sendCommand('rename_node', {
784
+ scene_path: scenePath,
785
+ node_path: node.path,
786
+ new_name: newName
787
+ });
788
+ if (result.ok) {
789
+ await refreshExpandedScene(scenePath);
790
+ }
791
+ else {
792
+ alert('Failed to rename: ' + (result.error || 'Unknown error'));
793
+ }
794
+ }
795
+ catch (err) {
796
+ alert('Failed to rename: ' + err.message);
797
+ }
798
+ }
799
+ }
800
+ input.addEventListener('blur', finishRename);
801
+ input.addEventListener('keydown', (e) => {
802
+ if (e.key === 'Enter') {
803
+ e.preventDefault();
804
+ input.blur();
805
+ }
806
+ else if (e.key === 'Escape') {
807
+ e.preventDefault();
808
+ input.value = node.name; // Reset to original
809
+ input.blur();
810
+ }
811
+ });
812
+ }
813
+ // Expose for global access
814
+ window.sceneNodeAction = sceneNodeAction;