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 が生成したコード変更の検証に特に役立ちます。
[](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml)
+[](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 モード
-
+
### File Detail モード
-
+
### Calls モード
-
+
## インストール
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가 생성한 코드 변경을 검증할 때 특히 유용합니다.
[](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml)
+[](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 모드
-
+
### File Detail 모드
-
+
### Calls 모드
-
+
## 설치
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.
[](https://github.com/adgk2349/FlowMap-AI_Code_Hallucination_Guard_for_Swift/actions/workflows/ci.yml)
+[](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
-
+
### File detail mode
-
+
### Calls mode
-
+
## 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.
+
+
+
+## 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.
+
+
+
+## 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.
+
+
+
+## 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 @@
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(' '),
};
});