diff --git a/README.ja.md b/README.ja.md index 9c679ec..d007191 100644 --- a/README.ja.md +++ b/README.ja.md @@ -5,6 +5,7 @@ > Swift コードの構造と呼び出し関係をグラフ分析するツールです。AI が生成したコード変更の検証に特に役立ちます。 [![CI](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml/badge.svg)](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml) +[![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/SeungMinLee.flowmap-ai-swift-guard?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=SeungMinLee.flowmap-ai-swift-guard) [English](README.md) · [한국어](README.ko.md) @@ -66,15 +67,15 @@ FlowMap は誤リンクを抑えるため、cross-file 呼び出し解決を保 ### Overview モード -![Overview mode](docs/screenshots/overview.jpg) +![Overview mode](editor/vscode/resources/screenshot_overview.png) ### File Detail モード -![File detail mode](docs/screenshots/file-detail.jpg) +![File detail mode](editor/vscode/resources/screenshot_detail.png) ### Calls モード -![Calls mode](docs/screenshots/calls.jpg) +![Calls mode](editor/vscode/resources/screenshot_calls.png) ## インストール diff --git a/README.ko.md b/README.ko.md index 3198ec8..6ead23d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -5,6 +5,7 @@ > Swift 코드의 구조와 호출 관계를 그래프로 분석하는 도구입니다. AI가 생성한 코드 변경을 검증할 때 특히 유용합니다. [![CI](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml/badge.svg)](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml) +[![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/SeungMinLee.flowmap-ai-swift-guard?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=SeungMinLee.flowmap-ai-swift-guard) [English](README.md) · [日本語](README.ja.md) @@ -66,15 +67,15 @@ FlowMap은 false link를 줄이기 위해 cross-file 호출을 보수적으로 ### Overview 모드 -![Overview mode](docs/screenshots/overview.jpg) +![Overview mode](editor/vscode/resources/screenshot_overview.png) ### File Detail 모드 -![File detail mode](docs/screenshots/file-detail.jpg) +![File detail mode](editor/vscode/resources/screenshot_detail.png) ### Calls 모드 -![Calls mode](docs/screenshots/calls.jpg) +![Calls mode](editor/vscode/resources/screenshot_calls.png) ## 설치 diff --git a/README.md b/README.md index 11d6688..9ceb03d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ > Swift code graph and impact analysis tool — understand and verify code structure, especially when reviewing AI-generated changes. [![CI](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml/badge.svg)](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml) +[![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/SeungMinLee.flowmap-ai-swift-guard?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=SeungMinLee.flowmap-ai-swift-guard) [한국어](README.ko.md) · [日本語](README.ja.md) @@ -67,15 +68,15 @@ This behavior is expected in current public beta and is being expanded increment ### Overview mode -![Overview mode](docs/screenshots/overview.jpg) +![Overview mode](editor/vscode/resources/screenshot_overview.png) ### File detail mode -![File detail mode](docs/screenshots/file-detail.jpg) +![File detail mode](editor/vscode/resources/screenshot_detail.png) ### Calls mode -![Calls mode](docs/screenshots/calls.jpg) +![Calls mode](editor/vscode/resources/screenshot_calls.png) ## Installation diff --git a/editor/vscode/.vscodeignore b/editor/vscode/.vscodeignore index 339bc4a..b9b96ad 100644 --- a/editor/vscode/.vscodeignore +++ b/editor/vscode/.vscodeignore @@ -1,8 +1,13 @@ -# Keep package minimal for release VSIX +# Keep package minimal for release VSIX. **/* !package.json !LICENSE +!README.md +!CHANGELOG.md !out/** !webview/** +!resources/** +.DS_Store +**/.DS_Store !node_modules/cytoscape/** diff --git a/editor/vscode/CHANGELOG.md b/editor/vscode/CHANGELOG.md new file mode 100644 index 0000000..800e073 --- /dev/null +++ b/editor/vscode/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 0.1.3 + +- Improved graph layout to reduce node overlap. +- Improved readability for dense graph views. +- Added right-click actions for Swift files and folders. +- Added Marketplace screenshots for Overview, File Detail, and Calls modes. +- Added Marketplace icon. +- Improved Marketplace README for public beta users. + +## 0.1.2 + +- Added public beta Marketplace listing metadata. +- Updated extension branding to FlowMap Hallucination Guard for Swift. diff --git a/editor/vscode/README.md b/editor/vscode/README.md new file mode 100644 index 0000000..449b72f --- /dev/null +++ b/editor/vscode/README.md @@ -0,0 +1,87 @@ +# FlowMap Hallucination Guard for Swift + +**Public Beta** + +FlowMap is a graph-based review tool for Swift projects. It helps you inspect AI-generated changes by showing the real structure of your code: files, types, functions, calls, graph diffs, and impacted nodes. + +AI coding tools can produce changes that look plausible in a text diff while quietly breaking call relationships or moving logic into the wrong place. FlowMap adds a visual review layer so you can catch those structural mistakes faster. + +Repository: https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift + +Issues: https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/issues + +## What's New in 0.1.3 + +- Improved graph layout to reduce node overlap. +- Improved readability for dense Swift projects. +- Added right-click actions for analyzing Swift files and folders directly from VS Code. +- Added Marketplace screenshots for Overview, File Detail, and Calls views. +- Kept public beta positioning explicit: FlowMap is a review aid, not a compiler or test replacement. + +## See the Code Shape Before You Trust the Change + +FlowMap turns a Swift workspace into a visual graph. Use it when an AI assistant edits code and you want to verify whether the resulting structure still makes sense. + +It highlights: + +- Added nodes and edges. +- Removed nodes and edges. +- Changed functions. +- Impacted nodes reached through call relationships. +- File, type, function, and call-level structure. + +## Overview Mode + +Use Overview Mode to inspect the broad project shape. Folders and Swift files are grouped into a workspace-level graph, making unexpected file additions or large structural shifts easier to spot. + +![Overview Mode](resources/screenshot_overview.png) + +## File Detail Mode + +Use File Detail Mode to inspect the inside of a Swift file. FlowMap groups types and functions so you can check whether an AI-generated edit changed the structure of a file in a suspicious way. + +![File Detail Mode](resources/screenshot_detail.png) + +## Calls Mode + +Use Calls Mode to trace function-level relationships across the workspace. The improved layout reduces overlap and keeps call paths easier to read, especially when reviewing changed or removed links. + +![Calls Mode](resources/screenshot_calls.png) + +## Faster VS Code Workflow + +FlowMap can be launched from the command palette, and 0.1.3 also adds right-click entry points: + +- Right-click a Swift file and run `FlowMap: Analyze Workspace`. +- Right-click a Swift file and run `FlowMap: Show Graph Diff`. +- Right-click a folder in Explorer and run `FlowMap: Analyze Workspace`. +- Save a Swift file to trigger auto-analysis when enabled. + +## Quick Start + +1. Install the extension. +2. Build the FlowMap engine from the GitHub repository. +3. Set `flowmap.binaryPath` to the built `flowmap` binary. +4. Open a Swift workspace in VS Code. +5. Run `FlowMap: Analyze Workspace`. +6. Open `FlowMap: Show Graph Diff`. + +## Requirements + +- macOS. +- Swift toolchain / Xcode Command Line Tools. +- A built FlowMap engine binary. + +## Configuration + +| Setting | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `flowmap.binaryPath` | `string` | `""` | Path to the `flowmap` analyzer binary. | +| `flowmap.autoAnalyzeOnSave` | `boolean` | `true` | Automatically re-analyze workspace when a Swift file is saved. | +| `flowmap.autoAnalyzeDebounceMs` | `number` | `500` | Delay in ms after save before triggering auto-analysis. | + +## Public Beta Notes + +FlowMap is not a compiler, type checker, or replacement for tests. It is a graph-based review aid for spotting suspicious structural changes faster. + +Cross-file call resolution is intentionally conservative in the current beta. Protocols, extensions, and generic-heavy code may resolve to interface-level relationships instead of a single concrete implementation. diff --git a/editor/vscode/package-lock.json b/editor/vscode/package-lock.json index 6abbf2c..0665569 100644 --- a/editor/vscode/package-lock.json +++ b/editor/vscode/package-lock.json @@ -1,12 +1,12 @@ { - "name": "flowmap", - "version": "0.1.0", + "name": "flowmap-ai-swift-guard", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "flowmap", - "version": "0.1.0", + "name": "flowmap-ai-swift-guard", + "version": "0.1.3", "dependencies": { "cytoscape": "^3.33.1" }, diff --git a/editor/vscode/package.json b/editor/vscode/package.json index 00e904e..d65264d 100644 --- a/editor/vscode/package.json +++ b/editor/vscode/package.json @@ -1,12 +1,18 @@ { - "name": "flowmap", - "displayName": "FlowMap", - "description": "Call-graph visualization for AI-generated Swift code", - "version": "0.1.0", + "name": "flowmap-ai-swift-guard", + "publisher": "SeungMinLee", + "displayName": "FlowMap Hallucination Guard for Swift", + "description": "Detect risky AI-generated Swift changes with graph diff and impact analysis", + "version": "0.1.3", + "icon": "resources/icon.png", "repository": { "type": "git", "url": "https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift.git" }, + "homepage": "https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift", + "bugs": { + "url": "https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/issues" + }, "engines": { "vscode": "^1.85.0" }, @@ -66,6 +72,27 @@ "title": "FlowMap: Pro Feature Preview" } ], + "menus": { + "editor/context": [ + { + "command": "flowmap.analyzeWorkspace", + "when": "resourceLangId == swift", + "group": "navigation" + }, + { + "command": "flowmap.showGraphDiff", + "when": "resourceLangId == swift", + "group": "navigation" + } + ], + "explorer/context": [ + { + "command": "flowmap.analyzeWorkspace", + "when": "explorerResourceIsFolder || resourceExtname == .swift", + "group": "navigation" + } + ] + }, "configuration": { "title": "FlowMap", "properties": { diff --git a/editor/vscode/resources/icon.png b/editor/vscode/resources/icon.png new file mode 100644 index 0000000..f8ee4a8 Binary files /dev/null and b/editor/vscode/resources/icon.png differ diff --git a/editor/vscode/resources/screenshot_calls.png b/editor/vscode/resources/screenshot_calls.png new file mode 100644 index 0000000..4745e25 Binary files /dev/null and b/editor/vscode/resources/screenshot_calls.png differ diff --git a/editor/vscode/resources/screenshot_detail.png b/editor/vscode/resources/screenshot_detail.png new file mode 100644 index 0000000..1a5ec47 Binary files /dev/null and b/editor/vscode/resources/screenshot_detail.png differ diff --git a/editor/vscode/resources/screenshot_overview.png b/editor/vscode/resources/screenshot_overview.png new file mode 100644 index 0000000..5bde1ee Binary files /dev/null and b/editor/vscode/resources/screenshot_overview.png differ diff --git a/editor/vscode/webview/graph.events.js b/editor/vscode/webview/graph.events.js index 385ab82..eaaa5c9 100644 --- a/editor/vscode/webview/graph.events.js +++ b/editor/vscode/webview/graph.events.js @@ -49,10 +49,8 @@ function registerFlowMapEvents() { } } else { - // Calls mode: full compound graph — file/type toggle; func highlights calls. - if (kind === 'file' || kind === 'type') { - toggleExpand(node.id()); - } else if (kind === 'func') { + // Calls mode: full compound graph — func highlights calls. + if (kind === 'func') { const callEdges = node.outgoers('edge').filter('[kind = "calls"]'); const callTargets = callEdges.targets(); if (callEdges.length > 0) { @@ -91,6 +89,54 @@ function registerFlowMapEvents() { cy.on('mouseout', 'node[kind = "folder"]', function (evt) { evt.target.removeClass('folder-hover'); }); + + // ── Drag clamp for leaf nodes inside parent file boxes ─────────────────── + cy.on('drag', 'node', function (evt) { + const node = evt.target; + if (node.isParent()) return; + + // If any parent ancestor is grabbed (meaning the user is dragging the parent window), + // do NOT clamp child positions as it interferes with Cytoscape's internal drag engine and causes erratic drifts + let ancestorGrabbed = false; + let p = node.parent(); + while (p && p.length > 0) { + if (p.grabbed()) { + ancestorGrabbed = true; + break; + } + p = p.parent(); + } + if (ancestorGrabbed) return; + + const fileBox = fileAncestorOf(node); + if (fileBox && fileBox.length > 0 && state.mode !== 'overview') { + const W = fileBox.outerWidth() || fileBox.width() || 100; + const H = fileBox.outerHeight() || fileBox.height() || 60; + const pPos = fileBox.position(); + const w = node.outerWidth() || node.width() || 80; + const h = node.outerHeight() || node.height() || 28; + + const padLeft = 8; + const padRight = 8; + const padTop = 24 + 8; + const padBottom = 8; + + const minX = pPos.x - W/2 + w/2 + padLeft; + const maxX = pPos.x + W/2 - w/2 - padRight; + const minY = pPos.y - H/2 + h/2 + padTop; + const maxY = pPos.y + H/2 - h/2 - padBottom; + + const pos = node.position(); + let cx = pos.x; + let cy = pos.y; + if (cx < minX) cx = minX; + if (cx > maxX) cx = maxX; + if (cy < minY) cy = minY; + if (cy > maxY) cy = maxY; + + node.position({ x: cx, y: cy }); + } + }); // ── Tooltip: show full label when the display label was truncated ──────── (function initTooltip() { @@ -198,6 +244,13 @@ function registerFlowMapEvents() { deferredFit(cy.nodes(), 80); }); } + + const forceBtn = document.getElementById('btn-force'); + if (forceBtn) { + forceBtn.addEventListener('click', function () { + runPhysicsLayout(); + }); + } // ── Search ────────────────────────────────────────────────────────── // Substring match on node labels (case-insensitive). @@ -305,7 +358,112 @@ function registerFlowMapEvents() { }); } })(); - + + // ── Drag-to-resize compound file boxes ───────────────────────────── + let resizeNode = null; + let resizeStartPos = null; + let startMinWidth = 0; + let startMinHeight = 0; + + cy.on('mousedown', 'node[kind = "file"]', function (evt) { + const node = evt.target; + if (state.mode === 'overview') return; + + const mousePos = evt.position; + const bb = node.boundingBox(); + + const cornerX = bb.x2; + const cornerY = bb.y2; + const dist = Math.sqrt((mousePos.x - cornerX) * (mousePos.x - cornerX) + (mousePos.y - cornerY) * (mousePos.y - cornerY)); + + if (dist < 24) { + resizeNode = node; + resizeStartPos = { x: mousePos.x, y: mousePos.y }; + startMinWidth = node.outerWidth(); + startMinHeight = node.outerHeight(); + + cy.boxSelectionEnabled(false); + cy.userPanningEnabled(false); + node.ungrabify(); + + pauseFloatingAnimation(); + + evt.preventDefault(); + evt.stopPropagation(); + } + }); + + cy.on('mousemove', function (evt) { + if (resizeNode) { + const mousePos = evt.position; + const dx = mousePos.x - resizeStartPos.x; + const dy = mousePos.y - resizeStartPos.y; + + const newWidth = Math.max(100, startMinWidth + dx); + const newHeight = Math.max(60, startMinHeight + dy); + + resizeNode.style({ + 'min-width': newWidth, + 'min-height': newHeight + }); + } else { + const mousePos = evt.position; + let nearCorner = false; + + if (state.mode !== 'overview') { + const fileNodes = cy.nodes('[kind = "file"]:visible'); + for (let i = 0; i < fileNodes.length; i++) { + const node = fileNodes[i]; + const bb = node.boundingBox(); + const dist = Math.sqrt((mousePos.x - bb.x2) * (mousePos.x - bb.x2) + (mousePos.y - bb.y2) * (mousePos.y - bb.y2)); + if (dist < 20) { + nearCorner = true; + break; + } + } + } + + const container = document.getElementById('cy'); + if (container) { + if (nearCorner) { + container.style.cursor = 'se-resize'; + } else { + if (container.style.cursor === 'se-resize') { + container.style.cursor = ''; + } + } + } + } + }); + + cy.on('mouseup', function (evt) { + if (resizeNode) { + cy.boxSelectionEnabled(true); + cy.userPanningEnabled(true); + resizeNode.grabify(); + + resetBasePositions(); + resumeFloatingAnimation(); + + resizeNode = null; + resizeStartPos = null; + } + }); + + window.addEventListener('mouseup', function () { + if (resizeNode) { + cy.boxSelectionEnabled(true); + cy.userPanningEnabled(true); + resizeNode.grabify(); + + resetBasePositions(); + resumeFloatingAnimation(); + + resizeNode = null; + resizeStartPos = null; + } + }); + // ═══════════════════════════════════════════════════════════════════════════ // Message handler // ═══════════════════════════════════════════════════════════════════════════ diff --git a/editor/vscode/webview/graph.html b/editor/vscode/webview/graph.html index d8cbcaf..cc96d0d 100644 --- a/editor/vscode/webview/graph.html +++ b/editor/vscode/webview/graph.html @@ -168,7 +168,7 @@ /* ── Status badge (top-left) ────────────────────────────────────────── */ #status-badge { position: absolute; - top: 10px; + bottom: 12px; left: 12px; padding: 4px 9px; border-radius: 5px; @@ -236,6 +236,7 @@ + @@ -243,6 +244,10 @@
LEGEND
+
+
+ +
diff --git a/editor/vscode/webview/graph.js b/editor/vscode/webview/graph.js index bd13de5..261fb35 100644 --- a/editor/vscode/webview/graph.js +++ b/editor/vscode/webview/graph.js @@ -110,13 +110,13 @@ // Hard minimum gaps used by all detail and calls layout code. // No layout may place nodes closer than these values. const GROUP_GAP = 180; // vertical gap between type-group blocks (file-detail) - const CARD_GAP_X = 90; // horizontal gap between func/type cards in a row - const CARD_GAP_Y = 48; // vertical gap between card rows + const CARD_GAP_X = 50; // horizontal gap between func/type cards in a row + const CARD_GAP_Y = 32; // vertical gap between card rows const COMPONENT_GAP = 220; // gap between component bounding boxes (calls mode) const DETAIL_PADDING = 60; // outer viewport padding after detail/calls layout const PANEL_MIN_W = 320; // minimum panel slot width per component (calls mode) const PANEL_MIN_H = 220; // minimum panel slot height per component (calls mode) - const TILE_GAP = 90; // minimum gap between component tiles (calls mode) + const TILE_GAP = 50; // minimum gap between component tiles (calls mode) // ── Parse embedded payload ─────────────────────────────────────────────── const raw = document.getElementById('graph-data').textContent ?? '{}'; @@ -146,7 +146,7 @@ color: '#e2e8f0', 'text-valign': 'center', 'text-halign': 'center', - 'font-size': '10px', + 'font-size': '12px', 'font-family': 'Inter, system-ui, -apple-system, sans-serif', width: 'label', height: 'label', @@ -173,7 +173,7 @@ style: { 'background-color': 'rgba(30, 58, 138, 0.35)', 'background-opacity': 1, - 'font-size': '11px', + 'font-size': '15px', 'font-weight': '600', color: '#cbd5e1', 'text-valign': 'center', @@ -182,9 +182,20 @@ 'border-color': 'rgba(96, 165, 250, 0.25)', 'border-opacity': 1, 'border-width': 1, - padding: '12px', - 'min-width': 110, - 'min-height': 32, + padding: '6px', + }, + }, + { + selector: 'node[kind = "file"]:parent', + style: { + 'text-valign': 'top', + 'text-margin-y': '15px', + 'padding-top': '30px', + 'padding-bottom': '10px', + 'padding-left': '10px', + 'padding-right': '10px', + 'min-width': 90, + 'min-height': 30, }, }, // Hover glow: added via mouseover event, removed on mouseout @@ -274,11 +285,26 @@ style: { 'background-color': 'rgba(6, 78, 59, 0.4)', 'background-opacity': 1, - 'font-size': '10px', + 'font-size': '14px', color: '#a7f3d0', 'border-color': 'rgba(52, 211, 153, 0.25)', 'border-opacity': 1, 'border-width': 1, + padding: '4px', + 'text-valign': 'center', + 'text-halign': 'center', + 'text-margin-y': '0px', + }, + }, + { + selector: 'node[kind = "type"]:parent', + style: { + 'text-valign': 'top', + 'text-margin-y': '12px', + 'padding-top': '24px', + 'padding-bottom': '6px', + 'padding-left': '6px', + 'padding-right': '6px', }, }, { @@ -286,7 +312,7 @@ style: { 'background-color': 'rgba(120, 53, 4, 0.4)', 'background-opacity': 1, - 'font-size': '9px', + 'font-size': '13px', color: '#fde68a', 'border-color': 'rgba(251, 191, 36, 0.25)', 'border-opacity': 1, @@ -297,32 +323,27 @@ { selector: 'node[diffState = "added"]', style: { - 'background-color': 'rgba(16, 185, 129, 0.25)', - 'border-color': 'rgba(52, 211, 153, 0.6)', - color: '#a7f3d0', - 'background-opacity': 1 + 'border-color': '#10b981', + 'border-width': 2.5, + 'border-opacity': 1 }, }, { selector: 'node[diffState = "removed"]', style: { - 'background-color': 'rgba(239, 68, 68, 0.25)', - 'background-opacity': 1, + 'border-color': '#ef4444', + 'border-width': 2.5, 'border-style': 'dashed', - 'border-color': 'rgba(248, 113, 113, 0.6)', - color: '#fca5a5', - 'border-width': 1.5, 'border-opacity': 1, - opacity: 0.70, + opacity: 0.85 }, }, { selector: 'node[diffState = "changed"]', style: { - 'background-color': 'rgba(245, 158, 11, 0.25)', - 'border-color': 'rgba(251, 191, 36, 0.6)', - color: '#fde68a', - 'background-opacity': 1 + 'border-color': '#fbbf24', + 'border-width': 2.5, + 'border-opacity': 1 }, }, // ── Impacted node: warm orange outline ────────────────────────────── @@ -330,7 +351,7 @@ selector: 'node[?impacted]', style: { 'border-color': '#f97316', - 'border-width': 2.5, + 'border-width': 3, 'border-style': 'solid', 'border-opacity': 1, }, @@ -357,7 +378,7 @@ 'line-color': 'rgba(255, 255, 255, 0.12)', 'target-arrow-color': 'rgba(255, 255, 255, 0.12)', 'target-arrow-shape': 'triangle', - 'curve-style': 'bezier', + 'curve-style': 'straight', 'line-style': 'dashed', }, }, @@ -369,12 +390,21 @@ 'line-color': '#f97316', 'target-arrow-color': '#f97316', 'target-arrow-shape': 'triangle-backcurve', - 'curve-style': 'bezier', - opacity: 0.7, + 'arrow-scale': 0.8, + 'curve-style': 'straight', + opacity: 0.55, 'transition-property': 'line-color, target-arrow-color, width, opacity', 'transition-duration': '0.3s', }, }, + // ── Calls edge style override ─────────────────────────────────────── + { + selector: 'edge[kind = "calls"]', + style: { + 'curve-style': 'bezier', + 'control-point-step-size': 16, + }, + }, // ── Diff-state edge overrides ─────────────────────────────────────── { selector: 'edge[diffState = "added"]', @@ -397,7 +427,11 @@ // ── Muted unchanged ───────────────────────────────────────────────── { selector: 'node.muted-bg', - style: { opacity: 0.4, color: '#64748b' }, + style: { opacity: 0.28, color: '#475569' }, + }, + { + selector: 'edge.muted-bg', + style: { opacity: 0.1 }, }, // ── Search highlight ──────────────────────────────────────────────── { @@ -480,86 +514,92 @@ // Clears old legend entries then rebuilds from current data vars. // Safe to call multiple times (e.g., from renderGraphFromAnalysis). function buildLegend() { - const legend = document.getElementById('legend'); - if (!legend) { return; } - - // Clear existing entries, preserving the #legend-title element. - const titleEl = document.getElementById('legend-title'); - while (legend.firstChild) { legend.removeChild(legend.firstChild); } - if (titleEl) { legend.appendChild(titleEl); } - - const hasDiff = - addedNodeIds.size > 0 || - removedNodeIds.size > 0 || - changedNodeIds.size > 0 || - impactIds.size > 0; - - let items = [ - { color: 'rgb(70,90,110)', label: 'File node' }, - { color: 'rgb(50,140,100)', label: 'Type node' }, - { color: 'rgb(220,150,70)', label: 'Func node' }, + const colKinds = document.getElementById('legend-col-kinds'); + const colDiffs = document.getElementById('legend-col-diffs'); + if (!colKinds || !colDiffs) { return; } + + // Clear existing entries + while (colKinds.firstChild) { colKinds.removeChild(colKinds.firstChild); } + while (colDiffs.firstChild) { colDiffs.removeChild(colDiffs.firstChild); } + + // 1. Kind items (always shown) + const kindItems = [ + { color: 'rgb(70,90,110)', label: 'File' }, + { color: 'rgb(50,140,100)', label: 'Type' }, + { color: 'rgb(220,150,70)', label: 'Func' }, ]; - if (hasDiff) { - items = items.concat([ - { color: '#1a4a1a', label: 'Added' }, - { color: '#4a1a1a', label: 'Removed' }, - { color: '#4a4a1a', label: 'Changed' }, - { color: 'transparent', label: 'Impacted (orange border)', border: '#e07b39' }, - ]); - } - - items.forEach(function (item) { + kindItems.forEach(function (item) { const div = document.createElement('div'); div.style.display = 'flex'; div.style.alignItems = 'center'; - div.style.marginBottom = '4px'; + div.style.marginBottom = '3px'; const swatch = document.createElement('span'); swatch.style.display = 'inline-block'; - swatch.style.width = '12px'; - swatch.style.height = '12px'; - swatch.style.marginRight = '6px'; - swatch.style.borderRadius = '3px'; + swatch.style.width = '10px'; + swatch.style.height = '10px'; + swatch.style.marginRight = '5px'; + swatch.style.borderRadius = '2px'; swatch.style.background = item.color; - if (item.border) { swatch.style.border = '2px solid ' + item.border; } const text = document.createElement('span'); text.textContent = item.label; - text.style.fontSize = '10px'; + text.style.fontSize = '9px'; text.style.color = '#ccc'; div.appendChild(swatch); div.appendChild(text); - legend.appendChild(div); + colKinds.appendChild(div); }); - if (addedEdgeKeys.size > 0 || removedEdgeKeys.size > 0) { - [ - { color: '#33cc33', label: 'Added edge' }, - { color: '#cc3333', label: 'Removed edge' }, - ].forEach(function (item) { + // 2. Diff items (shown only if there are diffs) + const hasDiff = + addedNodeIds.size > 0 || + removedNodeIds.size > 0 || + changedNodeIds.size > 0 || + impactIds.size > 0 || + addedEdgeKeys.size > 0 || + removedEdgeKeys.size > 0; + + if (hasDiff) { + colDiffs.style.display = 'flex'; + const diffItems = [ + { color: 'transparent', label: 'Added', border: '#10b981' }, + { color: 'transparent', label: 'Removed', border: '#ef4444', borderStyle: 'dashed' }, + { color: 'transparent', label: 'Changed', border: '#fbbf24' }, + { color: 'transparent', label: 'Impacted', border: '#e07b39' }, + ]; + + diffItems.forEach(function (item) { const div = document.createElement('div'); div.style.display = 'flex'; div.style.alignItems = 'center'; - div.style.marginBottom = '4px'; - - const line = document.createElement('span'); - line.style.display = 'inline-block'; - line.style.width = '12px'; - line.style.height = '2px'; - line.style.marginRight = '6px'; - line.style.borderTop = '2px dashed ' + item.color; + div.style.marginBottom = '3px'; + + const swatch = document.createElement('span'); + swatch.style.display = 'inline-block'; + swatch.style.width = '10px'; + swatch.style.height = '10px'; + swatch.style.marginRight = '5px'; + swatch.style.borderRadius = '2px'; + swatch.style.background = item.color; + if (item.border) { + const borderStyle = item.borderStyle || 'solid'; + swatch.style.border = '1.5px ' + borderStyle + ' ' + item.border; + } const text = document.createElement('span'); text.textContent = item.label; - text.style.fontSize = '10px'; + text.style.fontSize = '9px'; text.style.color = '#ccc'; - div.appendChild(line); + div.appendChild(swatch); div.appendChild(text); - legend.appendChild(div); + colDiffs.appendChild(div); }); + } else { + colDiffs.style.display = 'none'; } } diff --git a/editor/vscode/webview/graph.layouts.js b/editor/vscode/webview/graph.layouts.js index f10b0f7..3d8547a 100644 --- a/editor/vscode/webview/graph.layouts.js +++ b/editor/vscode/webview/graph.layouts.js @@ -22,16 +22,50 @@ function deferredFit(eles, padding) { if (files.length > 0) { cy.nodes('[kind = "type"], [kind = "func"]').addClass('hidden-node'); cy.nodes('[kind = "file"]').removeClass('hidden-node'); - cy.fit(files, 120); + const leafFiles = files.filter(function (n) { return !n.isParent(); }); + if (leafFiles.length > 0) { + cy.fit(leafFiles, 120); + } else { + cy.fit(files, 120); + } } return; } - cy.fit(target, pad); + // Filter target to visible leaf nodes (non-parents) for absolute numerical stability + const leafTarget = target.filter(function (n) { return !n.isParent(); }); + if (leafTarget.length > 0) { + cy.fit(leafTarget, pad); + } else { + cy.fit(target, pad); + } }); }); } +// ── pauseFloatingAnimation / resumeFloatingAnimation ──────────────────── +function pauseFloatingAnimation() { + if (floatAnimationId) { + cancelAnimationFrame(floatAnimationId); + floatAnimationId = null; + } +} + +function resumeFloatingAnimation() { + startFloatingAnimation(); +} + +function fileAncestorOf(node) { + var cur = node; + while (cur && cur.length > 0) { + if (cur.data('kind') === 'file') { return cur; } + var par = cur.data('parent'); + if (!par) { return null; } + cur = cy.getElementById(par); + } + return null; +} + // ── animateNodes ───────────────────────────────────────────────────────── // Animates multiple nodes to their calculated target positions in a single // batch, and triggers a callback when all animations complete. @@ -44,6 +78,8 @@ function animateNodes(targets, duration, callback) { return; } + pauseFloatingAnimation(); + targets.forEach(function (t) { t.node.animate({ position: t.position @@ -55,6 +91,7 @@ function animateNodes(targets, duration, callback) { if (completed === targets.length) { resetBasePositions(); if (callback) callback(); + resumeFloatingAnimation(); } } }); @@ -66,7 +103,18 @@ function animateNodes(targets, duration, callback) { function resetBasePositions() { resetHiddenPositions(); cy.nodes().forEach(function (node) { - node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + const pos = { x: node.position('x'), y: node.position('y') }; + node.scratch('base_pos', { x: pos.x, y: pos.y }); + node.scratch('orig_pos', { x: pos.x, y: pos.y }); + node.scratch('vel', { x: 0, y: 0 }); + }); + cy.edges().forEach(function (edge) { + const s = edge.source(); + const t = edge.target(); + const p1 = s.position(); + const p2 = t.position(); + const dist = Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y)) || 80; + edge.scratch('orig_length', Math.min(dist, 120)); }); } @@ -82,90 +130,289 @@ function startFloatingAnimation() { cy.on('free', 'node', function (evt) { const node = evt.target; - node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + if (!node.isParent()) { + const pos = { x: node.position('x'), y: node.position('y') }; + node.scratch('base_pos', { x: pos.x, y: pos.y }); + node.scratch('orig_pos', { x: pos.x, y: pos.y }); + node.scratch('vel', { x: 0, y: 0 }); + } else { + // If a parent node was dragged, reset all its leaf descendants' base, orig, and velocity positions + node.descendants().filter(function(n) { return !n.isParent(); }).forEach(function (child) { + const pos = { x: child.position('x'), y: child.position('y') }; + child.scratch('base_pos', { x: pos.x, y: pos.y }); + child.scratch('orig_pos', { x: pos.x, y: pos.y }); + child.scratch('vel', { x: 0, y: 0 }); + }); + } }); + function isRelated(n1, n2) { + let cur = n2; + while (cur && cur.length > 0) { + if (cur.id() === n1.id()) return true; + cur = cur.parent(); + } + cur = n1; + while (cur && cur.length > 0) { + if (cur.id() === n2.id()) return true; + cur = cur.parent(); + } + return false; + } + + function fileAncestorOf(node) { + let cur = node; + while (cur && cur.length > 0) { + if (cur.data('kind') === 'file') return cur; + let par = cur.parent(); + if (!par || par.length === 0) return null; + cur = par; + } + return null; + } + const startTime = Date.now(); function step() { const elapsed = (Date.now() - startTime) / 1000; - // Ensure all visible nodes have base_pos - cy.nodes(':visible').forEach(function (node) { + // Ensure all visible leaf nodes have base_pos and orig_pos + const leafNodes = cy.nodes(':visible').filter(function (n) { return !n.isParent(); }); + leafNodes.forEach(function (node) { if (!node.scratch('base_pos')) { - node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + const pos = { x: node.position('x'), y: node.position('y') }; + node.scratch('base_pos', { x: pos.x, y: pos.y }); + node.scratch('orig_pos', { x: pos.x, y: pos.y }); } }); - // Mutual repulsion logic to prevent overlap - const nodes = cy.nodes(':visible'); + const visibleNodes = cy.nodes(':visible'); const forces = {}; - nodes.forEach(function (n) { forces[n.id()] = { x: 0, y: 0 }; }); + leafNodes.forEach(function (n) { forces[n.id()] = { x: 0, y: 0 }; }); + + function getBasePos(node) { + if (node.isParent()) { + const leaves = node.descendants().filter(function (n) { return !n.isParent(); }); + if (leaves.length > 0) { + let sumX = 0, sumY = 0, count = 0; + leaves.forEach(function (leaf) { + const bp = leaf.scratch('base_pos'); + if (bp) { + sumX += bp.x; + sumY += bp.y; + count++; + } + }); + if (count > 0) { + return { x: sumX / count, y: sumY / count }; + } + } + } + const bp = node.scratch('base_pos'); + if (bp) return bp; + return { x: node.position('x'), y: node.position('y') }; + } - // Calculate repulsion (nodes push each other away if closer than 150px center-to-center) - const minDistance = 150; - const forceFactor = 0.08; + function distributeForce(node, fx, fy) { + if (node.isParent()) { + node.descendants().filter(function(n) { return !n.isParent(); }).forEach(function (child) { + if (!child.grabbed() && forces[child.id()]) { + forces[child.id()].x += fx; + forces[child.id()].y += fy; + } + }); + } else { + if (!node.grabbed() && forces[node.id()]) { + forces[node.id()].x += fx; + forces[node.id()].y += fy; + } + } + } - for (let i = 0; i < nodes.length; i++) { - const n1 = nodes[i]; + // Calculate AABB rectangular overlap repulsion forces using base coordinates (prevents float wave feedback) + for (let i = 0; i < visibleNodes.length; i++) { + const n1 = visibleNodes[i]; if (n1.grabbed()) continue; - const p1 = n1.position(); + const p1 = getBasePos(n1); + + for (let j = i + 1; j < visibleNodes.length; j++) { + const n2 = visibleNodes[j]; + if (n2.grabbed()) continue; + const p2 = getBasePos(n2); + + // Skip parent-child self repulsion + if (isRelated(n1, n2)) continue; + + const f1 = fileAncestorOf(n1); + const f2 = fileAncestorOf(n2); + + // If they belong to different files, only let the file boxes (parents) repel each other + if (f1 && f2 && f1.id() !== f2.id()) { + const isFile1 = n1.data('kind') === 'file'; + const isFile2 = n2.data('kind') === 'file'; + if (isFile1 && isFile2) { + // Keep going, let file nodes repel in Overview mode (where they are leaf/childless nodes) + } else { + if (!n1.isParent() || !n2.isParent()) { + continue; + } + } + } - for (let j = i + 1; j < nodes.length; j++) { - const n2 = nodes[j]; - const p2 = n2.position(); + const w1 = n1.outerWidth() || n1.width() || 80; + const h1 = n1.outerHeight() || n1.height() || 28; + const w2 = n2.outerWidth() || n2.width() || 80; + const h2 = n2.outerHeight() || n2.height() || 28; const dx = p1.x - p2.x; const dy = p1.y - p2.y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - - if (dist < minDistance) { - const overlap = minDistance - dist; - const rx = (dx / dist) * overlap * forceFactor; - const ry = (dy / dist) * overlap * forceFactor; - if (!n1.grabbed()) { - forces[n1.id()].x += rx; - forces[n1.id()].y += ry; - } - if (!n2.grabbed()) { - forces[n2.id()].x -= rx; - forces[n2.id()].y -= ry; + // Safety gap of 16px to prevent overlaps + const gap = 16; + const overlapX = (w1 + w2) / 2 + gap - Math.abs(dx); + const overlapY = (h1 + h2) / 2 + gap - Math.abs(dy); + + if (overlapX > 0 && overlapY > 0) { + const signX = dx >= 0 ? 1 : -1; + const signY = dy >= 0 ? 1 : -1; + + let rx = 0, ry = 0; + // Resolve only along the minimum overlap axis to prevent diagonal sliding instabilities + if (overlapX < overlapY) { + rx = signX * overlapX * 0.25; + } else { + ry = signY * overlapY * 0.25; } + + distributeForce(n1, rx, ry); + distributeForce(n2, -rx, -ry); } } } + // Ensure all visible edges have orig_length + const visibleEdges = cy.edges(':visible'); + visibleEdges.forEach(function (edge) { + if (!edge.scratch('orig_length')) { + const s = edge.source(); + const t = edge.target(); + const p1 = getBasePos(s); + const p2 = getBasePos(t); + const dist = Math.sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y)) || 80; + edge.scratch('orig_length', Math.min(dist, 120)); + } + }); + + // Calculate spring attraction forces along edges (net effect) using base coordinates + visibleEdges.forEach(function (edge) { + const s = edge.source(); + const t = edge.target(); + if (!s.visible() || !t.visible()) return; + + const p1 = getBasePos(s); + const p2 = getBasePos(t); + const dx = p1.x - p2.x; + const dy = p1.y - p2.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + + const origLength = edge.scratch('orig_length') || 80; + + // Pull them together if they exceed their original distance + if (dist > origLength) { + const stretch = dist - origLength; + const ax = (dx / dist) * stretch * 0.04; // spring constant for edge + const ay = (dy / dist) * stretch * 0.04; + + distributeForce(s, -ax, -ay); + distributeForce(t, ax, ay); + } + }); - // Apply repulsion to base position, then add gentle floating wave offset - nodes.forEach(function (node) { + // Apply repulsion and spring attraction to base position of leaf nodes, then add gentle floating wave offset + leafNodes.forEach(function (node) { if (node.grabbed()) return; + // Allow dragging parent (e.g. title bar) to move the window along with all child cards: + // If any parent ancestor is currently grabbed, sync base_pos to current coordinates and skip forces + let ancestorGrabbed = false; + let p = node.parent(); + while (p && p.length > 0) { + if (p.grabbed()) { + ancestorGrabbed = true; + break; + } + p = p.parent(); + } + if (ancestorGrabbed) { + node.scratch('base_pos', { x: node.position('x'), y: node.position('y') }); + node.scratch('orig_pos', { x: node.position('x'), y: node.position('y') }); + node.scratch('vel', { x: 0, y: 0 }); + return; + } + const id = node.id(); const base = node.scratch('base_pos'); + const orig = node.scratch('orig_pos'); if (!base) return; const f = forces[id]; - base.x += f.x; - base.y += f.y; - // Hash node ID for deterministic unique wave parameters - let hash = 0; - for (let i = 0; i < id.length; i++) { - hash = (hash * 31 + id.charCodeAt(i)) & 0xffff; + // Add a gentle restoring spring force to orig_pos if it drifts past a 12px deadband + if (orig) { + const dx_orig = orig.x - base.x; + const dy_orig = orig.y - base.y; + const dist_orig = Math.sqrt(dx_orig * dx_orig + dy_orig * dy_orig) || 1; + + const deadband = 12; // 12px free-floating deadband to prevent micro-vibrations + if (dist_orig > deadband) { + const k = 0.05; // spring constant + const pull = dist_orig - deadband; + f.x += (dx_orig / dist_orig) * pull * k; + f.y += (dy_orig / dist_orig) * pull * k; + } } - // Small gentle float (amplitude 3-5px) - const speedX = 0.6 + (hash % 5) * 0.12; - const speedY = 0.7 + (hash % 7) * 0.15; - const ampX = 3 + (hash % 3) * 1.0; - const ampY = 4 + (hash % 4) * 1.5; - - const waveX = Math.sin(elapsed * speedX + hash) * ampX; - const waveY = Math.cos(elapsed * speedY + hash) * ampY; + // Retrieve or initialize velocity + if (!node.scratch('vel')) { + node.scratch('vel', { x: 0, y: 0 }); + } + const vel = node.scratch('vel'); + + // Update velocity with friction damping (0.65 drag to absorb oscillation) + vel.x = vel.x * 0.65 + f.x; + vel.y = vel.y * 0.65 + f.y; + + // Update base position + base.x += vel.x; + base.y += vel.y; + + // Real window containment boundary clamping (keep nodes inside file box limits) + const fileBox = fileAncestorOf(node); + if (fileBox && fileBox.length > 0 && state.mode !== 'overview') { + const W = fileBox.outerWidth() || fileBox.width() || 100; + const H = fileBox.outerHeight() || fileBox.height() || 60; + const pPos = getBasePos(fileBox); + const w = node.outerWidth() || node.width() || 80; + const h = node.outerHeight() || node.height() || 28; + + const padLeft = 8; + const padRight = 8; + const padTop = 24 + 8; // header padding + safety margin + const padBottom = 8; + + const minX = pPos.x - W/2 + w/2 + padLeft; + const maxX = pPos.x + W/2 - w/2 - padRight; + const minY = pPos.y - H/2 + h/2 + padTop; + const maxY = pPos.y + H/2 - h/2 - padBottom; + + if (base.x < minX) base.x = minX; + if (base.x > maxX) base.x = maxX; + if (base.y < minY) base.y = minY; + if (base.y > maxY) base.y = maxY; + } node.position({ - x: base.x + waveX, - y: base.y + waveY + x: base.x, + y: base.y }); }); @@ -203,6 +450,8 @@ function runGridLayout() { } if (emptyEl) { emptyEl.style.display = 'none'; } + pauseFloatingAnimation(); + files.layout({ name: 'grid', padding: 80, @@ -214,6 +463,7 @@ function runGridLayout() { fit: true, stop: function () { resetBasePositions(); + resumeFloatingAnimation(); } }).run(); } @@ -231,8 +481,8 @@ function runOverviewLayout() { var nFolders = folderNodes.length; if (nFolders === 0) { deferredFit(cy.nodes(), 60); return; } - // Folder ring: radius grows with folder count (spacious default) - var FOLDER_R = Math.max(340, nFolders * 110); + // Folder ring: radius grows with folder count (compact) + var FOLDER_R = Math.max(120, nFolders * 50); folderNodes.forEach(function (folder, i) { var angle = (2 * Math.PI * i / nFolders) - Math.PI / 2; @@ -247,8 +497,8 @@ function runOverviewLayout() { var nFiles = files.length; if (nFiles === 0) { return; } - // File ring: radius scales with file count (spacious defaults) - var FILE_R = Math.max(200, nFiles * 65); + // File ring: radius scales with file count (compact) + var FILE_R = Math.max(80, nFiles * 25); var spread = nFiles === 1 ? 0 : Math.min(Math.PI * 0.75, (nFiles - 1) * 0.38); files.forEach(function (file, j) { @@ -275,7 +525,12 @@ function collectChildTargets(parentNode, px, py, ph, targets) { if (visibleChildren.length === 0) { return; } var n = visibleChildren.length; - var nCols = n <= 3 ? 1 : 2; + var nCols = 1; + if (n > 6) { + nCols = 3; + } else if (n > 3) { + nCols = 2; + } var childW = 80, childH = 28; visibleChildren.forEach(function (c) { @@ -336,7 +591,14 @@ function syncCallsEdges() { function layoutComponentBFS(compNodes) { var funcNodes = compNodes.filter('[kind = "func"]'); var positionsMap = {}; - if (funcNodes.length === 0) { return { posMap: positionsMap, nodeW: 80, nodeH: 28 }; } + if (funcNodes.length === 0) { + // Position the file/type nodes directly so they don't stay at (0,0) and overlap + var otherNodes = compNodes.filter('[kind = "file"], [kind = "type"]'); + otherNodes.forEach(function (n) { + positionsMap[n.id()] = { x: 0, y: 0 }; + }); + return { posMap: positionsMap, nodeW: 160, nodeH: 48 }; + } var callees = {}; var inDeg = {}; @@ -371,11 +633,55 @@ function layoutComponentBFS(compNodes) { if (!visited[n.id()]) { orderedIds.push(n.id()); } }); - // Spacious vertical stacking: narrow over wide. - var total = orderedIds.length; - var nCols = total > 8 ? 2 : 1; - var GAP_Y = 40; // spacious vertical gap - var GAP_X = 80; // spacious horizontal gap + // Group by parent type container + var parentMap = {}; + var parents = []; + orderedIds.forEach(function (id) { + var n = cy.getElementById(id); + var p = n.parent(); + var pid = (p && p.length > 0) ? p.id() : '_none_'; + if (!parentMap[pid]) { + parentMap[pid] = []; + parents.push(pid); + } + parentMap[pid].push(id); + }); + + var currentY = 0; + var GAP_Y = 20; + var GAP_X = 28; + + parents.forEach(function (pid) { + var gIds = parentMap[pid]; + var gTotal = gIds.length; + var gCols = 1; + if (gTotal > 6) { gCols = 3; } + else if (gTotal > 3) { gCols = 2; } + + var gNodeW = 80, gNodeH = 28; + gIds.forEach(function (id) { + var n = cy.getElementById(id); + gNodeW = Math.max(gNodeW, n.width() || 80); + gNodeH = Math.max(gNodeH, n.height() || 28); + }); + + var blockW = gCols * gNodeW + (gCols - 1) * GAP_X; + var startX = -blockW / 2 + gNodeW / 2; + + currentY += 15; + + gIds.forEach(function (id, idx) { + var c = idx % gCols; + var r = Math.floor(idx / gCols); + positionsMap[id] = { + x: startX + c * (gNodeW + GAP_X), + y: currentY + r * (gNodeH + GAP_Y) + gNodeH / 2 + }; + }); + + var rows = Math.ceil(gTotal / gCols); + currentY += rows * (gNodeH + GAP_Y) + 20; + }); var nodeW = 80, nodeH = 28; orderedIds.forEach(function (id) { @@ -384,18 +690,6 @@ function layoutComponentBFS(compNodes) { nodeH = Math.max(nodeH, n.height() || 28); }); - var blockW = nCols * nodeW + (nCols - 1) * GAP_X; - var startX = -blockW / 2 + nodeW / 2; - - orderedIds.forEach(function (id, i) { - var c = i % nCols; - var r = Math.floor(i / nCols); - positionsMap[id] = { - x: startX + c * (nodeW + GAP_X), - y: r * (nodeH + GAP_Y) + nodeH / 2, - }; - }); - return { posMap: positionsMap, nodeW: nodeW, nodeH: nodeH }; } @@ -421,16 +715,7 @@ function runSpacedCallsLayout() { if (px !== py) { uf[px] = py; } } - function fileAncestorOf(node) { - var cur = node; - while (cur && cur.length > 0) { - if (cur.data('kind') === 'file') { return cur; } - var par = cur.data('parent'); - if (!par) { return null; } - cur = cy.getElementById(par); - } - return null; - } + cy.edges('[kind = "calls"]:visible').forEach(function (e) { var sf = fileAncestorOf(e.source()); @@ -484,7 +769,18 @@ function runSpacedCallsLayout() { if (!hasNodes) { return; } - var bb = { x1: x1, y1: y1, x2: x2, y2: y2, w: x2 - x1, h: y2 - y1 }; + // Add generous padding to the tile's bounding box to account for + // compound file/type boxes padding, borders, and margins. + var paddingX = 40; // compact horizontal safety margin + var paddingY = 30; // compact vertical safety margin + var bb = { + x1: x1 - paddingX, + y1: y1 - paddingY, + x2: x2 + paddingX, + y2: y2 + paddingY, + w: (x2 - x1) + paddingX * 2, + h: (y2 - y1) + paddingY * 2 + }; maxTileW = Math.max(maxTileW, bb.w); totalArea += (bb.w + TILE_GAP) * (bb.h + TILE_GAP); tiles.push({ compNodes: compNodes, posMap: posMap, bb: bb }); @@ -576,3 +872,30 @@ function runSpacedCallsLayout() { deferredFit(cy.nodes(), DETAIL_PADDING); }); } + +// ── runPhysicsLayout ───────────────────────────────────────────────────── +// Runs Cytoscape's built-in physics-based force-directed COSE layout. +function runPhysicsLayout() { + const visibleNodes = cy.nodes(':visible'); + if (visibleNodes.length === 0) return; + + pauseFloatingAnimation(); + + cy.layout({ + name: 'cose', + animate: true, + animationDuration: 800, + randomize: false, + fit: true, + padding: 80, + nodeRepulsion: function(node) { return 1200; }, + idealEdgeLength: function(edge) { return 40; }, + edgeElasticity: function(edge) { return 20; }, + nestingFactor: 1.2, + gravity: 0.35, + stop: function () { + resetBasePositions(); + resumeFloatingAnimation(); + } + }).run(); +} diff --git a/editor/vscode/webview/graph.modes.js b/editor/vscode/webview/graph.modes.js index 572fb60..fdd1dba 100644 --- a/editor/vscode/webview/graph.modes.js +++ b/editor/vscode/webview/graph.modes.js @@ -40,6 +40,16 @@ function buildOverviewElements() { var folderId = '__folder__' + folderKey; var displayName = folderKey === '__root__' ? '(root)' : folderKey; + var folderFiles = folderMap[folderKey]; + var hasChanges = folderFiles.some(function (n) { + return addedNodeIds.has(n.id) || changedNodeIds.has(n.id) || removedNodeIds.has(n.id) || impactIds.has(n.id); + }); + + var fClasses = []; + if (!isClean && !hasChanges) { + fClasses.push('muted-bg'); + } + // Virtual folder node elements.push({ data: { @@ -48,10 +58,15 @@ function buildOverviewElements() { fullLabel: displayName, kind: 'folder', folderKey: folderKey, - fileCount: folderMap[folderKey].length, + fileCount: folderFiles.length, }, + classes: fClasses.join(' '), }); // Branch edge: root → folder + var fBrClasses = []; + if (!isClean && !hasChanges) { + fBrClasses.push('muted-bg'); + } elements.push({ data: { id: '__br__root_' + folderId, @@ -59,11 +74,23 @@ function buildOverviewElements() { target: folderId, kind: 'branch', }, + classes: fBrClasses.join(' '), }); // File nodes + folder→file branch edges - folderMap[folderKey].forEach(function (n) { + folderFiles.forEach(function (n) { var full = n.name ?? n.id; + var diffState = addedNodeIds.has(n.id) + ? 'added' + : changedNodeIds.has(n.id) + ? 'changed' + : 'unchanged'; + var impacted = impactIds.has(n.id); + var fileClasses = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + fileClasses.push('muted-bg'); + } + elements.push({ data: { id: n.id, @@ -73,14 +100,16 @@ function buildOverviewElements() { rawFileNodeId: n.id, // stable raw-graph ID — passed into showFileDetail on click uri: n.uri ?? '', line: typeof n.line === 'number' ? n.line : 0, - diffState: addedNodeIds.has(n.id) - ? 'added' - : changedNodeIds.has(n.id) - ? 'changed' - : 'unchanged', - impacted: impactIds.has(n.id), + diffState: diffState, + impacted: impacted, }, + classes: fileClasses.join(' '), }); + + var brClasses = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + brClasses.push('muted-bg'); + } elements.push({ data: { id: '__br__' + folderId + '_' + n.id, @@ -88,6 +117,7 @@ function buildOverviewElements() { target: n.id, kind: 'branch', }, + classes: brClasses.join(' '), }); }); }); @@ -163,6 +193,13 @@ function buildDetailElements(fileNodeId) { const fileNode = (graph.nodes ?? []).find(function (n) { return n.id === fileNodeId; }); if (fileNode) { const fFull = fileNode.name ?? fileNode.id; + const diffState = addedNodeIds.has(fileNode.id) + ? 'added' : changedNodeIds.has(fileNode.id) ? 'changed' : 'unchanged'; + const impacted = impactIds.has(fileNode.id); + const classes = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + classes.push('muted-bg'); + } detailElements.push({ data: { id: fileNode.id, @@ -172,11 +209,10 @@ function buildDetailElements(fileNodeId) { rawFileNodeId: fileNode.id, uri: fileNode.uri ?? '', line: typeof fileNode.line === 'number' ? fileNode.line : 0, - diffState: addedNodeIds.has(fileNode.id) - ? 'added' : changedNodeIds.has(fileNode.id) ? 'changed' : 'unchanged', - impacted: impactIds.has(fileNode.id), - // no parent — file context is top-level, not a compound parent + diffState: diffState, + impacted: impacted, }, + classes: classes.join(' '), }); } @@ -204,6 +240,13 @@ function buildDetailElements(fileNodeId) { freeNodes.forEach(function (f) { const fFull = f.name ?? f.id; + const diffState = addedNodeIds.has(f.id) + ? 'added' : changedNodeIds.has(f.id) ? 'changed' : 'unchanged'; + const impacted = impactIds.has(f.id); + const classes = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + classes.push('muted-bg'); + } detailElements.push({ data: { id: f.id, @@ -212,11 +255,10 @@ function buildDetailElements(fileNodeId) { kind: 'func', uri: f.uri ?? '', line: typeof f.line === 'number' ? f.line : 0, - // free function is top-level - diffState: addedNodeIds.has(f.id) - ? 'added' : changedNodeIds.has(f.id) ? 'changed' : 'unchanged', - impacted: impactIds.has(f.id), + diffState: diffState, + impacted: impacted, }, + classes: classes.join(' '), }); detailFuncIds.add(f.id); @@ -232,6 +274,13 @@ function buildDetailElements(fileNodeId) { typeNodes.forEach(function (t) { const tFull = t.name ?? t.id; + const diffState = addedNodeIds.has(t.id) + ? 'added' : changedNodeIds.has(t.id) ? 'changed' : 'unchanged'; + const impacted = impactIds.has(t.id); + const classes = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + classes.push('muted-bg'); + } detailElements.push({ data: { id: t.id, @@ -240,11 +289,10 @@ function buildDetailElements(fileNodeId) { kind: 'type', uri: t.uri ?? '', line: typeof t.line === 'number' ? t.line : 0, - diffState: addedNodeIds.has(t.id) - ? 'added' : changedNodeIds.has(t.id) ? 'changed' : 'unchanged', - impacted: impactIds.has(t.id), - // no parent — type is top-level in the detail view + diffState: diffState, + impacted: impacted, }, + classes: classes.join(' '), }); // Func children of this type (compound children of the type node) @@ -257,6 +305,13 @@ function buildDetailElements(fileNodeId) { }); funcNodes.forEach(function (f) { const fFull = f.name ?? f.id; + const diffState = addedNodeIds.has(f.id) + ? 'added' : changedNodeIds.has(f.id) ? 'changed' : 'unchanged'; + const impacted = impactIds.has(f.id); + const classes = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + classes.push('muted-bg'); + } detailElements.push({ data: { id: f.id, @@ -265,11 +320,10 @@ function buildDetailElements(fileNodeId) { kind: 'func', uri: f.uri ?? '', line: typeof f.line === 'number' ? f.line : 0, - // no compound parent in Cytoscape - using structural edges - diffState: addedNodeIds.has(f.id) - ? 'added' : changedNodeIds.has(f.id) ? 'changed' : 'unchanged', - impacted: impactIds.has(f.id), + diffState: diffState, + impacted: impacted, }, + classes: classes.join(' '), }); detailFuncIds.add(f.id); @@ -302,15 +356,21 @@ function buildDetailElements(fileNodeId) { }); callsEdges.forEach(function (e) { const key = e.from + '::' + e.to + '::' + e.kind; + const diffState = addedEdgeKeys.has(key) + ? 'added' : removedEdgeKeys.has(key) ? 'removed' : 'unchanged'; + const classes = []; + if (!isClean && diffState === 'unchanged') { + classes.push('muted-bg'); + } detailElements.push({ data: { id: e.id, source: e.from, target: e.to, kind: 'calls', - diffState: addedEdgeKeys.has(key) - ? 'added' : removedEdgeKeys.has(key) ? 'removed' : 'unchanged', + diffState: diffState, }, + classes: classes.join(' '), }); }); diff --git a/editor/vscode/webview/graph.state.js b/editor/vscode/webview/graph.state.js index e340f48..890a1d8 100644 --- a/editor/vscode/webview/graph.state.js +++ b/editor/vscode/webview/graph.state.js @@ -138,23 +138,29 @@ function applyAnalysisData(a) { */ function buildCyElements() { const cyNodes = (graph.nodes ?? []).map(function (n) { + const diffState = addedNodeIds.has(n.id) + ? 'added' + : changedNodeIds.has(n.id) + ? 'changed' + : 'unchanged'; + const impacted = impactIds.has(n.id); const data = { id: n.id, label: n.name ?? n.id, kind: n.kind ?? 'func', uri: n.uri ?? '', line: typeof n.line === 'number' ? n.line : 0, - diffState: addedNodeIds.has(n.id) - ? 'added' - : changedNodeIds.has(n.id) - ? 'changed' - : 'unchanged', - impacted: impactIds.has(n.id), + diffState: diffState, + impacted: impacted, }; if (parentMap[n.id]) { data.parent = parentMap[n.id]; } - return { data: data }; + const classes = []; + if (!isClean && diffState === 'unchanged' && !impacted) { + classes.push('muted-bg'); + } + return { data: data, classes: classes.join(' ') }; }); // Phantom nodes for removed nodes (existed in HEAD but not current tree) @@ -183,18 +189,24 @@ function buildCyElements() { .filter(function (e) { return !containsIds.has(e.id); }) .map(function (e) { const key = e.from + '::' + e.to + '::' + e.kind; + const diffState = addedEdgeKeys.has(key) + ? 'added' + : removedEdgeKeys.has(key) + ? 'removed' + : 'unchanged'; + const classes = []; + if (!isClean && diffState === 'unchanged') { + classes.push('muted-bg'); + } return { data: { id: e.id, source: e.from, target: e.to, kind: e.kind ?? '', - diffState: addedEdgeKeys.has(key) - ? 'added' - : removedEdgeKeys.has(key) - ? 'removed' - : 'unchanged', + diffState: diffState, }, + classes: classes.join(' '), }; });