chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.vsix
|
||||
@@ -0,0 +1,10 @@
|
||||
src/**
|
||||
test/**
|
||||
node_modules/**
|
||||
.vscode/**
|
||||
*.ts
|
||||
!*.d.ts
|
||||
.gitignore
|
||||
tsconfig.json
|
||||
esbuild.mjs
|
||||
**/*.map
|
||||
@@ -0,0 +1,47 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.2 - 2026-04-11
|
||||
|
||||
### Fixed
|
||||
- Compatible with VS Code 1.115 / Electron 39 by updating the extension SQLite dependency stack.
|
||||
|
||||
## 0.2.1 — 2026-04-08
|
||||
|
||||
### Fixed
|
||||
- Compatible with Python backend schema v6 (no extension-side schema changes in this release)
|
||||
|
||||
## 0.2.0 — 2026-03-20
|
||||
|
||||
### Added
|
||||
- **Query Graph** command with 8 query patterns (callers_of, callees_of, imports_of, etc.)
|
||||
- **Find Callees** command to trace all functions called by a target
|
||||
- **Find Large Functions** command to identify oversized functions/classes
|
||||
- **Compute Embeddings** command to generate vector embeddings
|
||||
- **Watch Mode** command for continuous graph updates
|
||||
- Cursor-aware resolution for blast radius and navigation commands
|
||||
- Fuzzy fallback search when exact node matches fail
|
||||
- SCM decorations for git-aware file status
|
||||
|
||||
### Changed
|
||||
- Updated README with complete command table (13 commands)
|
||||
- All 13 commands now documented
|
||||
|
||||
## 0.1.1 — 2026-03-17
|
||||
|
||||
### Fixed
|
||||
- CLI path setting scoped to `machine` level (security fix)
|
||||
- Secure nonce generation using `crypto.randomBytes()`
|
||||
|
||||
## 0.1.0 — 2026-03-17
|
||||
|
||||
Initial release.
|
||||
|
||||
- Code Graph tree view with file, class, function, type, and test nodes
|
||||
- Interactive D3.js graph visualisation in a webview panel
|
||||
- Blast radius analysis from cursor position
|
||||
- Find callers and find tests commands
|
||||
- Search across all graph nodes
|
||||
- Review changes with git-aware impact analysis
|
||||
- Auto-update graph on file save
|
||||
- CLI auto-detection and guided installation
|
||||
- Getting Started walkthrough
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tirth Kanani
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Code Review Graph for VS Code
|
||||
|
||||
Visualize code dependencies, blast radius, and review context from your code graph -- directly in VS Code.
|
||||
|
||||
## Features
|
||||
|
||||
- **Code Graph Explorer** -- Browse files, classes, functions, and their relationships in a tree view
|
||||
- **Blast Radius** -- See which files and symbols are impacted when you change code
|
||||
- **Review Changes** -- Automatically detect git changes and show their blast radius
|
||||
- **Find Callers / Callees** -- Trace all callers or callees of any function
|
||||
- **Find Tests** -- Locate tests for any symbol
|
||||
- **Query Graph** -- Run semantic queries (callers, callees, imports, inheritance, tests) with 8 patterns
|
||||
- **Find Large Functions** -- Identify functions or classes exceeding a line-count threshold
|
||||
- **Interactive Graph** -- Force-directed D3.js visualization of your code dependencies
|
||||
- **Live Search** -- Fuzzy search across your entire code graph with instant results
|
||||
- **Compute Embeddings** -- Generate vector embeddings for semantic search
|
||||
- **Watch Mode** -- Continuous graph updates as you work
|
||||
- **Auto-Update** -- Graph rebuilds in the background when you save files
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install the Extension
|
||||
|
||||
Install **Code Review Graph** from the VS Code Marketplace.
|
||||
|
||||
### 2. Install the Backend
|
||||
|
||||
The extension requires the `code-review-graph` Python CLI to parse your codebase.
|
||||
|
||||
```bash
|
||||
# Recommended
|
||||
uv pip install code-review-graph
|
||||
|
||||
# Alternatives
|
||||
pipx install code-review-graph
|
||||
pip install code-review-graph
|
||||
```
|
||||
|
||||
Requires Python 3.10+.
|
||||
|
||||
### 3. Build Your Graph
|
||||
|
||||
Open the Command Palette (`Ctrl+Shift+P`) and run **Code Graph: Build Graph**.
|
||||
|
||||
The graph database is stored locally at `.code-review-graph/graph.db` and updates automatically on file save.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `Code Graph: Build Graph` | Parse the codebase and create the graph database |
|
||||
| `Code Graph: Update Graph` | Incrementally update the graph |
|
||||
| `Code Graph: Show Blast Radius` | Show the blast radius for a symbol |
|
||||
| `Code Graph: Review Changes` | Analyze git changes and show impacted files |
|
||||
| `Code Graph: Find Callers` | Find all callers of a function |
|
||||
| `Code Graph: Find Callees` | Find all functions called by a target |
|
||||
| `Code Graph: Find Tests` | Find tests for a symbol |
|
||||
| `Code Graph: Find Large Functions` | Find functions/classes exceeding a size threshold |
|
||||
| `Code Graph: Query Graph` | Run semantic queries (8 patterns: callers_of, callees_of, etc.) |
|
||||
| `Code Graph: Search` | Search the code graph |
|
||||
| `Code Graph: Show Graph` | Open the interactive graph visualization |
|
||||
| `Code Graph: Compute Embeddings` | Generate vector embeddings for semantic search |
|
||||
| `Code Graph: Watch Mode` | Run graph in watch mode for continuous updates |
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---|---|---|
|
||||
| `codeReviewGraph.cliPath` | `""` | Path to the CLI binary. Leave empty to use the bundled version or one found on `PATH`. |
|
||||
| `codeReviewGraph.autoUpdate` | `true` | Auto-update the graph on file save. |
|
||||
| `codeReviewGraph.blastRadiusDepth` | `2` | Max traversal depth for blast radius (1--10). |
|
||||
| `codeReviewGraph.graphTheme` | `"auto"` | Graph color theme: `auto`, `light`, or `dark`. |
|
||||
| `codeReviewGraph.graph.maxNodes` | `500` | Max nodes in the graph visualization (10--5000). |
|
||||
| `codeReviewGraph.graph.defaultEdges` | All except CONTAINS | Edge types shown by default. |
|
||||
| `codeReviewGraph.treeView.showFiles` | `true` | Show file nodes in the tree view. |
|
||||
| `codeReviewGraph.treeView.showClasses` | `true` | Show class nodes in the tree view. |
|
||||
| `codeReviewGraph.treeView.showFunctions` | `true` | Show function nodes in the tree view. |
|
||||
| `codeReviewGraph.treeView.showTypes` | `true` | Show type nodes in the tree view. |
|
||||
| `codeReviewGraph.treeView.showTests` | `true` | Show test nodes in the tree view. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- VS Code 1.85+
|
||||
- Python 3.10+ (for the backend CLI)
|
||||
- A workspace with source code to analyze
|
||||
|
||||
## Links
|
||||
|
||||
- [Main Repository](https://github.com/tirth8205/code-review-graph)
|
||||
- [Report an Issue](https://github.com/tirth8205/code-review-graph/issues)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as esbuild from "esbuild";
|
||||
|
||||
const isWatch = process.argv.includes("--watch");
|
||||
const isProduction = process.argv.includes("--production");
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const extensionConfig = {
|
||||
entryPoints: ["src/extension.ts"],
|
||||
bundle: true,
|
||||
outfile: "dist/extension.js",
|
||||
external: ["vscode", "better-sqlite3"],
|
||||
format: "cjs",
|
||||
platform: "node",
|
||||
target: "node18",
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction,
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
/** @type {esbuild.BuildOptions} */
|
||||
const webviewConfig = {
|
||||
entryPoints: ["src/webview/graph.ts"],
|
||||
bundle: true,
|
||||
outfile: "dist/webview/graph.js",
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
sourcemap: !isProduction,
|
||||
minify: isProduction,
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (isWatch) {
|
||||
const extensionCtx = await esbuild.context(extensionConfig);
|
||||
const webviewCtx = await esbuild.context(webviewConfig);
|
||||
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
|
||||
console.log("[watch] Build started. Watching for changes...");
|
||||
} else {
|
||||
await Promise.all([
|
||||
esbuild.build(extensionConfig),
|
||||
esbuild.build(webviewConfig),
|
||||
]);
|
||||
console.log("Build complete.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- edges -->
|
||||
<line x1="6" y1="6" x2="18" y2="6" />
|
||||
<line x1="6" y1="6" x2="6" y2="18" />
|
||||
<line x1="18" y1="6" x2="12" y2="18" />
|
||||
<line x1="6" y1="18" x2="12" y2="18" />
|
||||
<!-- nodes -->
|
||||
<circle cx="6" cy="6" r="2.5" fill="currentColor" />
|
||||
<circle cx="18" cy="6" r="2.5" fill="currentColor" />
|
||||
<circle cx="6" cy="18" r="2.5" fill="currentColor" />
|
||||
<circle cx="12" cy="18" r="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 611 B |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,7 @@
|
||||
## Build Your Graph
|
||||
|
||||
Click the button above to parse your codebase and create a knowledge graph.
|
||||
|
||||
This usually takes ~10 seconds for a 500-file project. The graph will be stored locally in `.code-review-graph/graph.db`.
|
||||
|
||||
After the initial build, the graph updates automatically when you save files.
|
||||
@@ -0,0 +1,10 @@
|
||||
## Explore Your Code
|
||||
|
||||
Open the **Code Graph** panel in the activity bar to browse your codebase structure.
|
||||
|
||||
**Try these commands** (Ctrl+Shift+P):
|
||||
- **Code Graph: Show Blast Radius** -- See what's impacted when you change code
|
||||
- **Code Graph: Find Callers** -- Find all callers of a function
|
||||
- **Code Graph: Find Tests** -- Find tests for any function
|
||||
- **Code Graph: Search** -- Search across your entire code graph
|
||||
- **Code Graph: Show Graph** -- Open the interactive graph visualization
|
||||
@@ -0,0 +1,17 @@
|
||||
## Install the Backend
|
||||
|
||||
code-review-graph needs a Python backend to parse your codebase.
|
||||
|
||||
**Requirements:** Python 3.10+
|
||||
|
||||
**Recommended:** Install via [uv](https://docs.astral.sh/uv/):
|
||||
```bash
|
||||
uv pip install code-review-graph
|
||||
```
|
||||
|
||||
**Alternatives:**
|
||||
```bash
|
||||
pipx install code-review-graph
|
||||
# or
|
||||
pip install code-review-graph
|
||||
```
|
||||
+4022
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"name": "code-review-graph",
|
||||
"displayName": "Code Review Graph",
|
||||
"description": "Visualize code dependencies, blast radius, and review context from your code-review-graph database directly in VS Code.",
|
||||
"version": "0.2.2",
|
||||
"publisher": "tirth8205",
|
||||
"license": "MIT",
|
||||
"icon": "media/icons/icon.png",
|
||||
"keywords": [
|
||||
"code analysis",
|
||||
"graph",
|
||||
"visualization",
|
||||
"dependencies",
|
||||
"blast radius",
|
||||
"code review",
|
||||
"tree-sitter"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tirth8205/code-review-graph"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"extensionKind": [
|
||||
"workspace"
|
||||
],
|
||||
"categories": [
|
||||
"Visualization",
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [
|
||||
"workspaceContains:.code-review-graph/graph.db",
|
||||
"onCommand:codeReviewGraph.*",
|
||||
"onView:codeReviewGraph.*"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "codeReviewGraph",
|
||||
"title": "Code Graph",
|
||||
"icon": "media/icons/graph.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"codeReviewGraph": [
|
||||
{
|
||||
"id": "codeReviewGraph.codeGraph",
|
||||
"name": "Code Graph"
|
||||
},
|
||||
{
|
||||
"id": "codeReviewGraph.blastRadius",
|
||||
"name": "Blast Radius"
|
||||
},
|
||||
{
|
||||
"id": "codeReviewGraph.stats",
|
||||
"name": "Stats"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "codeReviewGraph.showBlastRadius",
|
||||
"title": "Code Graph: Show Blast Radius",
|
||||
"icon": "$(pulse)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findCallers",
|
||||
"title": "Code Graph: Find Callers",
|
||||
"icon": "$(references)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findTests",
|
||||
"title": "Code Graph: Find Tests",
|
||||
"icon": "$(beaker)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.queryGraph",
|
||||
"title": "Code Graph: Query Graph",
|
||||
"icon": "$(symbol-keyword)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findCallees",
|
||||
"title": "Code Graph: Find Callees",
|
||||
"icon": "$(call-outgoing)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findLargeFunctions",
|
||||
"title": "Code Graph: Find Large Functions",
|
||||
"icon": "$(warning)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.showGraph",
|
||||
"title": "Code Graph: Show Graph",
|
||||
"icon": "$(type-hierarchy)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.search",
|
||||
"title": "Code Graph: Search",
|
||||
"icon": "$(search)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.reviewChanges",
|
||||
"title": "Code Graph: Review Changes",
|
||||
"icon": "$(git-compare)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.embedGraph",
|
||||
"title": "Code Graph: Compute Embeddings",
|
||||
"icon": "$(sparkle)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.watchGraph",
|
||||
"title": "Code Graph: Watch Mode",
|
||||
"icon": "$(eye)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.buildGraph",
|
||||
"title": "Code Graph: Build Graph",
|
||||
"icon": "$(database)"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.updateGraph",
|
||||
"title": "Code Graph: Update Graph",
|
||||
"icon": "$(sync)"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"scm/title": [
|
||||
{
|
||||
"command": "codeReviewGraph.reviewChanges",
|
||||
"group": "navigation",
|
||||
"when": "scmProvider == git"
|
||||
}
|
||||
],
|
||||
"view/item/context": [
|
||||
{
|
||||
"command": "codeReviewGraph.showBlastRadius",
|
||||
"when": "viewItem =~ /node-/",
|
||||
"group": "codeGraph@1"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findCallers",
|
||||
"when": "viewItem =~ /node-/",
|
||||
"group": "codeGraph@2"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findCallees",
|
||||
"when": "viewItem =~ /node-/",
|
||||
"group": "codeGraph@3"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.findTests",
|
||||
"when": "viewItem =~ /node-/",
|
||||
"group": "codeGraph@4"
|
||||
},
|
||||
{
|
||||
"command": "codeReviewGraph.showGraph",
|
||||
"when": "viewItem =~ /node-/",
|
||||
"group": "codeGraph@5"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Code Review Graph",
|
||||
"properties": {
|
||||
"codeReviewGraph.cliPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"scope": "machine",
|
||||
"description": "Path to the code-review-graph CLI binary. Leave empty to use the bundled version or the one found on PATH."
|
||||
},
|
||||
"codeReviewGraph.autoUpdate": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically update the graph database when files are saved."
|
||||
},
|
||||
"codeReviewGraph.blastRadiusDepth": {
|
||||
"type": "number",
|
||||
"default": 2,
|
||||
"minimum": 1,
|
||||
"maximum": 10,
|
||||
"description": "Maximum depth for blast radius traversal."
|
||||
},
|
||||
"codeReviewGraph.graphTheme": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"enum": [
|
||||
"auto",
|
||||
"light",
|
||||
"dark"
|
||||
],
|
||||
"description": "Color theme for the graph visualization. 'auto' follows the VS Code theme."
|
||||
},
|
||||
"codeReviewGraph.treeView.showFunctions": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show function nodes in the tree view."
|
||||
},
|
||||
"codeReviewGraph.treeView.showClasses": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show class nodes in the tree view."
|
||||
},
|
||||
"codeReviewGraph.treeView.showFiles": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show file nodes in the tree view."
|
||||
},
|
||||
"codeReviewGraph.treeView.showTypes": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show type nodes in the tree view."
|
||||
},
|
||||
"codeReviewGraph.treeView.showTests": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show test nodes in the tree view."
|
||||
},
|
||||
"codeReviewGraph.graph.defaultEdges": {
|
||||
"type": "array",
|
||||
"default": [
|
||||
"CALLS",
|
||||
"IMPORTS_FROM",
|
||||
"INHERITS",
|
||||
"IMPLEMENTS",
|
||||
"TESTED_BY",
|
||||
"DEPENDS_ON"
|
||||
],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"CALLS",
|
||||
"IMPORTS_FROM",
|
||||
"INHERITS",
|
||||
"IMPLEMENTS",
|
||||
"CONTAINS",
|
||||
"TESTED_BY",
|
||||
"DEPENDS_ON"
|
||||
]
|
||||
},
|
||||
"description": "Edge types shown by default in the graph visualization."
|
||||
},
|
||||
"codeReviewGraph.graph.maxNodes": {
|
||||
"type": "number",
|
||||
"default": 500,
|
||||
"minimum": 10,
|
||||
"maximum": 5000,
|
||||
"description": "Maximum number of nodes to display in the graph visualization."
|
||||
}
|
||||
}
|
||||
},
|
||||
"walkthroughs": [
|
||||
{
|
||||
"id": "codeReviewGraph.welcome",
|
||||
"title": "Get Started with Code Review Graph",
|
||||
"description": "Build a code graph and explore your codebase visually.",
|
||||
"steps": [
|
||||
{
|
||||
"id": "codeReviewGraph.welcome.install",
|
||||
"title": "Install the CLI",
|
||||
"description": "Install the code-review-graph CLI tool to build your graph database.\n\n[Install CLI](command:codeReviewGraph.walkthrough.install)",
|
||||
"media": {
|
||||
"markdown": "media/walkthrough/install.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "codeReviewGraph.welcome.build",
|
||||
"title": "Build Your Graph",
|
||||
"description": "Run the build command to analyze your codebase and create a graph database.\n\n[Build Graph](command:codeReviewGraph.buildGraph)",
|
||||
"media": {
|
||||
"markdown": "media/walkthrough/build.md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "codeReviewGraph.welcome.explore",
|
||||
"title": "Explore the Graph",
|
||||
"description": "Open the Code Graph panel in the activity bar to explore your code's structure, blast radius, and review context.\n\n[Open Code Graph](command:codeReviewGraph.walkthrough.explore)",
|
||||
"media": {
|
||||
"markdown": "media/walkthrough/explore.md"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"compile": "node esbuild.mjs",
|
||||
"watch": "node esbuild.mjs --watch",
|
||||
"package": "vsce package",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node --experimental-vm-modules node_modules/@vscode/test-electron/out/runTest.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@vscode/test-electron": "^2.3.8",
|
||||
"@vscode/vsce": "^2.22.0",
|
||||
"esbuild": "^0.20.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"d3": "^7.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const CLI_TIMEOUT_MS = 60_000;
|
||||
const INSTALL_TIMEOUT_MS = 120_000;
|
||||
|
||||
export interface CliResult {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class CliWrapper {
|
||||
private readonly cliPath: string;
|
||||
|
||||
constructor() {
|
||||
this.cliPath = this.getCliPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the CLI binary is reachable.
|
||||
*/
|
||||
async isInstalled(): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(this.cliPath, ['--version'], { timeout: 10_000 });
|
||||
return true;
|
||||
} catch (err: unknown) {
|
||||
if (isEnoent(err)) {
|
||||
return false;
|
||||
}
|
||||
// Non-zero exit or other transient error — treat as not installed.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the CLI version string, or undefined when the CLI is not available.
|
||||
*/
|
||||
async getVersion(): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(this.cliPath, ['--version'], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build (or fully rebuild) the graph database for a workspace.
|
||||
*/
|
||||
async buildGraph(
|
||||
workspaceRoot: string,
|
||||
options?: { fullRebuild?: boolean },
|
||||
): Promise<CliResult> {
|
||||
const args = ['build'];
|
||||
if (options?.fullRebuild) {
|
||||
args.push('--full');
|
||||
}
|
||||
|
||||
return vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Code Review Graph: Building graph\u2026',
|
||||
cancellable: false,
|
||||
},
|
||||
() => this.exec(args, workspaceRoot),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally update the graph database for a workspace.
|
||||
*/
|
||||
async updateGraph(workspaceRoot: string): Promise<CliResult> {
|
||||
return this.exec(['update'], workspaceRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the watch daemon for continuous file monitoring.
|
||||
*/
|
||||
async watchGraph(workspaceRoot: string): Promise<CliResult> {
|
||||
return this.exec(['watch'], workspaceRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute embeddings for all graph nodes.
|
||||
*/
|
||||
async embedGraph(workspaceRoot: string): Promise<CliResult> {
|
||||
return vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Code Review Graph: Computing embeddings\u2026',
|
||||
cancellable: false,
|
||||
},
|
||||
() => this.exec(['embed'], workspaceRoot),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which Python package installer is available on the system.
|
||||
* Checks in preference order: uv, pipx, pip3.
|
||||
*/
|
||||
async detectPythonInstaller(): Promise<'uv' | 'pipx' | 'pip' | null> {
|
||||
const candidates: Array<{ bin: string; result: 'uv' | 'pipx' | 'pip' }> = [
|
||||
{ bin: 'uv', result: 'uv' },
|
||||
{ bin: 'pipx', result: 'pipx' },
|
||||
{ bin: 'pip3', result: 'pip' },
|
||||
];
|
||||
|
||||
for (const { bin, result } of candidates) {
|
||||
try {
|
||||
await execFileAsync(bin, ['--version'], { timeout: 10_000 });
|
||||
return result;
|
||||
} catch {
|
||||
// Not found or errored — try next.
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the `code-review-graph` package using the specified installer.
|
||||
*/
|
||||
async installBackend(installer: 'uv' | 'pipx' | 'pip'): Promise<CliResult> {
|
||||
const commandMap: Record<typeof installer, { bin: string; args: string[] }> = {
|
||||
uv: { bin: 'uv', args: ['pip', 'install', 'code-review-graph'] },
|
||||
pipx: { bin: 'pipx', args: ['install', 'code-review-graph'] },
|
||||
pip: { bin: 'pip3', args: ['install', 'code-review-graph'] },
|
||||
};
|
||||
|
||||
const { bin, args } = commandMap[installer];
|
||||
|
||||
return vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Code Review Graph: Installing via ${installer}\u2026`,
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(bin, args, {
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
});
|
||||
return { success: true, stdout, stderr };
|
||||
} catch (err: unknown) {
|
||||
return toCliResult(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ private
|
||||
|
||||
private getCliPath(): string {
|
||||
const configured = vscode.workspace
|
||||
.getConfiguration('codeReviewGraph')
|
||||
.get<string>('cliPath', '');
|
||||
return configured || 'code-review-graph';
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the CLI with the given arguments inside `cwd`.
|
||||
*/
|
||||
private async exec(args: string[], cwd?: string): Promise<CliResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(this.cliPath, args, {
|
||||
cwd,
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
});
|
||||
return { success: true, stdout, stderr };
|
||||
} catch (err: unknown) {
|
||||
return toCliResult(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- helpers
|
||||
|
||||
interface ExecError {
|
||||
code?: string | number;
|
||||
killed?: boolean;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function isEnoent(err: unknown): boolean {
|
||||
return (err as ExecError)?.code === 'ENOENT';
|
||||
}
|
||||
|
||||
function toCliResult(err: unknown): CliResult {
|
||||
const e = err as ExecError;
|
||||
|
||||
if (isEnoent(err)) {
|
||||
return {
|
||||
success: false,
|
||||
stdout: '',
|
||||
stderr: 'CLI binary not found. Is code-review-graph installed?',
|
||||
};
|
||||
}
|
||||
|
||||
if (e.killed) {
|
||||
return {
|
||||
success: false,
|
||||
stdout: e.stdout ?? '',
|
||||
stderr: 'Command timed out.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
stdout: e.stdout ?? '',
|
||||
stderr: e.stderr ?? e.message ?? 'Unknown error',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,595 @@
|
||||
/**
|
||||
* Read-only SQLite reader for the code-review-graph database.
|
||||
*
|
||||
* Opens the database created by the Python backend and provides typed
|
||||
* query methods. All writes are performed by the Python side; this
|
||||
* module never mutates the database.
|
||||
*
|
||||
* Uses `better-sqlite3` with prepared statements for performance.
|
||||
*/
|
||||
|
||||
import type BetterSqlite3 from 'better-sqlite3';
|
||||
|
||||
type DatabaseType = BetterSqlite3.Database;
|
||||
|
||||
// Load better-sqlite3 with graceful error handling for ABI mismatches.
|
||||
// On WSL or mismatched Node.js versions, the native module may fail to load.
|
||||
// better-sqlite3 uses `export =` so we import the value via require() and
|
||||
// type it as the DatabaseConstructor.
|
||||
let Database: typeof import('better-sqlite3');
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
Database = require('better-sqlite3');
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const isAbiMismatch = msg.includes('NODE_MODULE_VERSION')
|
||||
|| msg.includes('was compiled against')
|
||||
|| msg.includes('not a valid Win32');
|
||||
if (isAbiMismatch) {
|
||||
console.error(
|
||||
'[code-review-graph] better-sqlite3 ABI mismatch. '
|
||||
+ 'Your VS Code uses a different Node.js version than the one '
|
||||
+ 'this extension was built for. '
|
||||
+ 'Try: cd ~/.vscode/extensions/code-review-graph-* && npm rebuild better-sqlite3'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type NodeKind = 'File' | 'Class' | 'Function' | 'Type' | 'Test';
|
||||
|
||||
export type EdgeKind =
|
||||
| 'CALLS'
|
||||
| 'IMPORTS_FROM'
|
||||
| 'INHERITS'
|
||||
| 'IMPLEMENTS'
|
||||
| 'CONTAINS'
|
||||
| 'TESTED_BY'
|
||||
| 'DEPENDS_ON';
|
||||
|
||||
export interface GraphNode {
|
||||
id: number;
|
||||
kind: NodeKind;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
filePath: string;
|
||||
lineStart: number | null;
|
||||
lineEnd: number | null;
|
||||
language: string | null;
|
||||
parentName: string | null;
|
||||
params: string | null;
|
||||
returnType: string | null;
|
||||
modifiers: string | null;
|
||||
isTest: boolean;
|
||||
fileHash: string | null;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
id: number;
|
||||
kind: EdgeKind;
|
||||
sourceQualified: string;
|
||||
targetQualified: string;
|
||||
filePath: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface GraphStats {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
nodesByKind: Record<string, number>;
|
||||
edgesByKind: Record<string, number>;
|
||||
languages: string[];
|
||||
filesCount: number;
|
||||
lastUpdated: string | null;
|
||||
embeddingsCount: number;
|
||||
}
|
||||
|
||||
export interface ImpactRadius {
|
||||
changedNodes: GraphNode[];
|
||||
impactedNodes: GraphNode[];
|
||||
impactedFiles: string[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw row types returned by better-sqlite3
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NodeRow {
|
||||
id: number;
|
||||
kind: string;
|
||||
name: string;
|
||||
qualified_name: string;
|
||||
file_path: string;
|
||||
line_start: number | null;
|
||||
line_end: number | null;
|
||||
language: string | null;
|
||||
parent_name: string | null;
|
||||
params: string | null;
|
||||
return_type: string | null;
|
||||
modifiers: string | null;
|
||||
is_test: number;
|
||||
file_hash: string | null;
|
||||
extra: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface EdgeRow {
|
||||
id: number;
|
||||
kind: string;
|
||||
source_qualified: string;
|
||||
target_qualified: string;
|
||||
file_path: string;
|
||||
line: number;
|
||||
extra: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface CountRow {
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
interface KindCountRow {
|
||||
kind: string;
|
||||
cnt: number;
|
||||
}
|
||||
|
||||
interface LanguageRow {
|
||||
language: string;
|
||||
}
|
||||
|
||||
interface FilePathRow {
|
||||
file_path: string;
|
||||
}
|
||||
|
||||
interface MetadataRow {
|
||||
value: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SqliteReader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_OPEN_RETRIES = 3;
|
||||
const RETRY_BACKOFF_MS = 100;
|
||||
|
||||
export class SqliteReader {
|
||||
private db: DatabaseType | null = null;
|
||||
|
||||
/**
|
||||
* Create a SqliteReader with retry logic that does not block the event loop.
|
||||
* Prefer this over the constructor when calling from async code.
|
||||
*/
|
||||
static async create(dbPath: string): Promise<SqliteReader> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < MAX_OPEN_RETRIES; attempt++) {
|
||||
try {
|
||||
return new SqliteReader(dbPath);
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (attempt < MAX_OPEN_RETRIES - 1) {
|
||||
await new Promise(resolve =>
|
||||
setTimeout(resolve, RETRY_BACKOFF_MS * (attempt + 1))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
constructor(dbPath: string) {
|
||||
this.db = new Database(dbPath, { readonly: true });
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('busy_timeout = 5000');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the database schema is compatible with this extension version.
|
||||
* Returns a warning message if incompatible, or undefined if OK.
|
||||
*/
|
||||
checkSchemaCompatibility(): string | undefined {
|
||||
if (!this.db) { return 'Database is not open'; }
|
||||
try {
|
||||
// Check that required tables exist
|
||||
const tables = this.db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
.all() as Array<{ name: string }>;
|
||||
const tableNames = new Set(tables.map((t) => t.name));
|
||||
|
||||
if (!tableNames.has('nodes') || !tableNames.has('edges')) {
|
||||
return 'Database is missing required tables (nodes/edges). Rebuild required.';
|
||||
}
|
||||
|
||||
// Check for schema_version in metadata if it exists
|
||||
if (tableNames.has('metadata')) {
|
||||
const row = this.db
|
||||
.prepare("SELECT value FROM metadata WHERE key = 'schema_version'")
|
||||
.get() as { value: string } | undefined;
|
||||
if (row) {
|
||||
const version = parseInt(row.value, 10);
|
||||
// Must match LATEST_VERSION in code_review_graph/migrations.py
|
||||
const SUPPORTED_SCHEMA_VERSION = 9;
|
||||
if (!isNaN(version) && version > SUPPORTED_SCHEMA_VERSION) {
|
||||
return `Database was created with a newer version (schema v${version}). Update the extension.`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return 'Could not verify database schema.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the database connection. Safe to call multiple times. */
|
||||
close(): void {
|
||||
if (this.db) {
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the database is open and contains the nodes table. */
|
||||
isValid(): boolean {
|
||||
if (!this.db) { return false; }
|
||||
try {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='nodes'"
|
||||
)
|
||||
.get() as { name: string } | undefined;
|
||||
return row !== undefined;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Node queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** All file paths (kind='File'), ordered by file_path. */
|
||||
getAllFiles(): string[] {
|
||||
const rows = this._db()
|
||||
.prepare(
|
||||
"SELECT DISTINCT file_path FROM nodes WHERE kind = 'File' ORDER BY file_path"
|
||||
)
|
||||
.all() as FilePathRow[];
|
||||
return rows.map((r) => r.file_path);
|
||||
}
|
||||
|
||||
/** All nodes in a file, ordered by line_start. */
|
||||
getNodesByFile(filePath: string): GraphNode[] {
|
||||
const rows = this._db()
|
||||
.prepare(
|
||||
'SELECT * FROM nodes WHERE file_path = ? ORDER BY line_start'
|
||||
)
|
||||
.all(filePath) as NodeRow[];
|
||||
return rows.map((r) => this._rowToNode(r));
|
||||
}
|
||||
|
||||
/** Single node lookup by qualified_name. */
|
||||
getNode(qualifiedName: string): GraphNode | undefined {
|
||||
const row = this._db()
|
||||
.prepare('SELECT * FROM nodes WHERE qualified_name = ?')
|
||||
.get(qualifiedName) as NodeRow | undefined;
|
||||
return row ? this._rowToNode(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Innermost node at a cursor position.
|
||||
*
|
||||
* Returns the node whose line range contains `line` with the smallest
|
||||
* span (i.e. the most specific / innermost enclosing node).
|
||||
*/
|
||||
getNodeAtCursor(filePath: string, line: number): GraphNode | undefined {
|
||||
const row = this._db()
|
||||
.prepare(
|
||||
`SELECT * FROM nodes
|
||||
WHERE file_path = ? AND line_start <= ? AND line_end >= ?
|
||||
ORDER BY (line_end - line_start) ASC
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(filePath, line, line) as NodeRow | undefined;
|
||||
return row ? this._rowToNode(row) : undefined;
|
||||
}
|
||||
|
||||
/** LIKE search on name and qualified_name. */
|
||||
searchNodes(query: string, limit: number = 20): GraphNode[] {
|
||||
const pattern = `%${query}%`;
|
||||
const rows = this._db()
|
||||
.prepare(
|
||||
'SELECT * FROM nodes WHERE name LIKE ? OR qualified_name LIKE ? LIMIT ?'
|
||||
)
|
||||
.all(pattern, pattern, limit) as NodeRow[];
|
||||
return rows.map((r) => this._rowToNode(r));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Edge queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Outgoing edges from a node. */
|
||||
getEdgesBySource(qualifiedName: string): GraphEdge[] {
|
||||
const rows = this._db()
|
||||
.prepare('SELECT * FROM edges WHERE source_qualified = ?')
|
||||
.all(qualifiedName) as EdgeRow[];
|
||||
return rows.map((r) => this._rowToEdge(r));
|
||||
}
|
||||
|
||||
/** Incoming edges to a node. */
|
||||
getEdgesByTarget(qualifiedName: string): GraphEdge[] {
|
||||
const rows = this._db()
|
||||
.prepare('SELECT * FROM edges WHERE target_qualified = ?')
|
||||
.all(qualifiedName) as EdgeRow[];
|
||||
return rows.map((r) => this._rowToEdge(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Edges where both source and target are in the given set.
|
||||
*
|
||||
* Uses a parameterised IN clause -- safe for arbitrary set sizes
|
||||
* (better-sqlite3 handles large parameter lists efficiently).
|
||||
*/
|
||||
getEdgesAmong(qualifiedNames: Set<string>): GraphEdge[] {
|
||||
if (qualifiedNames.size === 0) { return []; }
|
||||
const qns = [...qualifiedNames];
|
||||
const placeholders = qns.map(() => '?').join(',');
|
||||
const rows = this._db()
|
||||
.prepare(
|
||||
`SELECT * FROM edges
|
||||
WHERE source_qualified IN (${placeholders})
|
||||
AND target_qualified IN (${placeholders})`
|
||||
)
|
||||
.all(...qns, ...qns) as EdgeRow[];
|
||||
return rows.map((r) => this._rowToEdge(r));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Statistics & metadata
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Aggregate counts, languages, last_updated, and embeddings count. */
|
||||
getStats(): GraphStats {
|
||||
const db = this._db();
|
||||
|
||||
const totalNodes = (
|
||||
db.prepare('SELECT COUNT(*) AS cnt FROM nodes').get() as CountRow
|
||||
).cnt;
|
||||
|
||||
const totalEdges = (
|
||||
db.prepare('SELECT COUNT(*) AS cnt FROM edges').get() as CountRow
|
||||
).cnt;
|
||||
|
||||
const nodesByKind: Record<string, number> = {};
|
||||
const nkRows = db
|
||||
.prepare('SELECT kind, COUNT(*) AS cnt FROM nodes GROUP BY kind')
|
||||
.all() as KindCountRow[];
|
||||
for (const r of nkRows) { nodesByKind[r.kind] = r.cnt; }
|
||||
|
||||
const edgesByKind: Record<string, number> = {};
|
||||
const ekRows = db
|
||||
.prepare('SELECT kind, COUNT(*) AS cnt FROM edges GROUP BY kind')
|
||||
.all() as KindCountRow[];
|
||||
for (const r of ekRows) { edgesByKind[r.kind] = r.cnt; }
|
||||
|
||||
const languages = (
|
||||
db
|
||||
.prepare(
|
||||
"SELECT DISTINCT language FROM nodes WHERE language IS NOT NULL AND language != ''"
|
||||
)
|
||||
.all() as LanguageRow[]
|
||||
).map((r) => r.language);
|
||||
|
||||
const filesCount = (
|
||||
db
|
||||
.prepare("SELECT COUNT(*) AS cnt FROM nodes WHERE kind = 'File'")
|
||||
.get() as CountRow
|
||||
).cnt;
|
||||
|
||||
const lastUpdated = this.getMetadata('last_updated') ?? null;
|
||||
|
||||
// Embeddings count -- table may not exist
|
||||
let embeddingsCount = 0;
|
||||
try {
|
||||
embeddingsCount = (
|
||||
db.prepare('SELECT COUNT(*) AS cnt FROM embeddings').get() as CountRow
|
||||
).cnt;
|
||||
} catch {
|
||||
// embeddings table does not exist -- that is fine
|
||||
}
|
||||
|
||||
return {
|
||||
totalNodes,
|
||||
totalEdges,
|
||||
nodesByKind,
|
||||
edgesByKind,
|
||||
languages,
|
||||
filesCount,
|
||||
lastUpdated,
|
||||
embeddingsCount,
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a single key from the metadata table. */
|
||||
getMetadata(key: string): string | undefined {
|
||||
const row = this._db()
|
||||
.prepare('SELECT value FROM metadata WHERE key = ?')
|
||||
.get(key) as MetadataRow | undefined;
|
||||
return row?.value;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Impact radius (BFS traversal)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* BFS from changed files to find all impacted nodes within `maxDepth` hops.
|
||||
*
|
||||
* Matches the Python `GraphStore.get_impact_radius` logic exactly:
|
||||
* 1. Collect all nodes in `changedFiles` as seed set.
|
||||
* 2. BFS forward (outgoing) AND backward (incoming) edges up to `maxDepth`.
|
||||
* 3. Return impacted nodes (excluding seeds), impacted files, and edges
|
||||
* among all involved nodes.
|
||||
*/
|
||||
getImpactRadius(
|
||||
changedFiles: string[],
|
||||
maxDepth: number = 2,
|
||||
): ImpactRadius {
|
||||
// 1. Seed: all qualified names in changed files
|
||||
const seeds = new Set<string>();
|
||||
for (const f of changedFiles) {
|
||||
for (const node of this.getNodesByFile(f)) {
|
||||
seeds.add(node.qualifiedName);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. BFS outward through all edge types (forward + backward)
|
||||
const visited = new Set<string>();
|
||||
let frontier = new Set(seeds);
|
||||
const impacted = new Set<string>();
|
||||
let depth = 0;
|
||||
|
||||
while (frontier.size > 0 && depth < maxDepth) {
|
||||
const nextFrontier = new Set<string>();
|
||||
for (const qn of frontier) {
|
||||
visited.add(qn);
|
||||
|
||||
// Forward edges (things this node affects)
|
||||
for (const e of this.getEdgesBySource(qn)) {
|
||||
if (!visited.has(e.targetQualified)) {
|
||||
nextFrontier.add(e.targetQualified);
|
||||
impacted.add(e.targetQualified);
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse edges (things that depend on this node)
|
||||
for (const e of this.getEdgesByTarget(qn)) {
|
||||
if (!visited.has(e.sourceQualified)) {
|
||||
nextFrontier.add(e.sourceQualified);
|
||||
impacted.add(e.sourceQualified);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = nextFrontier;
|
||||
depth++;
|
||||
}
|
||||
|
||||
// 3. Resolve to full node info
|
||||
const changedNodes: GraphNode[] = [];
|
||||
for (const qn of seeds) {
|
||||
const node = this.getNode(qn);
|
||||
if (node) { changedNodes.push(node); }
|
||||
}
|
||||
|
||||
const impactedNodes: GraphNode[] = [];
|
||||
for (const qn of impacted) {
|
||||
if (seeds.has(qn)) { continue; }
|
||||
const node = this.getNode(qn);
|
||||
if (node) { impactedNodes.push(node); }
|
||||
}
|
||||
|
||||
const impactedFiles = [
|
||||
...new Set(impactedNodes.map((n) => n.filePath)),
|
||||
];
|
||||
|
||||
// Collect relevant edges among all involved nodes
|
||||
const allQns = new Set([...seeds, ...impacted]);
|
||||
const edges = allQns.size > 0 ? this.getEdgesAmong(allQns) : [];
|
||||
|
||||
return { changedNodes, impactedNodes, impactedFiles, edges };
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Size-based queries
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Find nodes exceeding a line-count threshold.
|
||||
*
|
||||
* Mirrors the Python `GraphStore.get_nodes_by_size()` method.
|
||||
*/
|
||||
getNodesBySize(
|
||||
minLines: number = 50,
|
||||
kind?: string,
|
||||
filePathPattern?: string,
|
||||
limit: number = 50,
|
||||
): Array<GraphNode & { lineCount: number }> {
|
||||
const conditions = ['(line_end - line_start + 1) >= ?'];
|
||||
const params: Array<string | number> = [minLines];
|
||||
|
||||
if (kind) {
|
||||
conditions.push('kind = ?');
|
||||
params.push(kind);
|
||||
}
|
||||
if (filePathPattern) {
|
||||
conditions.push('file_path LIKE ?');
|
||||
params.push(`%${filePathPattern}%`);
|
||||
}
|
||||
|
||||
params.push(limit);
|
||||
const where = conditions.join(' AND ');
|
||||
const rows = this._db()
|
||||
.prepare(
|
||||
`SELECT * FROM nodes WHERE ${where} ` + // nosec
|
||||
'ORDER BY (line_end - line_start + 1) DESC LIMIT ?',
|
||||
)
|
||||
.all(...params) as NodeRow[];
|
||||
|
||||
return rows.map((r) => ({
|
||||
...this._rowToNode(r),
|
||||
lineCount:
|
||||
r.line_start != null && r.line_end != null
|
||||
? r.line_end - r.line_start + 1
|
||||
: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Return the open database handle or throw. */
|
||||
private _db(): DatabaseType {
|
||||
if (!this.db) {
|
||||
throw new Error('SqliteReader: database is closed');
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
/** Convert a raw node row (snake_case) to a typed GraphNode (camelCase). */
|
||||
private _rowToNode(row: NodeRow): GraphNode {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as NodeKind,
|
||||
name: row.name,
|
||||
qualifiedName: row.qualified_name,
|
||||
filePath: row.file_path,
|
||||
lineStart: row.line_start,
|
||||
lineEnd: row.line_end,
|
||||
language: row.language ?? null,
|
||||
parentName: row.parent_name ?? null,
|
||||
params: row.params ?? null,
|
||||
returnType: row.return_type ?? null,
|
||||
modifiers: row.modifiers ?? null,
|
||||
isTest: row.is_test === 1,
|
||||
fileHash: row.file_hash ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a raw edge row (snake_case) to a typed GraphEdge (camelCase). */
|
||||
private _rowToEdge(row: EdgeRow): GraphEdge {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as EdgeKind,
|
||||
sourceQualified: row.source_qualified,
|
||||
targetQualified: row.target_qualified,
|
||||
filePath: row.file_path,
|
||||
line: row.line ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Return a debounced version of `fn` that delays invocation until `ms`
|
||||
* milliseconds have elapsed since the last call. The returned function
|
||||
* has the same signature as the original.
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const debounced = (...args: Parameters<T>): void => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
fn(...args);
|
||||
}, ms);
|
||||
};
|
||||
|
||||
return debounced as unknown as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches a `graph.db` file on disk and fires a callback whenever the
|
||||
* file is created or modified (debounced to avoid rapid successive events).
|
||||
*/
|
||||
export class GraphWatcher implements vscode.Disposable {
|
||||
private readonly watcher: vscode.FileSystemWatcher;
|
||||
private readonly disposables: vscode.Disposable[] = [];
|
||||
|
||||
/**
|
||||
* @param dbPath Absolute path to the `graph.db` file to watch.
|
||||
* @param onChanged Callback invoked (at most once per 500 ms) when the file changes.
|
||||
*/
|
||||
constructor(dbPath: string, onChanged: () => void) {
|
||||
const dir = path.dirname(dbPath);
|
||||
const filename = path.basename(dbPath);
|
||||
|
||||
this.watcher = vscode.workspace.createFileSystemWatcher(
|
||||
new vscode.RelativePattern(vscode.Uri.file(dir), filename),
|
||||
);
|
||||
|
||||
const debouncedOnChanged = debounce(onChanged, 500);
|
||||
|
||||
this.disposables.push(
|
||||
this.watcher.onDidChange(() => debouncedOnChanged()),
|
||||
this.watcher.onDidCreate(() => debouncedOnChanged()),
|
||||
this.watcher,
|
||||
);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
this.disposables.length = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
import { SqliteReader } from "./backend/sqlite";
|
||||
import type { GraphNode } from "./backend/sqlite";
|
||||
import { CliWrapper } from "./backend/cli";
|
||||
import {
|
||||
CodeGraphTreeProvider,
|
||||
BlastRadiusTreeProvider,
|
||||
StatsTreeProvider,
|
||||
} from "./views/treeView";
|
||||
import { GraphWebviewPanel } from "./views/graphWebview";
|
||||
import { Installer } from "./onboarding/installer";
|
||||
import { registerWalkthroughCommands, showWelcomeIfNeeded } from "./onboarding/welcome";
|
||||
import { StatusBar } from "./views/statusBar";
|
||||
import { ScmDecorationProvider } from "./features/scmDecorations";
|
||||
|
||||
let sqliteReader: SqliteReader | undefined;
|
||||
let autoUpdateTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let scmDecorationProvider: ScmDecorationProvider | undefined;
|
||||
|
||||
/**
|
||||
* Locate the graph database file in the workspace.
|
||||
* Checks `.code-review-graph/graph.db` first, then falls back to `.code-review-graph.db`.
|
||||
*/
|
||||
function findGraphDb(workspaceRoot: string): string | undefined {
|
||||
const primary = path.join(workspaceRoot, ".code-review-graph", "graph.db");
|
||||
if (fs.existsSync(primary)) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const fallback = path.join(workspaceRoot, ".code-review-graph.db");
|
||||
if (fs.existsSync(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace root folder path, or undefined if no workspace is open.
|
||||
* Checks all workspace folders for a graph database (multi-root support).
|
||||
*/
|
||||
function getWorkspaceRoot(): string | undefined {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders) { return undefined; }
|
||||
|
||||
// Prefer the folder that has a graph database
|
||||
for (const folder of folders) {
|
||||
if (findGraphDb(folder.uri.fsPath)) {
|
||||
return folder.uri.fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to first folder
|
||||
return folders[0]?.uri.fsPath;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Navigate to a node's source file location.
|
||||
*/
|
||||
async function navigateToNode(node: GraphNode): Promise<void> {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
const filePath = workspaceRoot
|
||||
? path.join(workspaceRoot, node.filePath)
|
||||
: node.filePath;
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(filePath);
|
||||
const line = Math.max(0, (node.lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all extension commands.
|
||||
*/
|
||||
function registerCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
cli: CliWrapper
|
||||
): void {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.buildGraph",
|
||||
async () => {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage("No workspace folder is open.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await cli.buildGraph(workspaceRoot);
|
||||
if (result.success) {
|
||||
await reinitialize(context);
|
||||
vscode.window.showInformationMessage("Code Graph: Build complete.");
|
||||
} else {
|
||||
vscode.window.showErrorMessage(
|
||||
`Code Graph: Build failed. ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.updateGraph",
|
||||
async () => {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage("No workspace folder is open.");
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Code Graph: Updating graph...",
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
const result = await cli.updateGraph(workspaceRoot);
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Code Graph: Update failed. ${result.stderr}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.showBlastRadius",
|
||||
async (qualifiedNameOrUri?: string | vscode.Uri) => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let qualifiedName: string | undefined;
|
||||
if (typeof qualifiedNameOrUri === "string") {
|
||||
qualifiedName = qualifiedNameOrUri;
|
||||
} else {
|
||||
qualifiedName = await vscode.window.showInputBox({
|
||||
prompt:
|
||||
"Enter the qualified name (e.g., my_module.MyClass.my_method)",
|
||||
placeHolder: "my_module.my_function",
|
||||
});
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the file for this node and compute impact radius
|
||||
const node = sqliteReader.getNode(qualifiedName);
|
||||
if (!node) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: Node "${qualifiedName}" not found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration("codeReviewGraph");
|
||||
const depth = config.get<number>("blastRadiusDepth", 2);
|
||||
const impact = sqliteReader.getImpactRadius([node.filePath], depth);
|
||||
|
||||
if (impact.impactedNodes.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No blast radius found for "${qualifiedName}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
"codeReviewGraph.blastRadius.focus"
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.findCallers",
|
||||
async (qualifiedName?: string) => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
qualifiedName = await vscode.window.showInputBox({
|
||||
prompt: "Enter the qualified name to find callers for",
|
||||
placeHolder: "my_module.my_function",
|
||||
});
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
|
||||
const callerEdges = edges.filter((e) => e.kind === "CALLS");
|
||||
|
||||
if (callerEdges.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No callers found for "${qualifiedName}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = callerEdges.map((e) => {
|
||||
const callerNode = sqliteReader!.getNode(e.sourceQualified);
|
||||
return {
|
||||
label: callerNode?.name ?? e.sourceQualified,
|
||||
description: callerNode?.filePath ?? e.filePath,
|
||||
detail: `Line ${callerNode?.lineStart ?? e.line}`,
|
||||
node: callerNode,
|
||||
edge: e,
|
||||
};
|
||||
});
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Callers of ${qualifiedName}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.findTests",
|
||||
async (qualifiedName?: string) => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
qualifiedName = await vscode.window.showInputBox({
|
||||
prompt: "Enter the qualified name to find tests for",
|
||||
placeHolder: "my_module.my_function",
|
||||
});
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find tests via TESTED_BY edges
|
||||
const edges = sqliteReader.getEdgesByTarget(qualifiedName);
|
||||
const testEdges = edges.filter((e) => e.kind === "TESTED_BY");
|
||||
|
||||
// Also check reverse: source is the node, target is the test
|
||||
const outEdges = sqliteReader.getEdgesBySource(qualifiedName);
|
||||
const outTestEdges = outEdges.filter((e) => e.kind === "TESTED_BY");
|
||||
|
||||
const allTestQualifiedNames = new Set([
|
||||
...testEdges.map((e) => e.sourceQualified),
|
||||
...outTestEdges.map((e) => e.targetQualified),
|
||||
]);
|
||||
|
||||
if (allTestQualifiedNames.size === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No tests found for "${qualifiedName}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
node: GraphNode | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const tqn of allTestQualifiedNames) {
|
||||
const testNode = sqliteReader.getNode(tqn);
|
||||
items.push({
|
||||
label: testNode?.name ?? tqn,
|
||||
description: testNode?.filePath ?? "",
|
||||
detail: `Line ${testNode?.lineStart ?? "?"}`,
|
||||
node: testNode,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Tests for ${qualifiedName}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.queryGraph — expose all 8 query patterns
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("codeReviewGraph.queryGraph", async () => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
{ label: "callers_of", description: "Find functions calling the target" },
|
||||
{ label: "callees_of", description: "Find functions called by the target" },
|
||||
{ label: "imports_of", description: "Find modules imported by a file" },
|
||||
{ label: "importers_of", description: "Find files importing from the target" },
|
||||
{ label: "children_of", description: "Find nodes contained in a file or class" },
|
||||
{ label: "tests_for", description: "Find tests for a function or class" },
|
||||
{ label: "inheritors_of", description: "Find classes inheriting/implementing the target" },
|
||||
{ label: "file_summary", description: "List all nodes in a file" },
|
||||
];
|
||||
|
||||
const pattern = await vscode.window.showQuickPick(patterns, {
|
||||
placeHolder: "Select a query pattern",
|
||||
});
|
||||
if (!pattern) { return; }
|
||||
|
||||
const target = await vscode.window.showInputBox({
|
||||
prompt: `Enter the target for ${pattern.label}`,
|
||||
placeHolder: "e.g., my_module.py::my_function or path/to/file.py",
|
||||
});
|
||||
if (!target) { return; }
|
||||
|
||||
// Map pattern to edge kind + direction
|
||||
type QueryDef = { edgeKind: string; direction: "incoming" | "outgoing"; nodeFilter?: string };
|
||||
const queryMap: Record<string, QueryDef> = {
|
||||
callers_of: { edgeKind: "CALLS", direction: "incoming" },
|
||||
callees_of: { edgeKind: "CALLS", direction: "outgoing" },
|
||||
imports_of: { edgeKind: "IMPORTS_FROM", direction: "outgoing" },
|
||||
importers_of: { edgeKind: "IMPORTS_FROM", direction: "incoming" },
|
||||
children_of: { edgeKind: "CONTAINS", direction: "outgoing" },
|
||||
tests_for: { edgeKind: "TESTED_BY", direction: "incoming" },
|
||||
inheritors_of: { edgeKind: "INHERITS", direction: "incoming" },
|
||||
file_summary: { edgeKind: "CONTAINS", direction: "outgoing" },
|
||||
};
|
||||
|
||||
const qdef = queryMap[pattern.label];
|
||||
if (!qdef) { return; }
|
||||
|
||||
// Try exact match, then search
|
||||
let node = sqliteReader.getNode(target);
|
||||
if (!node) {
|
||||
const matches = sqliteReader.searchNodes(target, 5);
|
||||
if (matches.length === 1) {
|
||||
node = matches[0];
|
||||
} else if (matches.length > 1) {
|
||||
const selected = await vscode.window.showQuickPick(
|
||||
matches.map(m => ({
|
||||
label: m.name,
|
||||
description: `${m.kind} · ${m.filePath}`,
|
||||
node: m,
|
||||
})),
|
||||
{ placeHolder: `Multiple matches for "${target}" — select one` },
|
||||
);
|
||||
if (!selected) { return; }
|
||||
node = selected.node;
|
||||
}
|
||||
}
|
||||
|
||||
if (!node) {
|
||||
vscode.window.showInformationMessage(`Code Graph: "${target}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const edges = qdef.direction === "incoming"
|
||||
? sqliteReader.getEdgesByTarget(node.qualifiedName)
|
||||
: sqliteReader.getEdgesBySource(node.qualifiedName);
|
||||
|
||||
const filtered = edges.filter(e => e.kind === qdef.edgeKind);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No ${pattern.label} results for "${node.name}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = filtered.map(e => {
|
||||
const relatedQn = qdef.direction === "incoming" ? e.sourceQualified : e.targetQualified;
|
||||
const relatedNode = sqliteReader!.getNode(relatedQn);
|
||||
return {
|
||||
label: relatedNode?.name ?? relatedQn,
|
||||
description: relatedNode ? `${relatedNode.kind} · ${relatedNode.filePath}` : "",
|
||||
detail: `Line ${relatedNode?.lineStart ?? e.line}`,
|
||||
node: relatedNode,
|
||||
};
|
||||
});
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `${pattern.label}: ${node.name} (${filtered.length} results)`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findCallees
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.findCallees",
|
||||
async (qualifiedName?: string) => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!qualifiedName) {
|
||||
qualifiedName = await vscode.window.showInputBox({
|
||||
prompt: "Enter the qualified name to find callees for",
|
||||
placeHolder: "my_module.my_function",
|
||||
});
|
||||
}
|
||||
if (!qualifiedName) { return; }
|
||||
|
||||
const edges = sqliteReader.getEdgesBySource(qualifiedName);
|
||||
const calleeEdges = edges.filter(e => e.kind === "CALLS");
|
||||
|
||||
if (calleeEdges.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No callees found for "${qualifiedName}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = calleeEdges.map(e => {
|
||||
const calleeNode = sqliteReader!.getNode(e.targetQualified);
|
||||
return {
|
||||
label: calleeNode?.name ?? e.targetQualified,
|
||||
description: calleeNode?.filePath ?? e.filePath,
|
||||
detail: `Line ${calleeNode?.lineStart ?? e.line}`,
|
||||
node: calleeNode,
|
||||
};
|
||||
});
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Callees of ${qualifiedName}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findLargeFunctions
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.findLargeFunctions",
|
||||
async () => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage("Code Graph: No graph database loaded.");
|
||||
return;
|
||||
}
|
||||
|
||||
const minLinesStr = await vscode.window.showInputBox({
|
||||
prompt: "Minimum line count threshold",
|
||||
placeHolder: "50",
|
||||
value: "50",
|
||||
});
|
||||
if (!minLinesStr) { return; }
|
||||
|
||||
const minLines = parseInt(minLinesStr, 10);
|
||||
if (isNaN(minLines) || minLines < 1) {
|
||||
vscode.window.showWarningMessage("Code Graph: Invalid line count.");
|
||||
return;
|
||||
}
|
||||
|
||||
const results = sqliteReader.getNodesBySize(minLines, undefined, undefined, 50);
|
||||
|
||||
if (results.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No functions found with ${minLines}+ lines.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = results.map(r => ({
|
||||
label: `${r.name} (${r.lineCount} lines)`,
|
||||
description: `${r.kind} · ${r.filePath}`,
|
||||
detail: `Lines ${r.lineStart ?? "?"}–${r.lineEnd ?? "?"}`,
|
||||
node: r as GraphNode,
|
||||
}));
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `${results.length} nodes with ${minLines}+ lines`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.embedGraph
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("codeReviewGraph.embedGraph", async () => {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage("No workspace folder is open.");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await cli.embedGraph(workspaceRoot);
|
||||
if (result.success) {
|
||||
vscode.window.showInformationMessage("Code Graph: Embeddings computed.");
|
||||
} else {
|
||||
const msg = result.stderr.includes("not installed")
|
||||
? "Install embeddings support: pip install code-review-graph[embeddings]"
|
||||
: `Embedding failed: ${result.stderr}`;
|
||||
vscode.window.showErrorMessage(`Code Graph: ${msg}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.watchGraph
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("codeReviewGraph.watchGraph", async () => {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage("No workspace folder is open.");
|
||||
return;
|
||||
}
|
||||
|
||||
vscode.window.showInformationMessage("Code Graph: Watch mode started.");
|
||||
const result = await cli.watchGraph(workspaceRoot);
|
||||
if (!result.success) {
|
||||
vscode.window.showErrorMessage(
|
||||
`Code Graph: Watch failed. ${result.stderr}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("codeReviewGraph.showGraph", async () => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded. Run 'Code Graph: Build Graph' first."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
GraphWebviewPanel.createOrShow(context.extensionUri, sqliteReader);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("codeReviewGraph.search", async () => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = await vscode.window.showInputBox({
|
||||
prompt: "Search the code graph",
|
||||
placeHolder: "Enter a function, class, or module name",
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = sqliteReader.searchNodes(query);
|
||||
|
||||
if (results.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No results found for "${query}".`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = results.map((r) => ({
|
||||
label: r.name,
|
||||
description: r.kind,
|
||||
detail: r.filePath
|
||||
? `${r.filePath}:${r.lineStart ?? ""}`
|
||||
: undefined,
|
||||
result: r,
|
||||
}));
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Results for "${query}"`,
|
||||
});
|
||||
|
||||
if (selected?.result) {
|
||||
await navigateToNode(selected.result);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.reviewChanges",
|
||||
async () => {
|
||||
if (!sqliteReader) {
|
||||
vscode.window.showWarningMessage(
|
||||
"Code Graph: No graph database loaded."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage("No workspace folder is open.");
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: "Code Graph: Analyzing changes...",
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
const { execFile } = await import("node:child_process");
|
||||
const { promisify } = await import("node:util");
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let changedFiles: string[] = [];
|
||||
try {
|
||||
const r1 = await execFileAsync(
|
||||
"git", ["diff", "--name-only", "HEAD"],
|
||||
{ cwd: workspaceRoot }
|
||||
);
|
||||
const r2 = await execFileAsync(
|
||||
"git", ["diff", "--cached", "--name-only"],
|
||||
{ cwd: workspaceRoot }
|
||||
);
|
||||
changedFiles = [...new Set([
|
||||
...r1.stdout.trim().split("\n").filter(Boolean),
|
||||
...r2.stdout.trim().split("\n").filter(Boolean),
|
||||
])];
|
||||
} catch {
|
||||
// git not available or not a git repo
|
||||
}
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
"Code Graph: No changes detected."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const absFiles = changedFiles.map(f => path.join(workspaceRoot, f));
|
||||
const impact = sqliteReader!.getImpactRadius(absFiles);
|
||||
|
||||
// --- Generate review guidance ---
|
||||
const guidance: string[] = [];
|
||||
const impactedFileCount = new Set(
|
||||
impact.impactedNodes.map(n => n.filePath)
|
||||
).size;
|
||||
|
||||
// Test coverage check
|
||||
const untestedFns: string[] = [];
|
||||
for (const node of impact.changedNodes) {
|
||||
if (node.kind !== "Function" || node.isTest) { continue; }
|
||||
const edges = sqliteReader!.getEdgesByTarget(node.qualifiedName);
|
||||
const hasCoverage = edges.some(e => e.kind === "TESTED_BY");
|
||||
if (!hasCoverage) {
|
||||
const out = sqliteReader!.getEdgesBySource(node.qualifiedName);
|
||||
if (!out.some(e => e.kind === "TESTED_BY")) {
|
||||
untestedFns.push(node.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (untestedFns.length > 0) {
|
||||
guidance.push(
|
||||
`\u26a0\ufe0f **${untestedFns.length} changed function(s) lack test coverage**: ${untestedFns.slice(0, 5).join(", ")}${untestedFns.length > 5 ? "..." : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// Wide blast radius warning
|
||||
if (impactedFileCount > 10) {
|
||||
guidance.push(
|
||||
`\u26a0\ufe0f **Wide blast radius**: ${impactedFileCount} files impacted — consider splitting this change.`
|
||||
);
|
||||
}
|
||||
|
||||
// Inheritance changes
|
||||
const inheritanceChanges = impact.edges.filter(
|
||||
e => e.kind === "INHERITS" || e.kind === "IMPLEMENTS"
|
||||
);
|
||||
if (inheritanceChanges.length > 0) {
|
||||
guidance.push(
|
||||
`\u26a0\ufe0f **Inheritance chain affected**: ${inheritanceChanges.length} inheritance/implementation edge(s) touched.`
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-file impacts
|
||||
if (impact.impactedNodes.length > 0) {
|
||||
guidance.push(
|
||||
`\u2139\ufe0f ${impact.impactedNodes.length} nodes in ${impactedFileCount} file(s) may be affected by these changes.`
|
||||
);
|
||||
}
|
||||
|
||||
// Show guidance in output channel
|
||||
const channel = vscode.window.createOutputChannel("Code Graph Review", { log: true });
|
||||
channel.appendLine("# Review Guidance");
|
||||
channel.appendLine("");
|
||||
channel.appendLine(`Changed files: ${changedFiles.length}`);
|
||||
channel.appendLine(`Changed nodes: ${impact.changedNodes.length}`);
|
||||
channel.appendLine(`Impacted nodes: ${impact.impactedNodes.length}`);
|
||||
channel.appendLine(`Impacted files: ${impactedFileCount}`);
|
||||
channel.appendLine("");
|
||||
if (guidance.length > 0) {
|
||||
for (const g of guidance) { channel.appendLine(g); }
|
||||
} else {
|
||||
channel.appendLine("\u2705 No concerns detected.");
|
||||
}
|
||||
channel.show(true);
|
||||
|
||||
// Also show in graph
|
||||
GraphWebviewPanel.createOrShow(
|
||||
context.extensionUri,
|
||||
sqliteReader!,
|
||||
impact
|
||||
);
|
||||
|
||||
// Update SCM decorations
|
||||
if (scmDecorationProvider && sqliteReader) {
|
||||
await scmDecorationProvider.update(sqliteReader, workspaceRoot);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize the reader and tree providers after a graph rebuild.
|
||||
*/
|
||||
async function reinitialize(
|
||||
context: vscode.ExtensionContext
|
||||
): Promise<void> {
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (!workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dbPath = findGraphDb(workspaceRoot);
|
||||
if (!dbPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
sqliteReader?.close();
|
||||
sqliteReader = new SqliteReader(dbPath);
|
||||
|
||||
// Refresh tree views
|
||||
await vscode.commands.executeCommand(
|
||||
"codeReviewGraph.codeGraph.refresh"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a FileSystemWatcher to detect changes to the graph database.
|
||||
*/
|
||||
function watchGraphDb(context: vscode.ExtensionContext): void {
|
||||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||||
"**/.code-review-graph/graph.db"
|
||||
);
|
||||
|
||||
const dbPathRef = { current: "" };
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
if (workspaceRoot) {
|
||||
const dbPath = findGraphDb(workspaceRoot);
|
||||
if (dbPath) {
|
||||
dbPathRef.current = dbPath;
|
||||
}
|
||||
}
|
||||
|
||||
watcher.onDidChange(() => {
|
||||
// Close and reopen to pick up external writes
|
||||
if (sqliteReader && dbPathRef.current) {
|
||||
sqliteReader.close();
|
||||
sqliteReader = new SqliteReader(dbPathRef.current);
|
||||
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
|
||||
}
|
||||
});
|
||||
|
||||
watcher.onDidCreate(async () => {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
if (wsRoot && !sqliteReader) {
|
||||
const dbPath = findGraphDb(wsRoot);
|
||||
if (dbPath) {
|
||||
dbPathRef.current = dbPath;
|
||||
sqliteReader = new SqliteReader(dbPath);
|
||||
vscode.commands.executeCommand("codeReviewGraph.codeGraph.refresh");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watcher.onDidDelete(() => {
|
||||
sqliteReader?.close();
|
||||
sqliteReader = undefined;
|
||||
dbPathRef.current = "";
|
||||
});
|
||||
|
||||
context.subscriptions.push(watcher);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up debounced auto-update on file save.
|
||||
*/
|
||||
function setupAutoUpdate(
|
||||
context: vscode.ExtensionContext,
|
||||
cli: CliWrapper
|
||||
): void {
|
||||
const AUTO_UPDATE_DEBOUNCE_MS = 2000;
|
||||
|
||||
const onSave = vscode.workspace.onDidSaveTextDocument(() => {
|
||||
const config = vscode.workspace.getConfiguration("codeReviewGraph");
|
||||
if (!config.get<boolean>("autoUpdate", true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoUpdateTimer) {
|
||||
clearTimeout(autoUpdateTimer);
|
||||
}
|
||||
|
||||
autoUpdateTimer = setTimeout(async () => {
|
||||
const wsRoot = getWorkspaceRoot();
|
||||
if (!wsRoot || !sqliteReader) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cli.updateGraph(wsRoot);
|
||||
} catch {
|
||||
// Silently ignore update errors on save; user can manually update
|
||||
}
|
||||
}, AUTO_UPDATE_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
context.subscriptions.push(onSave);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension activation entry point.
|
||||
*/
|
||||
export async function activate(
|
||||
context: vscode.ExtensionContext
|
||||
): Promise<void> {
|
||||
const cli = new CliWrapper();
|
||||
const installer = new Installer(cli);
|
||||
|
||||
// Register walkthrough commands
|
||||
registerWalkthroughCommands(context, cli, installer);
|
||||
|
||||
const workspaceRoot = getWorkspaceRoot();
|
||||
|
||||
if (workspaceRoot) {
|
||||
const dbPath = findGraphDb(workspaceRoot);
|
||||
|
||||
if (dbPath) {
|
||||
// Graph database found - initialize
|
||||
sqliteReader = new SqliteReader(dbPath);
|
||||
|
||||
// Schema compatibility check
|
||||
const schemaWarning = sqliteReader.checkSchemaCompatibility();
|
||||
if (schemaWarning) {
|
||||
const choice = await vscode.window.showWarningMessage(
|
||||
`Code Graph: ${schemaWarning}`,
|
||||
"Rebuild Graph",
|
||||
"Dismiss"
|
||||
);
|
||||
if (choice === "Rebuild Graph") {
|
||||
await vscode.commands.executeCommand("codeReviewGraph.buildGraph");
|
||||
}
|
||||
}
|
||||
|
||||
// Register tree view providers
|
||||
const codeGraphProvider = new CodeGraphTreeProvider(
|
||||
sqliteReader,
|
||||
workspaceRoot
|
||||
);
|
||||
const blastRadiusProvider = new BlastRadiusTreeProvider();
|
||||
const statsProvider = new StatsTreeProvider(sqliteReader);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerTreeDataProvider(
|
||||
"codeReviewGraph.codeGraph",
|
||||
codeGraphProvider
|
||||
),
|
||||
vscode.window.registerTreeDataProvider(
|
||||
"codeReviewGraph.blastRadius",
|
||||
blastRadiusProvider
|
||||
),
|
||||
vscode.window.registerTreeDataProvider(
|
||||
"codeReviewGraph.stats",
|
||||
statsProvider
|
||||
)
|
||||
);
|
||||
|
||||
// Create status bar
|
||||
const statusBar = new StatusBar();
|
||||
statusBar.update(sqliteReader);
|
||||
statusBar.show();
|
||||
context.subscriptions.push(statusBar);
|
||||
|
||||
// Register SCM file decoration provider
|
||||
scmDecorationProvider = new ScmDecorationProvider();
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerFileDecorationProvider(scmDecorationProvider)
|
||||
);
|
||||
} else {
|
||||
// No graph database found - show welcome
|
||||
showWelcomeIfNeeded(context);
|
||||
}
|
||||
}
|
||||
|
||||
// Register revealInTree command for bidirectional graph→tree sync
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"codeReviewGraph.revealInTree",
|
||||
(_qualifiedName: string) => {
|
||||
// When a node is clicked in the graph, highlight it in the graph
|
||||
// The graph webview already calls this; the tree view sync relies
|
||||
// on the file navigation that nodeClicked also triggers.
|
||||
// This command is a hook for future tree reveal integration.
|
||||
GraphWebviewPanel.highlightNode(_qualifiedName);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Register commands (always, even without a database)
|
||||
registerCommands(context, cli);
|
||||
|
||||
// Watch for graph.db changes
|
||||
watchGraphDb(context);
|
||||
|
||||
// Set up auto-update on save
|
||||
setupAutoUpdate(context, cli);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension deactivation cleanup.
|
||||
*/
|
||||
export function deactivate(): void {
|
||||
if (autoUpdateTimer) {
|
||||
clearTimeout(autoUpdateTimer);
|
||||
autoUpdateTimer = undefined;
|
||||
}
|
||||
|
||||
sqliteReader?.close();
|
||||
sqliteReader = undefined;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
import { BlastRadiusTreeProvider } from '../views/treeView';
|
||||
import { resolveNodeAtCursor } from './cursorResolver';
|
||||
|
||||
/**
|
||||
* Register the cursor-aware blast radius command.
|
||||
*
|
||||
* When invoked the command:
|
||||
* 1. Gets the active editor's file path and cursor line.
|
||||
* 2. Resolves the innermost node at cursor via the graph database.
|
||||
* 3. Falls back to the file-level node when no specific node is found.
|
||||
* 4. Runs a BFS impact radius query up to the configured depth.
|
||||
* 5. Updates the BlastRadiusTreeProvider with the results.
|
||||
* 6. Focuses the blast radius tree view.
|
||||
*/
|
||||
export function registerBlastRadiusCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
getReader: () => SqliteReader | undefined,
|
||||
blastRadiusProvider: BlastRadiusTreeProvider,
|
||||
workspaceRoot: string,
|
||||
): void {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.showBlastRadius', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Active editor check ---
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('Open a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Resolve file path and cursor position ---
|
||||
const absFilePath = editor.document.uri.fsPath;
|
||||
const cursorLine = editor.selection.active.line + 1; // 1-based
|
||||
|
||||
// --- Resolve node at cursor ---
|
||||
const nodeAtCursor = reader.getNodeAtCursor(absFilePath, cursorLine);
|
||||
|
||||
// Determine the file path to feed into getImpactRadius.
|
||||
// If we found a node, use its filePath (which is the canonical path
|
||||
// stored in the database). Otherwise fall back to the editor path.
|
||||
const filePath = nodeAtCursor ? nodeAtCursor.filePath : absFilePath;
|
||||
|
||||
// --- Read depth from settings ---
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
|
||||
// --- Compute blast radius ---
|
||||
const impact = reader.getImpactRadius([filePath], depth);
|
||||
|
||||
// --- Update tree provider ---
|
||||
blastRadiusProvider.setResults(impact.changedNodes, impact.impactedNodes);
|
||||
|
||||
// --- Focus the blast radius view ---
|
||||
await vscode.commands.executeCommand('codeReviewGraph.blastRadius.focus');
|
||||
|
||||
// --- Summary message ---
|
||||
const impactedFileCount = new Set(impact.impactedNodes.map((n) => n.filePath)).size;
|
||||
vscode.window.showInformationMessage(
|
||||
`Blast radius: ${impact.impactedNodes.length} nodes impacted across ${impactedFileCount} files`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
|
||||
/**
|
||||
* Resolve the innermost graph node at the current cursor position.
|
||||
*
|
||||
* Returns `undefined` when there is no active editor or no node spans the
|
||||
* cursor line in the graph database.
|
||||
*/
|
||||
export function resolveNodeAtCursor(
|
||||
reader: SqliteReader,
|
||||
): GraphNode | undefined {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const filePath = editor.document.uri.fsPath;
|
||||
const line = editor.selection.active.line + 1; // VS Code is 0-based, SQLite data is 1-based
|
||||
|
||||
return reader.getNodeAtCursor(filePath, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a document and scroll to the node's start line.
|
||||
*
|
||||
* The node's `filePath` is treated as an absolute path. If `lineStart` is
|
||||
* null the file is opened at the top.
|
||||
*/
|
||||
export async function navigateToNode(node: GraphNode): Promise<void> {
|
||||
const uri = vscode.Uri.file(node.filePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const line = Math.max(0, (node.lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
import { resolveNodeAtCursor, navigateToNode } from './cursorResolver';
|
||||
|
||||
/**
|
||||
* Register the navigation commands: findCallers, findTests, and search.
|
||||
*/
|
||||
export function registerNavigationCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
getReader: () => SqliteReader | undefined,
|
||||
): void {
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findCallers
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.findCallers', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve node at cursor
|
||||
const node = resolveNodeAtCursor(reader);
|
||||
if (!node) {
|
||||
vscode.window.showWarningMessage(
|
||||
'Code Graph: No graph node found at the current cursor position.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Query incoming CALLS edges
|
||||
const edges = reader.getEdgesByTarget(node.qualifiedName);
|
||||
const callerEdges = edges.filter((e) => e.kind === 'CALLS');
|
||||
|
||||
if (callerEdges.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No callers found for "${node.name}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build QuickPick items, resolving each caller to its full node
|
||||
const items: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
node: GraphNode | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const edge of callerEdges) {
|
||||
const callerNode = reader.getNode(edge.sourceQualified);
|
||||
items.push({
|
||||
label: callerNode?.name ?? edge.sourceQualified,
|
||||
description: callerNode?.filePath ?? edge.filePath,
|
||||
detail: `Line ${callerNode?.lineStart ?? edge.line}`,
|
||||
node: callerNode,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Callers of ${node.name}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.findTests
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.findTests', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve node at cursor
|
||||
const node = resolveNodeAtCursor(reader);
|
||||
if (!node) {
|
||||
vscode.window.showWarningMessage(
|
||||
'Code Graph: No graph node found at the current cursor position.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Collect test qualified names from TESTED_BY edges (both directions) ---
|
||||
const incomingEdges = reader.getEdgesByTarget(node.qualifiedName);
|
||||
const incomingTestEdges = incomingEdges.filter((e) => e.kind === 'TESTED_BY');
|
||||
|
||||
const outgoingEdges = reader.getEdgesBySource(node.qualifiedName);
|
||||
const outgoingTestEdges = outgoingEdges.filter((e) => e.kind === 'TESTED_BY');
|
||||
|
||||
const testQualifiedNames = new Set<string>([
|
||||
...incomingTestEdges.map((e) => e.sourceQualified),
|
||||
...outgoingTestEdges.map((e) => e.targetQualified),
|
||||
]);
|
||||
|
||||
// --- Also search by naming convention: test_{name}, Test{name} ---
|
||||
const conventionPatterns = [`test_${node.name}`, `Test${node.name}`];
|
||||
for (const pattern of conventionPatterns) {
|
||||
const matches = reader.searchNodes(pattern, 10);
|
||||
for (const match of matches) {
|
||||
if (match.isTest || match.kind === 'Test') {
|
||||
testQualifiedNames.add(match.qualifiedName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (testQualifiedNames.size === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No tests found for "${node.name}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Build QuickPick items ---
|
||||
const items: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
detail: string;
|
||||
node: GraphNode | undefined;
|
||||
}> = [];
|
||||
|
||||
for (const tqn of testQualifiedNames) {
|
||||
const testNode = reader.getNode(tqn);
|
||||
items.push({
|
||||
label: testNode?.name ?? tqn,
|
||||
description: testNode?.filePath ?? '',
|
||||
detail: `Line ${testNode?.lineStart ?? '?'}`,
|
||||
node: testNode,
|
||||
});
|
||||
}
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Tests for ${node.name}`,
|
||||
});
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// codeReviewGraph.search
|
||||
// -----------------------------------------------------------------
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewGraph.search', async () => {
|
||||
const reader = getReader();
|
||||
if (!reader) {
|
||||
vscode.window.showWarningMessage('Code Graph: No graph database loaded.');
|
||||
return;
|
||||
}
|
||||
|
||||
const query = await vscode.window.showInputBox({
|
||||
prompt: 'Search the code graph',
|
||||
placeHolder: 'Enter a function, class, or module name',
|
||||
});
|
||||
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = reader.searchNodes(query, 30);
|
||||
|
||||
if (results.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
`Code Graph: No results found for "${query}".`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = results.map((r) => ({
|
||||
label: r.name,
|
||||
description: r.kind,
|
||||
detail: r.filePath
|
||||
? `${r.filePath}:${r.lineStart ?? ''}`
|
||||
: undefined,
|
||||
result: r,
|
||||
}));
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: `Results for "${query}"`,
|
||||
});
|
||||
|
||||
if (selected?.result) {
|
||||
await navigateToNode(selected.result);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* SCM integration for code review.
|
||||
*
|
||||
* Detects staged and unstaged changes via git, computes the blast radius
|
||||
* for those files, and populates the Blast Radius tree view so the reviewer
|
||||
* can see what is impacted before committing.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
import { BlastRadiusTreeProvider } from '../views/treeView';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Timeout for git commands (milliseconds). */
|
||||
const GIT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Run a git command in the given working directory and return trimmed stdout
|
||||
* lines. Returns an empty array on any error (e.g. git not installed, not a
|
||||
* git repo, etc.).
|
||||
*/
|
||||
async function gitLines(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
});
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `codeReviewGraph.reviewChanges` command.
|
||||
*
|
||||
* The command:
|
||||
* 1. Runs `git diff --name-only HEAD` and `git diff --cached --name-only`
|
||||
* to collect changed + staged files.
|
||||
* 2. Computes the blast radius for those files.
|
||||
* 3. Updates the BlastRadiusTreeProvider with the results.
|
||||
* 4. Focuses the blast radius view.
|
||||
*/
|
||||
export function registerReviewCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
reader: SqliteReader,
|
||||
blastRadiusProvider: BlastRadiusTreeProvider,
|
||||
): void {
|
||||
const disposable = vscode.commands.registerCommand(
|
||||
'codeReviewGraph.reviewChanges',
|
||||
async () => {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showErrorMessage('No workspace folder is open.');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = workspaceFolder.uri.fsPath;
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Code Graph: Analyzing changes...',
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
// 1. Collect changed files (unstaged + staged, deduplicated)
|
||||
const [unstaged, staged] = await Promise.all([
|
||||
gitLines(['diff', '--name-only', 'HEAD'], workspaceRoot),
|
||||
gitLines(['diff', '--cached', '--name-only'], workspaceRoot),
|
||||
]);
|
||||
|
||||
const changedFiles = [...new Set([...unstaged, ...staged])];
|
||||
|
||||
if (changedFiles.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No changes detected.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Compute blast radius
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
const impact = reader.getImpactRadius(changedFiles, depth);
|
||||
|
||||
// 3. Update tree provider
|
||||
blastRadiusProvider.setResults(
|
||||
impact.changedNodes,
|
||||
impact.impactedNodes,
|
||||
);
|
||||
|
||||
// 4. Focus the blast radius view
|
||||
await vscode.commands.executeCommand(
|
||||
'codeReviewGraph.blastRadius.focus',
|
||||
);
|
||||
|
||||
// 5. Show summary
|
||||
vscode.window.showInformationMessage(
|
||||
`Review: ${changedFiles.length} changed file(s) impact ${impact.impactedNodes.length} additional file(s).`,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* SCM file decoration provider.
|
||||
*
|
||||
* Adds badges to files in the Explorer and SCM views:
|
||||
* - IMPACTED (orange) — file is in the blast radius of staged/unstaged changes
|
||||
* - TESTED (green) — changed functions in this file have test coverage
|
||||
* - UNTESTED (red) — changed functions lack test coverage
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
|
||||
export class ScmDecorationProvider
|
||||
implements vscode.FileDecorationProvider
|
||||
{
|
||||
private readonly _onDidChange = new vscode.EventEmitter<vscode.Uri | vscode.Uri[] | undefined>();
|
||||
readonly onDidChangeFileDecorations = this._onDidChange.event;
|
||||
|
||||
/** Files directly changed (staged + unstaged). */
|
||||
private changedFiles = new Set<string>();
|
||||
/** Files in the blast radius but not directly changed. */
|
||||
private impactedFiles = new Set<string>();
|
||||
/** Changed files whose functions all have TESTED_BY edges. */
|
||||
private testedFiles = new Set<string>();
|
||||
/** Changed files with at least one function lacking TESTED_BY edges. */
|
||||
private untestedFiles = new Set<string>();
|
||||
|
||||
/**
|
||||
* Recompute decorations from git state and the graph database.
|
||||
*/
|
||||
async update(
|
||||
reader: SqliteReader,
|
||||
workspaceRoot: string,
|
||||
): Promise<void> {
|
||||
const { execFile } = await import('node:child_process');
|
||||
const { promisify } = await import('node:util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
const path = await import('node:path');
|
||||
|
||||
// 1. Collect changed files
|
||||
let unstaged: string[] = [];
|
||||
let staged: string[] = [];
|
||||
try {
|
||||
const r1 = await execFileAsync('git', ['diff', '--name-only', 'HEAD'], {
|
||||
cwd: workspaceRoot,
|
||||
timeout: 10_000,
|
||||
});
|
||||
unstaged = r1.stdout.trim().split('\n').filter(Boolean);
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const r2 = await execFileAsync('git', ['diff', '--cached', '--name-only'], {
|
||||
cwd: workspaceRoot,
|
||||
timeout: 10_000,
|
||||
});
|
||||
staged = r2.stdout.trim().split('\n').filter(Boolean);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const changedRelative = [...new Set([...unstaged, ...staged])];
|
||||
const changedAbsolute = changedRelative.map((f) => path.join(workspaceRoot, f));
|
||||
|
||||
// 2. Compute impact radius
|
||||
const config = vscode.workspace.getConfiguration('codeReviewGraph');
|
||||
const depth = config.get<number>('blastRadiusDepth', 2);
|
||||
const impact = reader.getImpactRadius(changedAbsolute, depth);
|
||||
|
||||
// 3. Classify files
|
||||
this.changedFiles = new Set(changedAbsolute);
|
||||
this.impactedFiles = new Set(
|
||||
impact.impactedNodes
|
||||
.map((n) => n.filePath)
|
||||
.filter((f) => !this.changedFiles.has(f)),
|
||||
);
|
||||
|
||||
// 4. Test coverage classification
|
||||
this.testedFiles = new Set<string>();
|
||||
this.untestedFiles = new Set<string>();
|
||||
|
||||
for (const filePath of this.changedFiles) {
|
||||
const nodes = reader.getNodesByFile(filePath);
|
||||
const functions = nodes.filter(
|
||||
(n) => n.kind === 'Function' && !n.isTest,
|
||||
);
|
||||
if (functions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let allTested = true;
|
||||
for (const fn of functions) {
|
||||
const edges = reader.getEdgesByTarget(fn.qualifiedName);
|
||||
const hasTest = edges.some((e) => e.kind === 'TESTED_BY');
|
||||
if (!hasTest) {
|
||||
// Also check outgoing TESTED_BY (reverse direction)
|
||||
const outEdges = reader.getEdgesBySource(fn.qualifiedName);
|
||||
const hasOutTest = outEdges.some((e) => e.kind === 'TESTED_BY');
|
||||
if (!hasOutTest) {
|
||||
allTested = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allTested) {
|
||||
this.testedFiles.add(filePath);
|
||||
} else {
|
||||
this.untestedFiles.add(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Fire change event
|
||||
this._onDidChange.fire(undefined);
|
||||
}
|
||||
|
||||
/** Clear all decorations. */
|
||||
clear(): void {
|
||||
this.changedFiles.clear();
|
||||
this.impactedFiles.clear();
|
||||
this.testedFiles.clear();
|
||||
this.untestedFiles.clear();
|
||||
this._onDidChange.fire(undefined);
|
||||
}
|
||||
|
||||
provideFileDecoration(
|
||||
uri: vscode.Uri,
|
||||
): vscode.FileDecoration | undefined {
|
||||
const filePath = uri.fsPath;
|
||||
|
||||
if (this.untestedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '!',
|
||||
color: new vscode.ThemeColor('editorError.foreground'),
|
||||
tooltip: 'Code Graph: Changed functions lack test coverage',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.testedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '\u2713',
|
||||
color: new vscode.ThemeColor('testing.iconPassed'),
|
||||
tooltip: 'Code Graph: All changed functions have test coverage',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.impactedFiles.has(filePath)) {
|
||||
return {
|
||||
badge: '\u25CF',
|
||||
color: new vscode.ThemeColor('editorWarning.foreground'),
|
||||
tooltip: 'Code Graph: In blast radius of current changes',
|
||||
propagate: false,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidChange.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Quick search command with live filtering.
|
||||
*
|
||||
* Shows a QuickPick that queries the graph database as the user types,
|
||||
* then navigates to the selected node's source location.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'node:path';
|
||||
import { SqliteReader, GraphNode } from '../backend/sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kind-to-icon mapping (uses VS Code codicon identifiers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KIND_ICON: Record<string, string> = {
|
||||
Function: '$(symbol-method)',
|
||||
Class: '$(symbol-class)',
|
||||
File: '$(file)',
|
||||
Test: '$(beaker)',
|
||||
Type: '$(symbol-interface)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a QuickPickItem from a GraphNode.
|
||||
*/
|
||||
function nodeToQuickPickItem(
|
||||
node: GraphNode,
|
||||
workspaceRoot: string | undefined,
|
||||
): vscode.QuickPickItem & { node: GraphNode } {
|
||||
const icon = KIND_ICON[node.kind] ?? '$(symbol-misc)';
|
||||
const relativePath = workspaceRoot
|
||||
? path.relative(workspaceRoot, node.filePath)
|
||||
: node.filePath;
|
||||
const lineInfo = node.lineStart != null ? `:${node.lineStart}` : '';
|
||||
|
||||
return {
|
||||
label: `${icon} ${node.name}`,
|
||||
description: node.kind,
|
||||
detail: `${relativePath}${lineInfo}`,
|
||||
node,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a node's source location.
|
||||
*/
|
||||
async function navigateToNode(
|
||||
node: GraphNode,
|
||||
workspaceRoot: string | undefined,
|
||||
): Promise<void> {
|
||||
const filePath = workspaceRoot
|
||||
? path.join(workspaceRoot, node.filePath)
|
||||
: node.filePath;
|
||||
|
||||
const uri = vscode.Uri.file(filePath);
|
||||
const doc = await vscode.workspace.openTextDocument(uri);
|
||||
const line = Math.max(0, (node.lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `codeReviewGraph.search` command.
|
||||
*
|
||||
* Opens a QuickPick with live filtering:
|
||||
* - As the user types, `reader.searchNodes(value, 20)` is called.
|
||||
* - Results are displayed with kind-specific icons.
|
||||
* - On accept, the editor navigates to the selected node.
|
||||
*/
|
||||
export function registerSearchCommand(
|
||||
context: vscode.ExtensionContext,
|
||||
reader: SqliteReader,
|
||||
): void {
|
||||
const disposable = vscode.commands.registerCommand(
|
||||
'codeReviewGraph.search',
|
||||
async () => {
|
||||
const workspaceRoot =
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
|
||||
const quickPick = vscode.window.createQuickPick<
|
||||
vscode.QuickPickItem & { node: GraphNode }
|
||||
>();
|
||||
quickPick.placeholder = 'Search for functions, classes, files, types...';
|
||||
quickPick.matchOnDescription = true;
|
||||
quickPick.matchOnDetail = true;
|
||||
|
||||
// Debounce timer to avoid querying on every keystroke
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
quickPick.onDidChangeValue((value) => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
quickPick.items = [];
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
const results = reader.searchNodes(value, 20);
|
||||
quickPick.items = results.map((node) =>
|
||||
nodeToQuickPickItem(node, workspaceRoot),
|
||||
);
|
||||
}, 100);
|
||||
});
|
||||
|
||||
quickPick.onDidAccept(async () => {
|
||||
const selected = quickPick.selectedItems[0];
|
||||
quickPick.dispose();
|
||||
|
||||
if (selected?.node) {
|
||||
await navigateToNode(selected.node, workspaceRoot);
|
||||
}
|
||||
});
|
||||
|
||||
quickPick.onDidHide(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
quickPick.dispose();
|
||||
});
|
||||
|
||||
quickPick.show();
|
||||
},
|
||||
);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { CliWrapper } from '../backend/cli';
|
||||
|
||||
/**
|
||||
* Handles auto-detection and installation of the Python backend.
|
||||
*
|
||||
* Checks whether the `code-review-graph` CLI is available and, if not,
|
||||
* guides the user through installation via pip/pipx or manual instructions.
|
||||
*/
|
||||
export class Installer {
|
||||
constructor(private cli: CliWrapper) {}
|
||||
|
||||
/**
|
||||
* Check whether the backend is installed and prompt the user if it is not.
|
||||
*
|
||||
* @returns `true` if the backend is installed (or was just installed
|
||||
* successfully), `false` if the user dismissed the prompt or
|
||||
* installation failed.
|
||||
*/
|
||||
async checkAndPrompt(): Promise<boolean> {
|
||||
const installed = await this.cli.isInstalled();
|
||||
if (installed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const selection = await vscode.window.showInformationMessage(
|
||||
'Code Review Graph backend is not installed.',
|
||||
'Install Now',
|
||||
'Manual Instructions',
|
||||
'Dismiss',
|
||||
);
|
||||
|
||||
if (selection === 'Install Now') {
|
||||
return this.autoInstall();
|
||||
}
|
||||
|
||||
if (selection === 'Manual Instructions') {
|
||||
const terminal = vscode.window.createTerminal('Code Review Graph Setup');
|
||||
terminal.show();
|
||||
terminal.sendText('echo "=== Code Review Graph - Manual Installation ==="');
|
||||
terminal.sendText('echo ""');
|
||||
terminal.sendText('echo "Option 1: Install with pip"');
|
||||
terminal.sendText('echo " pip install code-review-graph"');
|
||||
terminal.sendText('echo ""');
|
||||
terminal.sendText('echo "Option 2: Install with pipx (recommended)"');
|
||||
terminal.sendText('echo " pipx install code-review-graph"');
|
||||
terminal.sendText('echo ""');
|
||||
terminal.sendText('echo "After installation, reload the VS Code window."');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dismissed
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to automatically install the backend using the first available
|
||||
* Python package installer (pip or pipx).
|
||||
*
|
||||
* @returns `true` if installation succeeded, `false` otherwise.
|
||||
*/
|
||||
async autoInstall(): Promise<boolean> {
|
||||
const installer = await this.cli.detectPythonInstaller();
|
||||
|
||||
if (!installer) {
|
||||
const openLink = 'Download Python';
|
||||
const response = await vscode.window.showErrorMessage(
|
||||
'Python 3.10+ is required. Install Python first.',
|
||||
openLink,
|
||||
);
|
||||
|
||||
if (response === openLink) {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse('https://www.python.org/downloads/'),
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
let success = false;
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: `Installing code-review-graph via ${installer}...`,
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
await this.cli.installBackend(installer);
|
||||
success = true;
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (success) {
|
||||
vscode.window.showInformationMessage(
|
||||
'Backend installed successfully!',
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to install code-review-graph via ${installer}. ` +
|
||||
'Check the terminal output for details or try installing manually: ' +
|
||||
`\`${installer} install code-review-graph\``,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Installer } from './installer';
|
||||
import { CliWrapper } from '../backend/cli';
|
||||
|
||||
/**
|
||||
* Register command handlers for the walkthrough steps defined in
|
||||
* `package.json` contributes.walkthroughs.
|
||||
*
|
||||
* Each walkthrough step button triggers one of these commands so the user
|
||||
* can install the CLI, build the graph, and explore the sidebar without
|
||||
* leaving the walkthrough.
|
||||
*/
|
||||
export function registerWalkthroughCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
cli: CliWrapper,
|
||||
installer: Installer,
|
||||
): void {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
'codeReviewGraph.walkthrough.install',
|
||||
async () => {
|
||||
await installer.autoInstall();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
'codeReviewGraph.walkthrough.build',
|
||||
async () => {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (!workspaceFolder) {
|
||||
vscode.window.showWarningMessage(
|
||||
'No workspace folder is open. Open a folder first.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Code Graph: Building graph...',
|
||||
cancellable: false,
|
||||
},
|
||||
async () => {
|
||||
await cli.buildGraph(workspaceFolder.uri.fsPath);
|
||||
},
|
||||
);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
'Code Graph: Build complete.',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
'codeReviewGraph.walkthrough.explore',
|
||||
async () => {
|
||||
await vscode.commands.executeCommand(
|
||||
'codeReviewGraph.codeGraph.focus',
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a welcome notification if no graph database has been built yet
|
||||
* in any of the open workspace folders.
|
||||
*
|
||||
* Checks for `.code-review-graph/graph.db` in every workspace folder.
|
||||
* When none is found, a notification is shown with a button that opens
|
||||
* the built-in walkthrough.
|
||||
*/
|
||||
export async function showWelcomeIfNeeded(
|
||||
context: vscode.ExtensionContext,
|
||||
): Promise<void> {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const folder of workspaceFolders) {
|
||||
const dbUri = vscode.Uri.joinPath(
|
||||
folder.uri,
|
||||
'.code-review-graph',
|
||||
'graph.db',
|
||||
);
|
||||
|
||||
try {
|
||||
await vscode.workspace.fs.stat(dbUri);
|
||||
// Database exists in at least one folder -- no need to prompt.
|
||||
return;
|
||||
} catch {
|
||||
// File does not exist in this folder -- continue checking.
|
||||
}
|
||||
}
|
||||
|
||||
// No graph.db found in any workspace folder.
|
||||
const selection = await vscode.window.showInformationMessage(
|
||||
'Welcome to Code Review Graph! Get started by building your code graph.',
|
||||
'Get Started',
|
||||
);
|
||||
|
||||
if (selection === 'Get Started') {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.openWalkthrough',
|
||||
'tirth8205.code-review-graph#codeReviewGraph.welcome',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Webview panel for the interactive graph visualization.
|
||||
* Uses D3.js (bundled via esbuild) to render a force-directed graph.
|
||||
*
|
||||
* Hosts the toolbar HTML, CSS, and manages communication with the
|
||||
* browser-side graph.ts script.
|
||||
*/
|
||||
|
||||
import * as vscode from "vscode";
|
||||
import * as path from "node:path";
|
||||
import * as crypto from "node:crypto";
|
||||
import type { SqliteReader, ImpactRadius } from "../backend/sqlite";
|
||||
|
||||
export class GraphWebviewPanel {
|
||||
private static currentPanel: GraphWebviewPanel | undefined;
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private readonly reader: SqliteReader;
|
||||
private readonly impactRadius?: ImpactRadius;
|
||||
private readonly highlightQualifiedName?: string;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
private constructor(
|
||||
panel: vscode.WebviewPanel,
|
||||
extensionUri: vscode.Uri,
|
||||
reader: SqliteReader,
|
||||
impactRadius?: ImpactRadius,
|
||||
highlightQualifiedName?: string
|
||||
) {
|
||||
this.panel = panel;
|
||||
this.reader = reader;
|
||||
this.impactRadius = impactRadius;
|
||||
this.highlightQualifiedName = highlightQualifiedName;
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.html = this.getHtmlContent(
|
||||
this.panel.webview,
|
||||
extensionUri
|
||||
);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
(message) => this.handleMessage(message),
|
||||
null,
|
||||
this.disposables
|
||||
);
|
||||
|
||||
// Listen for theme changes
|
||||
this.disposables.push(
|
||||
vscode.window.onDidChangeActiveColorTheme((theme) => {
|
||||
const themeKind =
|
||||
theme.kind === vscode.ColorThemeKind.Light ||
|
||||
theme.kind === vscode.ColorThemeKind.HighContrastLight
|
||||
? "light"
|
||||
: "dark";
|
||||
this.panel.webview.postMessage({
|
||||
command: "setTheme",
|
||||
theme: themeKind,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
static createOrShow(
|
||||
extensionUri: vscode.Uri,
|
||||
reader: SqliteReader,
|
||||
impactRadius?: ImpactRadius,
|
||||
highlightQualifiedName?: string
|
||||
): void {
|
||||
const column = vscode.ViewColumn.Beside;
|
||||
|
||||
if (GraphWebviewPanel.currentPanel) {
|
||||
GraphWebviewPanel.currentPanel.panel.reveal(column);
|
||||
|
||||
// Re-send data if a new highlight is requested
|
||||
if (highlightQualifiedName) {
|
||||
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
|
||||
command: "highlightNode",
|
||||
qualifiedName: highlightQualifiedName,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
"codeReviewGraph.graph",
|
||||
"Code Graph",
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist")],
|
||||
}
|
||||
);
|
||||
|
||||
GraphWebviewPanel.currentPanel = new GraphWebviewPanel(
|
||||
panel,
|
||||
extensionUri,
|
||||
reader,
|
||||
impactRadius,
|
||||
highlightQualifiedName
|
||||
);
|
||||
}
|
||||
|
||||
private dispose(): void {
|
||||
GraphWebviewPanel.currentPanel = undefined;
|
||||
this.panel.dispose();
|
||||
for (const d of this.disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
this.disposables = [];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Message handling
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private handleMessage(message: {
|
||||
command: string;
|
||||
[key: string]: unknown;
|
||||
}): void {
|
||||
switch (message.command) {
|
||||
case "ready":
|
||||
this.sendGraphData();
|
||||
break;
|
||||
|
||||
case "nodeClicked":
|
||||
this.openFileAtLine(
|
||||
message.filePath as string,
|
||||
message.lineStart as number
|
||||
);
|
||||
// Bidirectional sync: reveal in tree view
|
||||
if (message.qualifiedName) {
|
||||
vscode.commands.executeCommand(
|
||||
"codeReviewGraph.revealInTree",
|
||||
message.qualifiedName as string
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "exportSvg":
|
||||
this.exportSvgToClipboard(message.svg as string);
|
||||
break;
|
||||
|
||||
case "exportPng":
|
||||
this.savePngToFile(message.data as string);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send full graph data to the webview.
|
||||
* If an impact radius was provided, send only those nodes/edges.
|
||||
* Otherwise send the full graph.
|
||||
*/
|
||||
private sendGraphData(): void {
|
||||
let nodes;
|
||||
let edges;
|
||||
|
||||
if (this.impactRadius) {
|
||||
nodes = [
|
||||
...this.impactRadius.changedNodes,
|
||||
...this.impactRadius.impactedNodes,
|
||||
];
|
||||
edges = this.impactRadius.edges;
|
||||
} else {
|
||||
// Load all nodes and edges
|
||||
const files = this.reader.getAllFiles();
|
||||
nodes = files.flatMap((f) => this.reader.getNodesByFile(f));
|
||||
const qualifiedNames = new Set(nodes.map((n) => n.qualifiedName));
|
||||
edges = this.reader.getEdgesAmong(qualifiedNames);
|
||||
}
|
||||
|
||||
// Enforce maxNodes setting
|
||||
const config = vscode.workspace.getConfiguration("codeReviewGraph");
|
||||
const maxNodes = config.get<number>("graph.maxNodes", 500);
|
||||
let truncated = false;
|
||||
if (nodes.length > maxNodes) {
|
||||
truncated = true;
|
||||
nodes = nodes.slice(0, maxNodes);
|
||||
const nodeQns = new Set(nodes.map((n: { qualifiedName: string }) => n.qualifiedName));
|
||||
edges = edges.filter(
|
||||
(e: { sourceQualified: string; targetQualified: string }) =>
|
||||
nodeQns.has(e.sourceQualified) && nodeQns.has(e.targetQualified)
|
||||
);
|
||||
}
|
||||
|
||||
this.panel.webview.postMessage({
|
||||
command: "setData",
|
||||
nodes,
|
||||
edges,
|
||||
truncated,
|
||||
maxNodes,
|
||||
});
|
||||
|
||||
// Send theme
|
||||
const themeKind =
|
||||
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
|
||||
vscode.window.activeColorTheme.kind ===
|
||||
vscode.ColorThemeKind.HighContrastLight
|
||||
? "light"
|
||||
: "dark";
|
||||
this.panel.webview.postMessage({
|
||||
command: "setTheme",
|
||||
theme: themeKind,
|
||||
});
|
||||
|
||||
// Highlight node if requested
|
||||
if (this.highlightQualifiedName) {
|
||||
// Small delay to let the graph render first
|
||||
setTimeout(() => {
|
||||
this.panel.webview.postMessage({
|
||||
command: "highlightNode",
|
||||
qualifiedName: this.highlightQualifiedName,
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a file in the editor at a specific line.
|
||||
*/
|
||||
private async openFileAtLine(
|
||||
filePath: string,
|
||||
lineStart: number
|
||||
): Promise<void> {
|
||||
const workspaceRoot =
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const fullPath = workspaceRoot
|
||||
? path.join(workspaceRoot, filePath)
|
||||
: filePath;
|
||||
|
||||
try {
|
||||
const doc = await vscode.workspace.openTextDocument(fullPath);
|
||||
const line = Math.max(0, (lineStart ?? 1) - 1);
|
||||
await vscode.window.showTextDocument(doc, {
|
||||
viewColumn: vscode.ViewColumn.One,
|
||||
selection: new vscode.Range(line, 0, line, 0),
|
||||
preserveFocus: false,
|
||||
});
|
||||
} catch {
|
||||
vscode.window.showWarningMessage(
|
||||
`Code Graph: Could not open file ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy SVG string to clipboard.
|
||||
*/
|
||||
private async exportSvgToClipboard(svgString: string): Promise<void> {
|
||||
await vscode.env.clipboard.writeText(svgString);
|
||||
vscode.window.showInformationMessage(
|
||||
"Code Graph: SVG copied to clipboard."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PNG data URL to a file.
|
||||
*/
|
||||
private async savePngToFile(dataUrl: string): Promise<void> {
|
||||
const uri = await vscode.window.showSaveDialog({
|
||||
defaultUri: vscode.Uri.file("code-graph.png"),
|
||||
filters: { "PNG Image": ["png"] },
|
||||
});
|
||||
if (!uri) { return; }
|
||||
|
||||
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
const buffer = Buffer.from(base64, "base64");
|
||||
await vscode.workspace.fs.writeFile(uri, buffer);
|
||||
vscode.window.showInformationMessage("Code Graph: PNG saved.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight a node by qualified name from external code (tree view click).
|
||||
*/
|
||||
static highlightNode(qualifiedName: string): void {
|
||||
if (GraphWebviewPanel.currentPanel) {
|
||||
GraphWebviewPanel.currentPanel.panel.webview.postMessage({
|
||||
command: "highlightNode",
|
||||
qualifiedName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTML content
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private getHtmlContent(
|
||||
webview: vscode.Webview,
|
||||
extensionUri: vscode.Uri
|
||||
): string {
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, "dist", "webview", "graph.js")
|
||||
);
|
||||
|
||||
const nonce = getNonce();
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; img-src ${webview.cspSource};">
|
||||
<title>Code Graph</title>
|
||||
<style>
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* CSS variables from VS Code theme */
|
||||
/* ------------------------------------------------------------------ */
|
||||
:root {
|
||||
--bg: var(--vscode-editor-background, #1e1e2e);
|
||||
--fg: var(--vscode-editor-foreground, #cdd6f4);
|
||||
--toolbar-bg: var(--vscode-sideBar-background, #181825);
|
||||
--toolbar-border: var(--vscode-panel-border, #313244);
|
||||
--input-bg: var(--vscode-input-background, #313244);
|
||||
--input-fg: var(--vscode-input-foreground, #cdd6f4);
|
||||
--input-border: var(--vscode-input-border, #45475a);
|
||||
--btn-bg: var(--vscode-button-background, #89b4fa);
|
||||
--btn-fg: var(--vscode-button-foreground, #1e1e2e);
|
||||
--btn-hover: var(--vscode-button-hoverBackground, #74c7ec);
|
||||
--badge-bg: var(--vscode-badge-background, #45475a);
|
||||
--badge-fg: var(--vscode-badge-foreground, #cdd6f4);
|
||||
--font: var(--vscode-font-family, 'Segoe UI', sans-serif);
|
||||
--font-size: var(--vscode-font-size, 13px);
|
||||
--font-mono: var(--vscode-editor-font-family, 'Fira Code', monospace);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: var(--font-size);
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Toolbar */
|
||||
/* ------------------------------------------------------------------ */
|
||||
#toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
background: var(--toolbar-bg);
|
||||
border-bottom: 1px solid var(--toolbar-border);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
#toolbar .toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#toolbar .toolbar-label {
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#toolbar .toolbar-separator {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: var(--toolbar-border);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* Search */
|
||||
#search-input {
|
||||
background: var(--input-bg);
|
||||
color: var(--input-fg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font);
|
||||
width: 180px;
|
||||
outline: none;
|
||||
}
|
||||
#search-input:focus {
|
||||
border-color: var(--btn-bg);
|
||||
}
|
||||
#search-input::placeholder {
|
||||
color: var(--fg);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Edge pills */
|
||||
.edge-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border: 1px solid transparent;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s, border-color 0.15s;
|
||||
}
|
||||
.edge-pill.active {
|
||||
opacity: 1;
|
||||
border-color: currentColor;
|
||||
}
|
||||
.edge-pill:focus-visible { outline: 2px solid var(--btn-bg, #89b4fa); outline-offset: 2px; }
|
||||
.edge-pill .pill-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Edge filter popover */
|
||||
#edge-popover {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--toolbar-bg);
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
min-width: 160px;
|
||||
}
|
||||
#edge-popover.visible { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
#edge-filter-wrap { position: relative; }
|
||||
|
||||
/* Depth slider */
|
||||
#depth-slider {
|
||||
width: 80px;
|
||||
accent-color: var(--btn-bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
#depth-slider:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#depth-value {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Toolbar buttons */
|
||||
.toolbar-btn {
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-fg);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.toolbar-btn:hover {
|
||||
background: var(--btn-bg);
|
||||
color: var(--btn-fg);
|
||||
}
|
||||
|
||||
/* Node count badge */
|
||||
#node-count {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--fg);
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Graph area */
|
||||
/* ------------------------------------------------------------------ */
|
||||
#graph-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
#graph-area svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Tooltip */
|
||||
/* ------------------------------------------------------------------ */
|
||||
#tooltip {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--toolbar-bg);
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--fg);
|
||||
max-width: 360px;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
#tooltip strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
#tooltip .tooltip-kind {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--badge-bg);
|
||||
color: var(--badge-fg);
|
||||
margin: 2px 0;
|
||||
}
|
||||
#tooltip .tooltip-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
#tooltip .tooltip-params {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--vscode-debugTokenExpression-string, #a6e3a1);
|
||||
}
|
||||
#tooltip .tooltip-return {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--vscode-debugTokenExpression-number, #89b4fa);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Pulse animation for highlighted nodes */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@keyframes pulse {
|
||||
0% { r: 16; stroke-opacity: 1; }
|
||||
100% { r: 30; stroke-opacity: 0; }
|
||||
}
|
||||
.pulse-ring {
|
||||
animation: pulse 0.6s ease-out;
|
||||
}
|
||||
|
||||
path.node-shape:focus { outline: none; }
|
||||
path.node-shape:focus-visible { stroke: var(--btn-bg, #89b4fa) !important; stroke-width: 3 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Toolbar -->
|
||||
<div id="toolbar">
|
||||
<!-- Search -->
|
||||
<div class="toolbar-group">
|
||||
<input id="search-input" type="text" placeholder="Search nodes..." spellcheck="false" aria-label="Search nodes" />
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- Edge type filter -->
|
||||
<div class="toolbar-group" id="edge-filter-wrap">
|
||||
<button id="btn-edge-filter" class="toolbar-btn" title="Toggle edge type filters" aria-label="Toggle edge type filters">Edges</button>
|
||||
<div id="edge-popover">
|
||||
<span id="edge-CALLS" class="edge-pill active" style="color:#3fb950" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Calls edges" title="Toggle CALLS edges"><span class="pill-dot" style="background:#3fb950"></span>Calls</span>
|
||||
<span id="edge-IMPORTS_FROM" class="edge-pill active" style="color:#f0883e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Imports edges" title="Toggle IMPORTS_FROM edges"><span class="pill-dot" style="background:#f0883e"></span>Imports</span>
|
||||
<span id="edge-INHERITS" class="edge-pill active" style="color:#d2a8ff" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Inherits edges" title="Toggle INHERITS edges"><span class="pill-dot" style="background:#d2a8ff"></span>Inherits</span>
|
||||
<span id="edge-IMPLEMENTS" class="edge-pill active" style="color:#f9e2af" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Implements edges" title="Toggle IMPLEMENTS edges"><span class="pill-dot" style="background:#f9e2af"></span>Implements</span>
|
||||
<span id="edge-TESTED_BY" class="edge-pill active" style="color:#f38ba8" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Tested edges" title="Toggle TESTED_BY edges"><span class="pill-dot" style="background:#f38ba8"></span>Tested</span>
|
||||
<span id="edge-CONTAINS" class="edge-pill active" style="color:#8b949e" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Contains edges" title="Toggle CONTAINS edges"><span class="pill-dot" style="background:#8b949e"></span>Contains</span>
|
||||
<span id="edge-DEPENDS_ON" class="edge-pill active" style="color:#fab387" role="button" tabindex="0" aria-pressed="true" aria-label="Toggle Depends edges" title="Toggle DEPENDS_ON edges"><span class="pill-dot" style="background:#fab387"></span>Depends</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- Depth slider -->
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Depth</span>
|
||||
<input id="depth-slider" type="range" min="0" max="10" value="0" aria-label="Graph depth limit" disabled title="Select a node first, then adjust depth" />
|
||||
<span id="depth-value">All</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-separator"></div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="toolbar-group">
|
||||
<button id="btn-fit" class="toolbar-btn">Fit</button>
|
||||
<button id="btn-export" class="toolbar-btn">Export SVG</button>
|
||||
<button id="btn-export-png" class="toolbar-btn">Export PNG</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Graph -->
|
||||
<div id="graph-area">
|
||||
<span id="node-count" aria-live="polite"></span>
|
||||
<span id="truncation-warning" style="display:none;color:var(--btn-bg);font-size:11px;cursor:pointer;" title="Increase codeReviewGraph.graph.maxNodes in settings"></span>
|
||||
<div id="empty-state" style="display:none;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;color:var(--fg);opacity:0.6">
|
||||
<p style="font-size:16px;margin-bottom:12px">No graph data available</p>
|
||||
<p style="font-size:13px">Run <strong>Code Graph: Build Graph</strong> from the Command Palette to get started.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div id="tooltip" role="tooltip" aria-live="polite"></div>
|
||||
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getNonce(): string {
|
||||
return crypto.randomBytes(16).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader } from '../backend/sqlite';
|
||||
|
||||
/** Number of milliseconds in one hour. */
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Manages a status bar item that shows a summary of the code graph
|
||||
* database and its staleness.
|
||||
*
|
||||
* Clicking the status bar item triggers `codeReviewGraph.updateGraph`.
|
||||
*/
|
||||
export class StatusBar implements vscode.Disposable {
|
||||
private item: vscode.StatusBarItem;
|
||||
|
||||
constructor() {
|
||||
this.item = vscode.window.createStatusBarItem(
|
||||
vscode.StatusBarAlignment.Left,
|
||||
100,
|
||||
);
|
||||
this.item.command = 'codeReviewGraph.updateGraph';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the status bar text, icon, and tooltip based on the current
|
||||
* state of the graph database.
|
||||
*
|
||||
* @param reader The open SQLite reader, or `undefined` if no database
|
||||
* is loaded.
|
||||
*/
|
||||
update(reader: SqliteReader | undefined): void {
|
||||
if (!reader) {
|
||||
this.item.text = '$(warning) Code Graph: Not built';
|
||||
this.item.tooltip = 'Click to build';
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = reader.getStats();
|
||||
|
||||
const lastUpdated = stats.lastUpdated;
|
||||
const isOutdated = this.isOlderThanOneHour(lastUpdated);
|
||||
|
||||
if (isOutdated) {
|
||||
this.item.text = '$(warning) Code Graph: Outdated';
|
||||
this.item.tooltip =
|
||||
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
|
||||
`Last updated: ${lastUpdated || 'unknown'}`;
|
||||
} else {
|
||||
this.item.text = `$(database) ${stats.totalNodes} nodes`;
|
||||
this.item.tooltip =
|
||||
`Code Graph: ${stats.filesCount} files, ${stats.totalEdges} edges\n` +
|
||||
`Last updated: ${lastUpdated || 'unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Show the status bar item. */
|
||||
show(): void {
|
||||
this.item.show();
|
||||
}
|
||||
|
||||
/** Hide the status bar item. */
|
||||
hide(): void {
|
||||
this.item.hide();
|
||||
}
|
||||
|
||||
/** Dispose the status bar item. */
|
||||
dispose(): void {
|
||||
this.item.dispose();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether `lastUpdated` is more than one hour in the past.
|
||||
*
|
||||
* Returns `true` if the timestamp is missing, unparseable, or older
|
||||
* than one hour.
|
||||
*/
|
||||
private isOlderThanOneHour(lastUpdated: string | null): boolean {
|
||||
if (!lastUpdated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const updatedTime = new Date(lastUpdated).getTime();
|
||||
if (isNaN(updatedTime)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() - updatedTime > ONE_HOUR_MS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FileTreeItem – represents a source file in the code graph
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class FileTreeItem extends vscode.TreeItem {
|
||||
public readonly filePath: string;
|
||||
public readonly qualifiedName: string;
|
||||
|
||||
constructor(filePath: string, workspaceRoot: string) {
|
||||
const fileName = path.basename(filePath);
|
||||
super(fileName, vscode.TreeItemCollapsibleState.Collapsed);
|
||||
|
||||
this.filePath = filePath;
|
||||
this.qualifiedName = filePath;
|
||||
|
||||
const relativePath = path.relative(workspaceRoot, filePath);
|
||||
this.description = relativePath !== fileName ? relativePath : '';
|
||||
this.iconPath = new vscode.ThemeIcon('file');
|
||||
this.contextValue = 'node-file';
|
||||
this.tooltip = filePath;
|
||||
|
||||
this.command = {
|
||||
title: 'Open File',
|
||||
command: 'vscode.open',
|
||||
arguments: [vscode.Uri.file(filePath)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SymbolTreeItem – represents a class, function, type, or test node
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KIND_ICON_MAP: Record<string, string> = {
|
||||
Function: 'symbol-method',
|
||||
Class: 'symbol-class',
|
||||
Type: 'symbol-interface',
|
||||
Test: 'testing-run-icon',
|
||||
};
|
||||
|
||||
const KIND_CONTEXT_MAP: Record<string, string> = {
|
||||
Function: 'node-function',
|
||||
Class: 'node-class',
|
||||
Type: 'node-type',
|
||||
Test: 'node-test',
|
||||
};
|
||||
|
||||
function formatSymbolLabel(name: string, kind: string): string {
|
||||
if (kind === 'Function' || kind === 'Test') {
|
||||
return `${name}()`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function formatSymbolDescription(kind: string, lineStart: number | null, lineEnd: number | null): string {
|
||||
const kindLower = kind.toLowerCase();
|
||||
if (lineStart != null && lineEnd != null) {
|
||||
return `${kindLower} \u00b7 L${lineStart}\u2013${lineEnd}`;
|
||||
}
|
||||
if (lineStart != null) {
|
||||
return `${kindLower} \u00b7 L${lineStart}`;
|
||||
}
|
||||
return kindLower;
|
||||
}
|
||||
|
||||
export class SymbolTreeItem extends vscode.TreeItem {
|
||||
public readonly qualifiedName: string;
|
||||
public readonly filePath: string;
|
||||
public readonly lineStart: number | null;
|
||||
public readonly kind: string;
|
||||
|
||||
constructor(
|
||||
qualifiedName: string,
|
||||
name: string,
|
||||
kind: string,
|
||||
filePath: string,
|
||||
lineStart: number | null,
|
||||
lineEnd: number | null,
|
||||
) {
|
||||
const label = formatSymbolLabel(name, kind);
|
||||
super(label, vscode.TreeItemCollapsibleState.Collapsed);
|
||||
|
||||
this.qualifiedName = qualifiedName;
|
||||
this.filePath = filePath;
|
||||
this.lineStart = lineStart;
|
||||
this.kind = kind;
|
||||
|
||||
this.description = formatSymbolDescription(kind, lineStart, lineEnd);
|
||||
this.iconPath = new vscode.ThemeIcon(KIND_ICON_MAP[kind] ?? 'symbol-misc');
|
||||
this.contextValue = KIND_CONTEXT_MAP[kind] ?? 'node-function';
|
||||
this.tooltip = qualifiedName;
|
||||
|
||||
const line = lineStart != null ? lineStart - 1 : 0;
|
||||
this.command = {
|
||||
title: 'Go to Symbol',
|
||||
command: 'vscode.open',
|
||||
arguments: [
|
||||
vscode.Uri.file(filePath),
|
||||
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EdgeTreeItem – represents a relationship edge (leaf node)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OUTGOING_EDGE_LABELS: Record<string, string> = {
|
||||
CALLS: 'calls',
|
||||
IMPORTS_FROM: 'imports',
|
||||
INHERITS: 'inherits from',
|
||||
IMPLEMENTS: 'implements',
|
||||
TESTED_BY: 'tested by',
|
||||
CONTAINS: 'contains',
|
||||
DEPENDS_ON: 'depends on',
|
||||
};
|
||||
|
||||
const INCOMING_EDGE_LABELS: Record<string, string> = {
|
||||
CALLS: 'called by',
|
||||
IMPORTS_FROM: 'imported by',
|
||||
INHERITS: 'inherited by',
|
||||
IMPLEMENTS: 'implemented by',
|
||||
TESTED_BY: 'tests',
|
||||
CONTAINS: 'contained in',
|
||||
DEPENDS_ON: 'depended on by',
|
||||
};
|
||||
|
||||
const EDGE_ICON_MAP_OUTGOING: Record<string, string> = {
|
||||
CALLS: 'arrow-right',
|
||||
IMPORTS_FROM: 'package',
|
||||
INHERITS: 'type-hierarchy',
|
||||
IMPLEMENTS: 'symbol-interface',
|
||||
TESTED_BY: 'testing-run-icon',
|
||||
CONTAINS: 'symbol-namespace',
|
||||
DEPENDS_ON: 'references',
|
||||
};
|
||||
|
||||
const EDGE_ICON_MAP_INCOMING: Record<string, string> = {
|
||||
CALLS: 'arrow-left',
|
||||
IMPORTS_FROM: 'package',
|
||||
INHERITS: 'type-hierarchy',
|
||||
IMPLEMENTS: 'symbol-interface',
|
||||
TESTED_BY: 'testing-run-icon',
|
||||
CONTAINS: 'symbol-namespace',
|
||||
DEPENDS_ON: 'references',
|
||||
};
|
||||
|
||||
function extractShortName(qualifiedName: string): string {
|
||||
// Qualified names are like "/path/to/file.py::ClassName.method" or "/path/to/file.py"
|
||||
const colonIdx = qualifiedName.lastIndexOf('::');
|
||||
if (colonIdx >= 0) {
|
||||
return qualifiedName.substring(colonIdx + 2);
|
||||
}
|
||||
return path.basename(qualifiedName);
|
||||
}
|
||||
|
||||
export class EdgeTreeItem extends vscode.TreeItem {
|
||||
public readonly targetQualifiedName: string;
|
||||
public readonly targetFilePath: string;
|
||||
public readonly targetLine: number;
|
||||
|
||||
constructor(
|
||||
edgeKind: string,
|
||||
direction: 'outgoing' | 'incoming',
|
||||
targetQualifiedName: string,
|
||||
targetFilePath: string,
|
||||
targetLine: number,
|
||||
) {
|
||||
const shortName = extractShortName(targetQualifiedName);
|
||||
const verb = direction === 'outgoing'
|
||||
? (OUTGOING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase())
|
||||
: (INCOMING_EDGE_LABELS[edgeKind] ?? edgeKind.toLowerCase());
|
||||
const arrow = direction === 'outgoing' ? '\u2192' : '\u2190';
|
||||
const label = `${arrow} ${verb} ${shortName}`;
|
||||
|
||||
super(label, vscode.TreeItemCollapsibleState.None);
|
||||
|
||||
this.targetQualifiedName = targetQualifiedName;
|
||||
this.targetFilePath = targetFilePath;
|
||||
this.targetLine = targetLine;
|
||||
|
||||
const iconMap = direction === 'outgoing' ? EDGE_ICON_MAP_OUTGOING : EDGE_ICON_MAP_INCOMING;
|
||||
this.iconPath = new vscode.ThemeIcon(iconMap[edgeKind] ?? 'arrow-right');
|
||||
this.contextValue = 'edge';
|
||||
this.tooltip = `${arrow} ${verb} ${targetQualifiedName}`;
|
||||
|
||||
const line = targetLine > 0 ? targetLine - 1 : 0;
|
||||
this.command = {
|
||||
title: 'Go to Target',
|
||||
command: 'vscode.open',
|
||||
arguments: [
|
||||
vscode.Uri.file(targetFilePath),
|
||||
{ selection: new vscode.Range(line, 0, line, 0) } as vscode.TextDocumentShowOptions,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlastRadiusGroupItem – groups "Changed" and "Impacted" results
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BlastRadiusGroupItem extends vscode.TreeItem {
|
||||
public readonly groupKind: 'changed' | 'impacted';
|
||||
|
||||
constructor(groupKind: 'changed' | 'impacted', count: number) {
|
||||
const label = groupKind === 'changed' ? `Changed (${count})` : `Impacted (${count})`;
|
||||
super(label, vscode.TreeItemCollapsibleState.Expanded);
|
||||
|
||||
this.groupKind = groupKind;
|
||||
this.iconPath = new vscode.ThemeIcon(groupKind === 'changed' ? 'flame' : 'broadcast');
|
||||
this.contextValue = `blast-radius-${groupKind}`;
|
||||
this.tooltip = groupKind === 'changed'
|
||||
? `${count} directly changed node(s)`
|
||||
: `${count} transitively impacted node(s)`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatsItem – displays a single statistic line (leaf node)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class StatsItem extends vscode.TreeItem {
|
||||
constructor(label: string, value: string) {
|
||||
super(label, vscode.TreeItemCollapsibleState.None);
|
||||
this.description = value;
|
||||
this.contextValue = 'stat';
|
||||
this.tooltip = `${label}: ${value}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { SqliteReader, GraphNode, GraphEdge } from '../backend/sqlite';
|
||||
import {
|
||||
FileTreeItem,
|
||||
SymbolTreeItem,
|
||||
EdgeTreeItem,
|
||||
BlastRadiusGroupItem,
|
||||
StatsItem,
|
||||
} from './treeItems';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CodeGraphTreeProvider -- main file > symbol > edge tree
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CodeGraphTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
|
||||
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
|
||||
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
|
||||
|
||||
constructor(
|
||||
private readonly reader: SqliteReader,
|
||||
private readonly workspaceRoot: string,
|
||||
) {}
|
||||
|
||||
refresh(): void {
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
|
||||
if (!element) {
|
||||
return this.getRootChildren();
|
||||
}
|
||||
if (element instanceof FileTreeItem) {
|
||||
return this.getFileChildren(element);
|
||||
}
|
||||
if (element instanceof SymbolTreeItem) {
|
||||
return this.getSymbolChildren(element);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// -- Root level: one FileTreeItem per file --------------------------------
|
||||
|
||||
private getRootChildren(): vscode.TreeItem[] {
|
||||
const files = this.reader.getAllFiles();
|
||||
return files
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((filePath) => new FileTreeItem(filePath, this.workspaceRoot));
|
||||
}
|
||||
|
||||
// -- File level: symbols (non-File nodes) sorted by line ------------------
|
||||
|
||||
private getFileChildren(fileItem: FileTreeItem): vscode.TreeItem[] {
|
||||
const nodes = this.reader.getNodesByFile(fileItem.filePath);
|
||||
return nodes
|
||||
.filter((n) => n.kind !== 'File')
|
||||
.sort((a, b) => (a.lineStart ?? 0) - (b.lineStart ?? 0))
|
||||
.map(
|
||||
(n) =>
|
||||
new SymbolTreeItem(
|
||||
n.qualifiedName,
|
||||
n.name,
|
||||
n.kind,
|
||||
n.filePath,
|
||||
n.lineStart,
|
||||
n.lineEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Symbol level: outgoing + incoming edges (skip CONTAINS) --------------
|
||||
|
||||
private getSymbolChildren(symbolItem: SymbolTreeItem): vscode.TreeItem[] {
|
||||
const items: vscode.TreeItem[] = [];
|
||||
|
||||
// Outgoing edges
|
||||
const outgoing = this.reader.getEdgesBySource(symbolItem.qualifiedName);
|
||||
for (const edge of outgoing) {
|
||||
if (edge.kind === 'CONTAINS') {
|
||||
continue;
|
||||
}
|
||||
const targetNode = this.reader.getNode(edge.targetQualified);
|
||||
const targetFile = targetNode?.filePath ?? edge.filePath;
|
||||
const targetLine = targetNode?.lineStart ?? edge.line;
|
||||
items.push(
|
||||
new EdgeTreeItem(
|
||||
edge.kind,
|
||||
'outgoing',
|
||||
edge.targetQualified,
|
||||
targetFile,
|
||||
targetLine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Incoming edges
|
||||
const incoming = this.reader.getEdgesByTarget(symbolItem.qualifiedName);
|
||||
for (const edge of incoming) {
|
||||
if (edge.kind === 'CONTAINS') {
|
||||
continue;
|
||||
}
|
||||
const sourceNode = this.reader.getNode(edge.sourceQualified);
|
||||
const sourceFile = sourceNode?.filePath ?? edge.filePath;
|
||||
const sourceLine = sourceNode?.lineStart ?? edge.line;
|
||||
items.push(
|
||||
new EdgeTreeItem(
|
||||
edge.kind,
|
||||
'incoming',
|
||||
edge.sourceQualified,
|
||||
sourceFile,
|
||||
sourceLine,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlastRadiusTreeProvider -- shows changed + impacted nodes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BlastRadiusTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
|
||||
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
|
||||
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
|
||||
|
||||
private changedNodes: GraphNode[] = [];
|
||||
private impactedNodes: GraphNode[] = [];
|
||||
|
||||
setResults(changed: GraphNode[], impacted: GraphNode[]): void {
|
||||
this.changedNodes = changed;
|
||||
this.impactedNodes = impacted;
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.changedNodes = [];
|
||||
this.impactedNodes = [];
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(element?: vscode.TreeItem): vscode.ProviderResult<vscode.TreeItem[]> {
|
||||
if (!element) {
|
||||
return this.getRootChildren();
|
||||
}
|
||||
if (element instanceof BlastRadiusGroupItem) {
|
||||
return this.getGroupChildren(element);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private getRootChildren(): vscode.TreeItem[] {
|
||||
if (this.changedNodes.length === 0 && this.impactedNodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const groups: vscode.TreeItem[] = [];
|
||||
if (this.changedNodes.length > 0) {
|
||||
groups.push(new BlastRadiusGroupItem('changed', this.changedNodes.length));
|
||||
}
|
||||
if (this.impactedNodes.length > 0) {
|
||||
groups.push(new BlastRadiusGroupItem('impacted', this.impactedNodes.length));
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
private getGroupChildren(group: BlastRadiusGroupItem): vscode.TreeItem[] {
|
||||
const nodes = group.groupKind === 'changed' ? this.changedNodes : this.impactedNodes;
|
||||
return nodes.map(
|
||||
(n) =>
|
||||
new SymbolTreeItem(
|
||||
n.qualifiedName,
|
||||
n.name,
|
||||
n.kind,
|
||||
n.filePath,
|
||||
n.lineStart,
|
||||
n.lineEnd,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatsTreeProvider -- graph statistics overview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class StatsTreeProvider implements vscode.TreeDataProvider<vscode.TreeItem> {
|
||||
private readonly _onDidChangeTreeData = new vscode.EventEmitter<vscode.TreeItem | undefined | null>();
|
||||
readonly onDidChangeTreeData: vscode.Event<vscode.TreeItem | undefined | null> = this._onDidChangeTreeData.event;
|
||||
|
||||
constructor(private readonly reader: SqliteReader) {}
|
||||
|
||||
refresh(): void {
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: vscode.TreeItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(): vscode.ProviderResult<vscode.TreeItem[]> {
|
||||
const stats = this.reader.getStats();
|
||||
const items: StatsItem[] = [];
|
||||
|
||||
items.push(new StatsItem('Files', stats.filesCount.toLocaleString()));
|
||||
items.push(new StatsItem('Total Nodes', stats.totalNodes.toLocaleString()));
|
||||
items.push(new StatsItem('Total Edges', stats.totalEdges.toLocaleString()));
|
||||
items.push(
|
||||
new StatsItem(
|
||||
'Languages',
|
||||
stats.languages.length > 0 ? stats.languages.join(', ') : 'none',
|
||||
),
|
||||
);
|
||||
items.push(
|
||||
new StatsItem(
|
||||
'Last Updated',
|
||||
stats.lastUpdated ?? 'unknown',
|
||||
),
|
||||
);
|
||||
items.push(
|
||||
new StatsItem(
|
||||
'Embeddings',
|
||||
stats.embeddingsCount > 0 ? stats.embeddingsCount.toLocaleString() : 'none',
|
||||
),
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,990 @@
|
||||
/**
|
||||
* Webview entry point for the D3.js force-directed graph visualization.
|
||||
* Runs in the browser context inside the VS Code webview panel.
|
||||
*
|
||||
* Communicates with the extension host via postMessage / addEventListener.
|
||||
* NO Node.js APIs are available here.
|
||||
*/
|
||||
|
||||
import * as d3 from "d3";
|
||||
|
||||
declare function acquireVsCodeApi(): {
|
||||
postMessage(msg: unknown): void;
|
||||
getState(): unknown;
|
||||
setState(state: unknown): void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type NodeKind = "File" | "Class" | "Function" | "Test" | "Type";
|
||||
|
||||
type EdgeKind =
|
||||
| "CALLS"
|
||||
| "IMPORTS_FROM"
|
||||
| "INHERITS"
|
||||
| "IMPLEMENTS"
|
||||
| "TESTED_BY"
|
||||
| "CONTAINS"
|
||||
| "DEPENDS_ON";
|
||||
|
||||
interface GraphNode {
|
||||
id: number;
|
||||
kind: NodeKind;
|
||||
name: string;
|
||||
qualifiedName: string;
|
||||
filePath: string;
|
||||
lineStart: number | null;
|
||||
lineEnd: number | null;
|
||||
language: string | null;
|
||||
parentName: string | null;
|
||||
params: string | null;
|
||||
returnType: string | null;
|
||||
modifiers: string | null;
|
||||
isTest: boolean;
|
||||
fileHash: string | null;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
id: number;
|
||||
kind: EdgeKind;
|
||||
sourceQualified: string;
|
||||
targetQualified: string;
|
||||
filePath: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** D3 simulation node extends GraphNode with x/y/vx/vy. */
|
||||
interface SimNode extends d3.SimulationNodeDatum, GraphNode {}
|
||||
|
||||
/** D3 simulation link with resolved source/target. */
|
||||
interface SimLink extends d3.SimulationLinkDatum<SimNode> {
|
||||
kind: EdgeKind;
|
||||
sourceQualified: string;
|
||||
targetQualified: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NODE_RADIUS: Record<NodeKind, number> = {
|
||||
File: 18,
|
||||
Class: 12,
|
||||
Function: 6,
|
||||
Test: 6,
|
||||
Type: 5,
|
||||
};
|
||||
|
||||
const NODE_COLOR: Record<NodeKind, string> = {
|
||||
File: "#58a6ff",
|
||||
Class: "#f0883e",
|
||||
Function: "#3fb950",
|
||||
Test: "#d2a8ff",
|
||||
Type: "#8b949e",
|
||||
};
|
||||
|
||||
const NODE_SHAPE: Record<NodeKind, d3.SymbolType> = {
|
||||
File: d3.symbolCircle,
|
||||
Class: d3.symbolSquare,
|
||||
Function: d3.symbolTriangle,
|
||||
Test: d3.symbolDiamond,
|
||||
Type: d3.symbolCross,
|
||||
};
|
||||
|
||||
const NODE_AREA: Record<NodeKind, number> = {
|
||||
File: 616,
|
||||
Class: 452,
|
||||
Function: 314,
|
||||
Test: 314,
|
||||
Type: 314,
|
||||
};
|
||||
|
||||
const EDGE_COLOR: Record<EdgeKind, string> = {
|
||||
CALLS: "#3fb950",
|
||||
IMPORTS_FROM: "#f0883e",
|
||||
INHERITS: "#d2a8ff",
|
||||
IMPLEMENTS: "#f9e2af",
|
||||
TESTED_BY: "#f38ba8",
|
||||
CONTAINS: "rgba(139,148,158,0.15)",
|
||||
DEPENDS_ON: "#fab387",
|
||||
};
|
||||
|
||||
const ALL_EDGE_KINDS: EdgeKind[] = [
|
||||
"CALLS",
|
||||
"IMPORTS_FROM",
|
||||
"INHERITS",
|
||||
"IMPLEMENTS",
|
||||
"TESTED_BY",
|
||||
"CONTAINS",
|
||||
"DEPENDS_ON",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const vscodeApi = acquireVsCodeApi();
|
||||
|
||||
let allNodes: SimNode[] = [];
|
||||
let allEdges: SimLink[] = [];
|
||||
let nodeMap = new Map<string, SimNode>();
|
||||
|
||||
let visibleEdgeKinds = new Set<EdgeKind>(ALL_EDGE_KINDS);
|
||||
let selectedNode: SimNode | null = null;
|
||||
let depthLimit = 0; // 0 = show all
|
||||
|
||||
let simulation: d3.Simulation<SimNode, SimLink> | null = null;
|
||||
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>;
|
||||
let container: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
let linkGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
let nodeGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
let labelGroup: d3.Selection<SVGGElement, unknown, null, undefined>;
|
||||
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown>;
|
||||
|
||||
let linkSelection: d3.Selection<SVGLineElement, SimLink, SVGGElement, unknown>;
|
||||
let nodeSelection: d3.Selection<SVGPathElement, SimNode, SVGGElement, unknown>;
|
||||
let labelSelection: d3.Selection<SVGTextElement, SimNode, SVGGElement, unknown>;
|
||||
|
||||
let currentTheme: "dark" | "light" = "dark";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function init(): void {
|
||||
createSvg();
|
||||
bindToolbarEvents();
|
||||
bindExtensionMessages();
|
||||
|
||||
vscodeApi.postMessage({ command: "ready" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSvg(): void {
|
||||
const graphEl = document.getElementById("graph-area")!;
|
||||
const width = graphEl.clientWidth || window.innerWidth;
|
||||
const height = graphEl.clientHeight || window.innerHeight;
|
||||
|
||||
svg = d3
|
||||
.select(graphEl)
|
||||
.append("svg")
|
||||
.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
||||
|
||||
// Arrow marker definitions -- one per edge kind
|
||||
const defs = svg.append("defs");
|
||||
for (const kind of ALL_EDGE_KINDS) {
|
||||
defs
|
||||
.append("marker")
|
||||
.attr("id", `arrow-${kind}`)
|
||||
.attr("viewBox", "0 -5 10 10")
|
||||
.attr("refX", 20)
|
||||
.attr("refY", 0)
|
||||
.attr("markerWidth", 6)
|
||||
.attr("markerHeight", 6)
|
||||
.attr("orient", "auto")
|
||||
.append("path")
|
||||
.attr("d", "M0,-5L10,0L0,5")
|
||||
.attr("fill", EDGE_COLOR[kind]);
|
||||
}
|
||||
|
||||
container = svg.append("g").attr("class", "graph-container");
|
||||
linkGroup = container.append("g").attr("class", "links");
|
||||
nodeGroup = container.append("g").attr("class", "nodes");
|
||||
labelGroup = container.append("g").attr("class", "labels");
|
||||
|
||||
// Initialize empty selections
|
||||
linkSelection = linkGroup.selectAll<SVGLineElement, SimLink>("line");
|
||||
nodeSelection = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape");
|
||||
labelSelection = labelGroup.selectAll<SVGTextElement, SimNode>("text");
|
||||
|
||||
// Zoom + pan
|
||||
zoomBehavior = d3
|
||||
.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.05, 8])
|
||||
.on("zoom", (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
|
||||
container.attr("transform", event.transform.toString());
|
||||
});
|
||||
|
||||
svg.call(zoomBehavior);
|
||||
|
||||
// Resize handler
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const w = graphEl.clientWidth;
|
||||
const h = graphEl.clientHeight;
|
||||
svg.attr("viewBox", `0 0 ${w} ${h}`);
|
||||
});
|
||||
resizeObserver.observe(graphEl);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data ingestion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setData(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
// Build SimNodes
|
||||
allNodes = nodes.map((n) => ({ ...n } as SimNode));
|
||||
nodeMap = new Map(allNodes.map((n) => [n.qualifiedName, n]));
|
||||
|
||||
// Build SimLinks, filtering to edges where both endpoints exist
|
||||
allEdges = [];
|
||||
for (const e of edges) {
|
||||
const src = nodeMap.get(e.sourceQualified);
|
||||
const tgt = nodeMap.get(e.targetQualified);
|
||||
if (src && tgt) {
|
||||
allEdges.push({
|
||||
source: src,
|
||||
target: tgt,
|
||||
kind: e.kind,
|
||||
sourceQualified: e.sourceQualified,
|
||||
targetQualified: e.targetQualified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset depth filter
|
||||
depthLimit = 0;
|
||||
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
|
||||
if (slider) {
|
||||
slider.value = "0";
|
||||
}
|
||||
const depthValue = document.getElementById("depth-value");
|
||||
if (depthValue) {
|
||||
depthValue.textContent = "All";
|
||||
}
|
||||
|
||||
// Show/hide empty state
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
const graphArea = document.getElementById("graph-area");
|
||||
if (nodes.length === 0) {
|
||||
if (emptyState) emptyState.style.display = "block";
|
||||
if (graphArea) {
|
||||
// Hide the SVG but keep the container
|
||||
const svgHide = graphArea.querySelector("svg");
|
||||
if (svgHide) svgHide.style.display = "none";
|
||||
}
|
||||
updateDepthSliderState();
|
||||
return;
|
||||
}
|
||||
if (emptyState) emptyState.style.display = "none";
|
||||
const svgEl = graphArea?.querySelector("svg");
|
||||
if (svgEl) svgEl.style.display = "";
|
||||
|
||||
buildGraph();
|
||||
|
||||
updateDepthSliderState();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getVisibleData(): { nodes: SimNode[]; links: SimLink[] } {
|
||||
// Filter edges by visible kinds
|
||||
let links = allEdges.filter((e) => visibleEdgeKinds.has(e.kind));
|
||||
|
||||
let nodes: SimNode[];
|
||||
|
||||
if (selectedNode && depthLimit > 0) {
|
||||
// BFS from selected node up to depthLimit
|
||||
const reachable = new Set<string>();
|
||||
reachable.add(selectedNode.qualifiedName);
|
||||
let frontier = new Set<string>([selectedNode.qualifiedName]);
|
||||
|
||||
for (let d = 0; d < depthLimit; d++) {
|
||||
const next = new Set<string>();
|
||||
for (const qn of frontier) {
|
||||
for (const link of links) {
|
||||
const srcQn =
|
||||
typeof link.source === "object"
|
||||
? (link.source as SimNode).qualifiedName
|
||||
: link.sourceQualified;
|
||||
const tgtQn =
|
||||
typeof link.target === "object"
|
||||
? (link.target as SimNode).qualifiedName
|
||||
: link.targetQualified;
|
||||
|
||||
if (srcQn === qn && !reachable.has(tgtQn)) {
|
||||
reachable.add(tgtQn);
|
||||
next.add(tgtQn);
|
||||
}
|
||||
if (tgtQn === qn && !reachable.has(srcQn)) {
|
||||
reachable.add(srcQn);
|
||||
next.add(srcQn);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
if (frontier.size === 0) break;
|
||||
}
|
||||
|
||||
nodes = allNodes.filter((n) => reachable.has(n.qualifiedName));
|
||||
const reachableSet = reachable;
|
||||
links = links.filter((l) => {
|
||||
const srcQn =
|
||||
typeof l.source === "object"
|
||||
? (l.source as SimNode).qualifiedName
|
||||
: l.sourceQualified;
|
||||
const tgtQn =
|
||||
typeof l.target === "object"
|
||||
? (l.target as SimNode).qualifiedName
|
||||
: l.targetQualified;
|
||||
return reachableSet.has(srcQn) && reachableSet.has(tgtQn);
|
||||
});
|
||||
} else {
|
||||
nodes = [...allNodes];
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
|
||||
const query = searchInput?.value?.trim().toLowerCase() ?? "";
|
||||
if (query.length > 0) {
|
||||
const matchingQns = new Set(
|
||||
nodes
|
||||
.filter((n) => n.name.toLowerCase().includes(query) || n.qualifiedName.toLowerCase().includes(query))
|
||||
.map((n) => n.qualifiedName)
|
||||
);
|
||||
// Keep matching nodes + their direct neighbors
|
||||
const expanded = new Set(matchingQns);
|
||||
for (const link of links) {
|
||||
const srcQn =
|
||||
typeof link.source === "object"
|
||||
? (link.source as SimNode).qualifiedName
|
||||
: link.sourceQualified;
|
||||
const tgtQn =
|
||||
typeof link.target === "object"
|
||||
? (link.target as SimNode).qualifiedName
|
||||
: link.targetQualified;
|
||||
if (matchingQns.has(srcQn)) expanded.add(tgtQn);
|
||||
if (matchingQns.has(tgtQn)) expanded.add(srcQn);
|
||||
}
|
||||
nodes = nodes.filter((n) => expanded.has(n.qualifiedName));
|
||||
links = links.filter((l) => {
|
||||
const srcQn =
|
||||
typeof l.source === "object"
|
||||
? (l.source as SimNode).qualifiedName
|
||||
: l.sourceQualified;
|
||||
const tgtQn =
|
||||
typeof l.target === "object"
|
||||
? (l.target as SimNode).qualifiedName
|
||||
: l.targetQualified;
|
||||
return expanded.has(srcQn) && expanded.has(tgtQn);
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
function buildGraph(): void {
|
||||
const { nodes, links } = getVisibleData();
|
||||
|
||||
// Stop existing simulation
|
||||
if (simulation) {
|
||||
simulation.stop();
|
||||
}
|
||||
|
||||
const graphEl = document.getElementById("graph-area")!;
|
||||
const width = graphEl.clientWidth || window.innerWidth;
|
||||
const height = graphEl.clientHeight || window.innerHeight;
|
||||
|
||||
// --- Links ---
|
||||
linkSelection = linkGroup
|
||||
.selectAll<SVGLineElement, SimLink>("line")
|
||||
.data(links, (d) => `${d.sourceQualified}-${d.targetQualified}-${d.kind}`)
|
||||
.join("line")
|
||||
.attr("stroke", (d) => EDGE_COLOR[d.kind])
|
||||
.attr("stroke-width", 1.5)
|
||||
.attr("stroke-opacity", 0.4)
|
||||
.attr("marker-end", (d) => `url(#arrow-${d.kind})`);
|
||||
|
||||
// --- Nodes ---
|
||||
nodeSelection = nodeGroup
|
||||
.selectAll<SVGPathElement, SimNode>("path.node-shape")
|
||||
.data(nodes, (d) => d.qualifiedName)
|
||||
.join("path")
|
||||
.attr("class", "node-shape")
|
||||
.attr("d", (d) => d3.symbol().type(NODE_SHAPE[d.kind] ?? d3.symbolCircle).size(NODE_AREA[d.kind] ?? 314)()!)
|
||||
.attr("fill", (d) => NODE_COLOR[d.kind] ?? "#cdd6f4")
|
||||
.attr("stroke", "none")
|
||||
.attr("stroke-width", 2)
|
||||
.attr("cursor", "pointer")
|
||||
.on("click", (_event, d) => {
|
||||
selectNode(d);
|
||||
vscodeApi.postMessage({
|
||||
command: "nodeClicked",
|
||||
qualifiedName: d.qualifiedName,
|
||||
filePath: d.filePath,
|
||||
lineStart: d.lineStart ?? 1,
|
||||
});
|
||||
})
|
||||
.on("dblclick", (_event, d) => {
|
||||
// Center on node and expand depth by 1
|
||||
selectNode(d);
|
||||
depthLimit = Math.min(depthLimit + 1, 10);
|
||||
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
|
||||
if (slider) slider.value = String(depthLimit);
|
||||
const depthValue = document.getElementById("depth-value");
|
||||
if (depthValue) depthValue.textContent = String(depthLimit);
|
||||
buildGraph();
|
||||
centerOnNode(d);
|
||||
})
|
||||
.on("mouseenter", (_event, d) => {
|
||||
showTooltip(d);
|
||||
highlightConnected(d);
|
||||
})
|
||||
.on("mouseleave", () => {
|
||||
hideTooltip();
|
||||
unhighlightAll();
|
||||
})
|
||||
.call(
|
||||
d3
|
||||
.drag<SVGPathElement, SimNode>()
|
||||
.on("start", (event, d) => {
|
||||
if (!event.active) simulation?.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
})
|
||||
.on("drag", (event, d) => {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
})
|
||||
.on("end", (event, d) => {
|
||||
if (!event.active) simulation?.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
})
|
||||
)
|
||||
.attr("tabindex", 0)
|
||||
.attr("role", "button")
|
||||
.attr("aria-label", (d) => `${d.kind}: ${d.name}`)
|
||||
.on("keydown", (event: KeyboardEvent, d: SimNode) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
selectNode(d);
|
||||
vscodeApi.postMessage({
|
||||
command: "nodeClicked",
|
||||
qualifiedName: d.qualifiedName,
|
||||
filePath: d.filePath,
|
||||
lineStart: d.lineStart ?? 1,
|
||||
});
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
selectedNode = null;
|
||||
unhighlightAll();
|
||||
nodeSelection.attr("stroke", "none");
|
||||
} else if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const visibleNodes = nodeSelection.data();
|
||||
let best: SimNode | null = null;
|
||||
let bestDist = Infinity;
|
||||
for (const n of visibleNodes) {
|
||||
if (n.qualifiedName === d.qualifiedName || n.x == null || n.y == null || d.x == null || d.y == null) continue;
|
||||
const dx = n.x - d.x;
|
||||
const dy = n.y - d.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
let ok = false;
|
||||
if (event.key === "ArrowRight" && dx > 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
|
||||
if (event.key === "ArrowLeft" && dx < 0 && Math.abs(dy) < Math.abs(dx)) ok = true;
|
||||
if (event.key === "ArrowDown" && dy > 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
|
||||
if (event.key === "ArrowUp" && dy < 0 && Math.abs(dx) < Math.abs(dy)) ok = true;
|
||||
if (ok && dist < bestDist) {
|
||||
best = n;
|
||||
bestDist = dist;
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
const target = nodeGroup.selectAll<SVGPathElement, SimNode>("path.node-shape")
|
||||
.filter((n) => n.qualifiedName === best!.qualifiedName)
|
||||
.node();
|
||||
if (target) (target as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.on("focus", (_event: FocusEvent, d: SimNode) => {
|
||||
showTooltip(d);
|
||||
highlightConnected(d);
|
||||
})
|
||||
.on("blur", () => {
|
||||
hideTooltip();
|
||||
unhighlightAll();
|
||||
});
|
||||
|
||||
// Highlight search matches
|
||||
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
|
||||
const query = searchInput?.value?.trim().toLowerCase() ?? "";
|
||||
if (query.length > 0) {
|
||||
nodeSelection.attr("stroke", (d) => {
|
||||
const matches =
|
||||
d.name.toLowerCase().includes(query) ||
|
||||
d.qualifiedName.toLowerCase().includes(query);
|
||||
return matches ? "#e6edf3" : "none";
|
||||
});
|
||||
}
|
||||
|
||||
// Highlight selected node
|
||||
if (selectedNode) {
|
||||
nodeSelection.attr("stroke", (d) => {
|
||||
if (d.qualifiedName === selectedNode!.qualifiedName) return "#e6edf3";
|
||||
if (query.length > 0) {
|
||||
const matches =
|
||||
d.name.toLowerCase().includes(query) ||
|
||||
d.qualifiedName.toLowerCase().includes(query);
|
||||
return matches ? "#e6edf3" : "none";
|
||||
}
|
||||
return "none";
|
||||
});
|
||||
}
|
||||
|
||||
// --- Labels ---
|
||||
labelSelection = labelGroup
|
||||
.selectAll<SVGTextElement, SimNode>("text")
|
||||
.data(nodes, (d) => d.qualifiedName)
|
||||
.join("text")
|
||||
.text((d) => d.name)
|
||||
.attr("font-size", 10)
|
||||
.attr("fill", currentTheme === "dark" ? "#cdd6f4" : "#4c4f69")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("dy", (d) => (NODE_RADIUS[d.kind] ?? 10) + 14)
|
||||
.attr("pointer-events", "none");
|
||||
|
||||
// --- Force simulation ---
|
||||
simulation = d3
|
||||
.forceSimulation<SimNode>(nodes)
|
||||
.alphaDecay(0.02)
|
||||
.force(
|
||||
"link",
|
||||
d3
|
||||
.forceLink<SimNode, SimLink>(links)
|
||||
.id((d) => d.qualifiedName)
|
||||
.distance(100)
|
||||
)
|
||||
.force("charge", d3.forceManyBody<SimNode>().strength(-200))
|
||||
.force("center", d3.forceCenter(width / 2, height / 2))
|
||||
.force(
|
||||
"collide",
|
||||
d3.forceCollide<SimNode>().radius((d) => (NODE_RADIUS[d.kind] ?? 10) + 5)
|
||||
)
|
||||
.on("tick", () => {
|
||||
linkSelection
|
||||
.attr("x1", (d) => (d.source as SimNode).x!)
|
||||
.attr("y1", (d) => (d.source as SimNode).y!)
|
||||
.attr("x2", (d) => (d.target as SimNode).x!)
|
||||
.attr("y2", (d) => (d.target as SimNode).y!);
|
||||
|
||||
nodeSelection.attr("transform", (d) => `translate(${d.x},${d.y})`);
|
||||
|
||||
labelSelection.attr("x", (d) => d.x!).attr("y", (d) => d.y!);
|
||||
});
|
||||
|
||||
// Update node count display
|
||||
const countEl = document.getElementById("node-count");
|
||||
if (countEl) {
|
||||
countEl.textContent = `${nodes.length} nodes, ${links.length} edges`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selection & highlight
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function selectNode(node: SimNode): void {
|
||||
selectedNode = node;
|
||||
nodeSelection.attr("stroke", (d) =>
|
||||
d.qualifiedName === node.qualifiedName ? "#e6edf3" : "none"
|
||||
);
|
||||
updateDepthSliderState();
|
||||
}
|
||||
|
||||
function updateDepthSliderState(): void {
|
||||
const slider = document.getElementById("depth-slider") as HTMLInputElement | null;
|
||||
const depthValue = document.getElementById("depth-value");
|
||||
if (slider) {
|
||||
if (selectedNode) {
|
||||
slider.disabled = false;
|
||||
} else {
|
||||
slider.disabled = true;
|
||||
if (depthValue) depthValue.textContent = "N/A";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function highlightConnected(node: SimNode): void {
|
||||
const connectedQns = new Set<string>();
|
||||
connectedQns.add(node.qualifiedName);
|
||||
|
||||
linkSelection.attr("stroke-opacity", (d) => {
|
||||
const srcQn = (d.source as SimNode).qualifiedName;
|
||||
const tgtQn = (d.target as SimNode).qualifiedName;
|
||||
if (srcQn === node.qualifiedName || tgtQn === node.qualifiedName) {
|
||||
connectedQns.add(srcQn);
|
||||
connectedQns.add(tgtQn);
|
||||
return 0.8;
|
||||
}
|
||||
return 0.1;
|
||||
});
|
||||
|
||||
nodeSelection.attr("opacity", (d) =>
|
||||
connectedQns.has(d.qualifiedName) ? 1 : 0.2
|
||||
);
|
||||
labelSelection.attr("opacity", (d) =>
|
||||
connectedQns.has(d.qualifiedName) ? 1 : 0.2
|
||||
);
|
||||
}
|
||||
|
||||
function unhighlightAll(): void {
|
||||
linkSelection.attr("stroke-opacity", 0.4);
|
||||
nodeSelection.attr("opacity", 1);
|
||||
labelSelection.attr("opacity", 1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tooltip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showTooltip(node: SimNode): void {
|
||||
const tooltip = document.getElementById("tooltip")!;
|
||||
tooltip.style.display = "block";
|
||||
|
||||
let html = `<strong>${escapeHtml(node.name)}</strong><br/>`;
|
||||
html += `<span class="tooltip-kind">${escapeHtml(node.kind)}</span><br/>`;
|
||||
html += `<span class="tooltip-path">${escapeHtml(node.filePath)}</span>`;
|
||||
if (node.lineStart != null) {
|
||||
html += `<br/>Lines ${node.lineStart}`;
|
||||
if (node.lineEnd != null && node.lineEnd !== node.lineStart) {
|
||||
html += `-${node.lineEnd}`;
|
||||
}
|
||||
}
|
||||
if (node.params) {
|
||||
html += `<br/><span class="tooltip-params">${escapeHtml(node.params)}</span>`;
|
||||
}
|
||||
if (node.returnType) {
|
||||
html += ` <span class="tooltip-return">→ ${escapeHtml(node.returnType)}</span>`;
|
||||
}
|
||||
|
||||
tooltip.innerHTML = html;
|
||||
|
||||
// Position near cursor -- we'll update on mousemove too
|
||||
document.addEventListener("mousemove", positionTooltip);
|
||||
}
|
||||
|
||||
function positionTooltip(event: MouseEvent): void {
|
||||
const tooltip = document.getElementById("tooltip")!;
|
||||
const x = event.clientX + 12;
|
||||
const y = event.clientY + 12;
|
||||
|
||||
// Keep tooltip in viewport
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const maxX = window.innerWidth - rect.width - 8;
|
||||
const maxY = window.innerHeight - rect.height - 8;
|
||||
|
||||
tooltip.style.left = `${Math.min(x, maxX)}px`;
|
||||
tooltip.style.top = `${Math.min(y, maxY)}px`;
|
||||
}
|
||||
|
||||
function hideTooltip(): void {
|
||||
const tooltip = document.getElementById("tooltip")!;
|
||||
tooltip.style.display = "none";
|
||||
document.removeEventListener("mousemove", positionTooltip);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight node (from extension message)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function highlightNodeByName(qualifiedName: string): void {
|
||||
const node = nodeMap.get(qualifiedName);
|
||||
if (!node) return;
|
||||
|
||||
selectNode(node);
|
||||
centerOnNode(node);
|
||||
|
||||
// Add pulsing ring animation
|
||||
const ring = nodeGroup
|
||||
.append("circle")
|
||||
.attr("cx", node.x ?? 0)
|
||||
.attr("cy", node.y ?? 0)
|
||||
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#e6edf3")
|
||||
.attr("stroke-width", 3)
|
||||
.attr("class", "pulse-ring");
|
||||
|
||||
// Remove after animation completes
|
||||
ring
|
||||
.transition()
|
||||
.duration(600)
|
||||
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
|
||||
.attr("stroke-opacity", 0)
|
||||
.on("end", function () {
|
||||
d3.select(this).remove();
|
||||
});
|
||||
|
||||
// Second pulse
|
||||
setTimeout(() => {
|
||||
if (!node.x) return;
|
||||
const ring2 = nodeGroup
|
||||
.append("circle")
|
||||
.attr("cx", node.x)
|
||||
.attr("cy", node.y ?? 0)
|
||||
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 4)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", "#e6edf3")
|
||||
.attr("stroke-width", 3);
|
||||
|
||||
ring2
|
||||
.transition()
|
||||
.duration(600)
|
||||
.attr("r", (NODE_RADIUS[node.kind] ?? 10) + 20)
|
||||
.attr("stroke-opacity", 0)
|
||||
.on("end", function () {
|
||||
d3.select(this).remove();
|
||||
});
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function centerOnNode(node: SimNode): void {
|
||||
if (!node.x || !node.y) return;
|
||||
|
||||
const graphEl = document.getElementById("graph-area")!;
|
||||
const width = graphEl.clientWidth;
|
||||
const height = graphEl.clientHeight;
|
||||
|
||||
const transform = d3.zoomIdentity
|
||||
.translate(width / 2, height / 2)
|
||||
.scale(1.5)
|
||||
.translate(-node.x, -node.y);
|
||||
|
||||
svg
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(zoomBehavior.transform, transform);
|
||||
}
|
||||
|
||||
function fitToView(): void {
|
||||
const graphEl = document.getElementById("graph-area")!;
|
||||
const width = graphEl.clientWidth;
|
||||
const height = graphEl.clientHeight;
|
||||
|
||||
if (allNodes.length === 0) return;
|
||||
|
||||
// Find bounding box of visible nodes
|
||||
const visibleNodes = nodeSelection.data();
|
||||
if (visibleNodes.length === 0) return;
|
||||
|
||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||
for (const n of visibleNodes) {
|
||||
if (n.x == null || n.y == null) continue;
|
||||
const r = NODE_RADIUS[n.kind] ?? 10;
|
||||
minX = Math.min(minX, n.x - r);
|
||||
maxX = Math.max(maxX, n.x + r);
|
||||
minY = Math.min(minY, n.y - r);
|
||||
maxY = Math.max(maxY, n.y + r);
|
||||
}
|
||||
|
||||
if (!isFinite(minX)) return;
|
||||
|
||||
const padding = 60;
|
||||
const bboxWidth = maxX - minX + padding * 2;
|
||||
const bboxHeight = maxY - minY + padding * 2;
|
||||
const scale = Math.min(width / bboxWidth, height / bboxHeight, 2);
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
|
||||
const transform = d3.zoomIdentity
|
||||
.translate(width / 2, height / 2)
|
||||
.scale(scale)
|
||||
.translate(-cx, -cy);
|
||||
|
||||
svg
|
||||
.transition()
|
||||
.duration(500)
|
||||
.call(zoomBehavior.transform, transform);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolbar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindToolbarEvents(): void {
|
||||
// Search
|
||||
const searchInput = document.getElementById("search-input") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
searchInput.addEventListener("input", () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
buildGraph();
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
// Edge toggle pills
|
||||
for (const kind of ALL_EDGE_KINDS) {
|
||||
const pill = document.getElementById(`edge-${kind}`);
|
||||
if (pill) {
|
||||
const toggle = () => {
|
||||
if (visibleEdgeKinds.has(kind)) {
|
||||
visibleEdgeKinds.delete(kind);
|
||||
pill.classList.remove("active");
|
||||
pill.setAttribute("aria-pressed", "false");
|
||||
} else {
|
||||
visibleEdgeKinds.add(kind);
|
||||
pill.classList.add("active");
|
||||
pill.setAttribute("aria-pressed", "true");
|
||||
}
|
||||
buildGraph();
|
||||
};
|
||||
pill.addEventListener("click", toggle);
|
||||
pill.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); toggle(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Edge filter popover toggle
|
||||
const edgeFilterBtn = document.getElementById("btn-edge-filter");
|
||||
const edgePopover = document.getElementById("edge-popover");
|
||||
if (edgeFilterBtn && edgePopover) {
|
||||
edgeFilterBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
edgePopover.classList.toggle("visible");
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!edgePopover.contains(e.target as Node) && e.target !== edgeFilterBtn) {
|
||||
edgePopover.classList.remove("visible");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Depth slider
|
||||
const depthSlider = document.getElementById("depth-slider") as HTMLInputElement | null;
|
||||
if (depthSlider) {
|
||||
depthSlider.addEventListener("input", () => {
|
||||
depthLimit = parseInt(depthSlider.value, 10);
|
||||
const depthValue = document.getElementById("depth-value");
|
||||
if (depthValue) {
|
||||
depthValue.textContent = depthLimit === 0 ? "All" : String(depthLimit);
|
||||
}
|
||||
buildGraph();
|
||||
});
|
||||
}
|
||||
|
||||
// Fit button
|
||||
const fitBtn = document.getElementById("btn-fit");
|
||||
if (fitBtn) {
|
||||
fitBtn.addEventListener("click", () => {
|
||||
fitToView();
|
||||
});
|
||||
}
|
||||
|
||||
// Export SVG button
|
||||
const exportBtn = document.getElementById("btn-export");
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener("click", () => {
|
||||
const svgEl = document.querySelector("#graph-area svg");
|
||||
if (svgEl) {
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
vscodeApi.postMessage({
|
||||
command: "exportSvg",
|
||||
svg: svgString,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Export PNG button
|
||||
const exportPngBtn = document.getElementById("btn-export-png");
|
||||
if (exportPngBtn) {
|
||||
exportPngBtn.addEventListener("click", () => {
|
||||
const svgEl = document.querySelector("#graph-area svg") as SVGSVGElement | null;
|
||||
if (!svgEl) { return; }
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgEl);
|
||||
const canvas = document.createElement("canvas");
|
||||
const bbox = svgEl.getBoundingClientRect();
|
||||
canvas.width = bbox.width * 2; // 2x for retina
|
||||
canvas.height = bbox.height * 2;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) { return; }
|
||||
ctx.scale(2, 2);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const pngData = canvas.toDataURL("image/png");
|
||||
vscodeApi.postMessage({ command: "exportPng", data: pngData });
|
||||
};
|
||||
img.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svgString)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension message handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function bindExtensionMessages(): void {
|
||||
window.addEventListener("message", (event) => {
|
||||
const message = event.data;
|
||||
switch (message.command) {
|
||||
case "setData":
|
||||
setData(
|
||||
message.nodes as GraphNode[],
|
||||
message.edges as GraphEdge[]
|
||||
);
|
||||
// Auto-fit after simulation settles a bit
|
||||
setTimeout(() => fitToView(), 800);
|
||||
// Show truncation warning if needed
|
||||
if (message.truncated) {
|
||||
const warn = document.getElementById("truncation-warning");
|
||||
if (warn) {
|
||||
warn.style.display = "inline";
|
||||
warn.textContent = `\u26a0 Showing ${message.maxNodes} of more nodes. Increase maxNodes in settings.`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "highlightNode":
|
||||
highlightNodeByName(message.qualifiedName as string);
|
||||
break;
|
||||
|
||||
case "setTheme":
|
||||
currentTheme = message.theme as "dark" | "light";
|
||||
applyTheme();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyTheme(): void {
|
||||
const textColor = currentTheme === "dark" ? "#cdd6f4" : "#4c4f69";
|
||||
labelSelection.attr("fill", textColor);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* Tests for the SqliteReader module.
|
||||
*
|
||||
* Creates a temporary SQLite database with the exact schema used by the
|
||||
* Python backend, inserts representative test data, and validates every
|
||||
* public method of SqliteReader.
|
||||
*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { SqliteReader, GraphNode, GraphEdge } from '../src/backend/sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema (mirrors the Python backend exactly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SCHEMA_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
qualified_name TEXT NOT NULL UNIQUE,
|
||||
file_path TEXT NOT NULL,
|
||||
line_start INTEGER,
|
||||
line_end INTEGER,
|
||||
language TEXT,
|
||||
parent_name TEXT,
|
||||
params TEXT,
|
||||
return_type TEXT,
|
||||
modifiers TEXT,
|
||||
is_test INTEGER DEFAULT 0,
|
||||
file_hash TEXT,
|
||||
extra TEXT DEFAULT '{}',
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
source_qualified TEXT NOT NULL,
|
||||
target_qualified TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
line INTEGER DEFAULT 0,
|
||||
extra TEXT DEFAULT '{}',
|
||||
updated_at REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(file_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_qualified ON nodes(qualified_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_qualified);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_qualified);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_file ON edges(file_path);
|
||||
`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = Date.now() / 1000;
|
||||
|
||||
interface TestNode {
|
||||
kind: string;
|
||||
name: string;
|
||||
qualified_name: string;
|
||||
file_path: string;
|
||||
line_start: number;
|
||||
line_end: number;
|
||||
language: string;
|
||||
parent_name: string | null;
|
||||
params: string | null;
|
||||
return_type: string | null;
|
||||
modifiers: string | null;
|
||||
is_test: number;
|
||||
file_hash: string;
|
||||
extra: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface TestEdge {
|
||||
kind: string;
|
||||
source_qualified: string;
|
||||
target_qualified: string;
|
||||
file_path: string;
|
||||
line: number;
|
||||
extra: string;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
const TEST_NODES: TestNode[] = [
|
||||
// auth.py -- File node + 2 functions
|
||||
{
|
||||
kind: 'File', name: 'auth.py', qualified_name: 'src/auth.py',
|
||||
file_path: 'src/auth.py', line_start: 1, line_end: 50,
|
||||
language: 'python', parent_name: null, params: null, return_type: null,
|
||||
modifiers: null, is_test: 0, file_hash: 'aaa', extra: '{}', updated_at: NOW,
|
||||
},
|
||||
{
|
||||
kind: 'Function', name: 'login', qualified_name: 'src/auth.py::login',
|
||||
file_path: 'src/auth.py', line_start: 5, line_end: 20,
|
||||
language: 'python', parent_name: null, params: '(username, password)',
|
||||
return_type: 'bool', modifiers: null, is_test: 0, file_hash: 'aaa',
|
||||
extra: '{}', updated_at: NOW,
|
||||
},
|
||||
{
|
||||
kind: 'Function', name: 'logout', qualified_name: 'src/auth.py::logout',
|
||||
file_path: 'src/auth.py', line_start: 22, line_end: 35,
|
||||
language: 'python', parent_name: null, params: '(session)',
|
||||
return_type: 'None', modifiers: null, is_test: 0, file_hash: 'aaa',
|
||||
extra: '{}', updated_at: NOW,
|
||||
},
|
||||
|
||||
// routes.py -- File node + 1 function
|
||||
{
|
||||
kind: 'File', name: 'routes.py', qualified_name: 'src/routes.py',
|
||||
file_path: 'src/routes.py', line_start: 1, line_end: 40,
|
||||
language: 'python', parent_name: null, params: null, return_type: null,
|
||||
modifiers: null, is_test: 0, file_hash: 'bbb', extra: '{}', updated_at: NOW,
|
||||
},
|
||||
{
|
||||
kind: 'Function', name: 'handle_login', qualified_name: 'src/routes.py::handle_login',
|
||||
file_path: 'src/routes.py', line_start: 10, line_end: 30,
|
||||
language: 'python', parent_name: null, params: '(request)',
|
||||
return_type: 'Response', modifiers: null, is_test: 0, file_hash: 'bbb',
|
||||
extra: '{}', updated_at: NOW,
|
||||
},
|
||||
|
||||
// test_auth.py -- File node + 1 test function
|
||||
{
|
||||
kind: 'File', name: 'test_auth.py', qualified_name: 'tests/test_auth.py',
|
||||
file_path: 'tests/test_auth.py', line_start: 1, line_end: 30,
|
||||
language: 'python', parent_name: null, params: null, return_type: null,
|
||||
modifiers: null, is_test: 0, file_hash: 'ccc', extra: '{}', updated_at: NOW,
|
||||
},
|
||||
{
|
||||
kind: 'Test', name: 'test_login', qualified_name: 'tests/test_auth.py::test_login',
|
||||
file_path: 'tests/test_auth.py', line_start: 5, line_end: 25,
|
||||
language: 'python', parent_name: null, params: '()',
|
||||
return_type: 'None', modifiers: null, is_test: 1, file_hash: 'ccc',
|
||||
extra: '{}', updated_at: NOW,
|
||||
},
|
||||
];
|
||||
|
||||
const TEST_EDGES: TestEdge[] = [
|
||||
// routes.py::handle_login CALLS auth.py::login
|
||||
{
|
||||
kind: 'CALLS', source_qualified: 'src/routes.py::handle_login',
|
||||
target_qualified: 'src/auth.py::login', file_path: 'src/routes.py',
|
||||
line: 15, extra: '{}', updated_at: NOW,
|
||||
},
|
||||
// routes.py IMPORTS_FROM auth.py
|
||||
{
|
||||
kind: 'IMPORTS_FROM', source_qualified: 'src/routes.py',
|
||||
target_qualified: 'src/auth.py', file_path: 'src/routes.py',
|
||||
line: 1, extra: '{}', updated_at: NOW,
|
||||
},
|
||||
// auth.py CONTAINS login
|
||||
{
|
||||
kind: 'CONTAINS', source_qualified: 'src/auth.py',
|
||||
target_qualified: 'src/auth.py::login', file_path: 'src/auth.py',
|
||||
line: 5, extra: '{}', updated_at: NOW,
|
||||
},
|
||||
// auth.py CONTAINS logout
|
||||
{
|
||||
kind: 'CONTAINS', source_qualified: 'src/auth.py',
|
||||
target_qualified: 'src/auth.py::logout', file_path: 'src/auth.py',
|
||||
line: 22, extra: '{}', updated_at: NOW,
|
||||
},
|
||||
// test_auth.py::test_login TESTED_BY (reverse: login is tested by test_login)
|
||||
{
|
||||
kind: 'TESTED_BY', source_qualified: 'src/auth.py::login',
|
||||
target_qualified: 'tests/test_auth.py::test_login', file_path: 'tests/test_auth.py',
|
||||
line: 5, extra: '{}', updated_at: NOW,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a populated temp database
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestDb(): string {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'crg-test-'));
|
||||
const dbPath = path.join(tmpDir, 'graph.db');
|
||||
|
||||
const db = new Database(dbPath);
|
||||
db.exec(SCHEMA_SQL);
|
||||
|
||||
const insertNode = db.prepare(`
|
||||
INSERT INTO nodes
|
||||
(kind, name, qualified_name, file_path, line_start, line_end,
|
||||
language, parent_name, params, return_type, modifiers, is_test,
|
||||
file_hash, extra, updated_at)
|
||||
VALUES
|
||||
(@kind, @name, @qualified_name, @file_path, @line_start, @line_end,
|
||||
@language, @parent_name, @params, @return_type, @modifiers, @is_test,
|
||||
@file_hash, @extra, @updated_at)
|
||||
`);
|
||||
|
||||
const insertEdge = db.prepare(`
|
||||
INSERT INTO edges
|
||||
(kind, source_qualified, target_qualified, file_path, line, extra, updated_at)
|
||||
VALUES
|
||||
(@kind, @source_qualified, @target_qualified, @file_path, @line, @extra, @updated_at)
|
||||
`);
|
||||
|
||||
const insertMeta = db.prepare(
|
||||
'INSERT INTO metadata (key, value) VALUES (?, ?)'
|
||||
);
|
||||
|
||||
const insertMany = db.transaction(() => {
|
||||
for (const n of TEST_NODES) { insertNode.run(n); }
|
||||
for (const e of TEST_EDGES) { insertEdge.run(e); }
|
||||
insertMeta.run('last_updated', '2025-06-15T10:30:00Z');
|
||||
});
|
||||
insertMany();
|
||||
db.close();
|
||||
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
function cleanup(dbPath: string): void {
|
||||
try {
|
||||
const dir = path.dirname(dbPath);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('SqliteReader', () => {
|
||||
let dbPath: string;
|
||||
let reader: SqliteReader;
|
||||
|
||||
before(() => {
|
||||
dbPath = createTestDb();
|
||||
reader = new SqliteReader(dbPath);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
reader.close();
|
||||
cleanup(dbPath);
|
||||
});
|
||||
|
||||
// -- isValid ------------------------------------------------------------
|
||||
|
||||
it('isValid() returns true for a properly initialised database', () => {
|
||||
assert.strictEqual(reader.isValid(), true);
|
||||
});
|
||||
|
||||
// -- getAllFiles ---------------------------------------------------------
|
||||
|
||||
it('getAllFiles() returns file paths ordered alphabetically', () => {
|
||||
const files = reader.getAllFiles();
|
||||
assert.deepStrictEqual(files, [
|
||||
'src/auth.py',
|
||||
'src/routes.py',
|
||||
'tests/test_auth.py',
|
||||
]);
|
||||
});
|
||||
|
||||
// -- getNodesByFile -----------------------------------------------------
|
||||
|
||||
it('getNodesByFile() returns nodes ordered by line_start', () => {
|
||||
const nodes = reader.getNodesByFile('src/auth.py');
|
||||
assert.strictEqual(nodes.length, 3); // File + login + logout
|
||||
|
||||
// Verify ordering
|
||||
assert.strictEqual(nodes[0].name, 'auth.py');
|
||||
assert.strictEqual(nodes[1].name, 'login');
|
||||
assert.strictEqual(nodes[2].name, 'logout');
|
||||
|
||||
// Verify camelCase conversion
|
||||
assert.strictEqual(nodes[1].qualifiedName, 'src/auth.py::login');
|
||||
assert.strictEqual(nodes[1].lineStart, 5);
|
||||
assert.strictEqual(nodes[1].lineEnd, 20);
|
||||
assert.strictEqual(nodes[1].returnType, 'bool');
|
||||
assert.strictEqual(nodes[1].isTest, false);
|
||||
});
|
||||
|
||||
// -- getNode ------------------------------------------------------------
|
||||
|
||||
it('getNode() returns a single node by qualified name', () => {
|
||||
const node = reader.getNode('src/auth.py::login');
|
||||
assert.ok(node);
|
||||
assert.strictEqual(node.kind, 'Function');
|
||||
assert.strictEqual(node.name, 'login');
|
||||
assert.strictEqual(node.params, '(username, password)');
|
||||
});
|
||||
|
||||
it('getNode() returns undefined for non-existent qualified name', () => {
|
||||
const node = reader.getNode('src/auth.py::nonexistent');
|
||||
assert.strictEqual(node, undefined);
|
||||
});
|
||||
|
||||
// -- getNodeAtCursor ----------------------------------------------------
|
||||
|
||||
it('getNodeAtCursor() returns the innermost node at the cursor', () => {
|
||||
// Line 10 is inside login (5-20) and inside auth.py (1-50).
|
||||
// login has the smaller span so it should be returned.
|
||||
const node = reader.getNodeAtCursor('src/auth.py', 10);
|
||||
assert.ok(node);
|
||||
assert.strictEqual(node.name, 'login');
|
||||
});
|
||||
|
||||
it('getNodeAtCursor() returns the File node when cursor is outside functions', () => {
|
||||
// Line 45 is inside auth.py (1-50) but outside both functions.
|
||||
const node = reader.getNodeAtCursor('src/auth.py', 45);
|
||||
assert.ok(node);
|
||||
assert.strictEqual(node.kind, 'File');
|
||||
assert.strictEqual(node.name, 'auth.py');
|
||||
});
|
||||
|
||||
it('getNodeAtCursor() returns undefined when no node covers the line', () => {
|
||||
const node = reader.getNodeAtCursor('src/auth.py', 999);
|
||||
assert.strictEqual(node, undefined);
|
||||
});
|
||||
|
||||
// -- getEdgesBySource ---------------------------------------------------
|
||||
|
||||
it('getEdgesBySource() returns outgoing edges', () => {
|
||||
const edges = reader.getEdgesBySource('src/routes.py::handle_login');
|
||||
assert.strictEqual(edges.length, 1);
|
||||
assert.strictEqual(edges[0].kind, 'CALLS');
|
||||
assert.strictEqual(edges[0].targetQualified, 'src/auth.py::login');
|
||||
assert.strictEqual(edges[0].line, 15);
|
||||
});
|
||||
|
||||
// -- getEdgesByTarget ---------------------------------------------------
|
||||
|
||||
it('getEdgesByTarget() returns incoming edges', () => {
|
||||
const edges = reader.getEdgesByTarget('src/auth.py::login');
|
||||
// CALLS from handle_login + CONTAINS from auth.py
|
||||
assert.strictEqual(edges.length, 2);
|
||||
const kinds = edges.map((e) => e.kind).sort();
|
||||
assert.deepStrictEqual(kinds, ['CALLS', 'CONTAINS']);
|
||||
});
|
||||
|
||||
// -- getEdgesAmong ------------------------------------------------------
|
||||
|
||||
it('getEdgesAmong() returns only edges within the given set', () => {
|
||||
const qns = new Set([
|
||||
'src/routes.py::handle_login',
|
||||
'src/auth.py::login',
|
||||
'src/auth.py',
|
||||
]);
|
||||
const edges = reader.getEdgesAmong(qns);
|
||||
// Should include: CALLS handle_login->login, CONTAINS auth.py->login,
|
||||
// IMPORTS_FROM routes.py->auth.py only if routes.py is in set (it's not).
|
||||
assert.ok(edges.length >= 2);
|
||||
for (const e of edges) {
|
||||
assert.ok(qns.has(e.sourceQualified), `source ${e.sourceQualified} should be in set`);
|
||||
assert.ok(qns.has(e.targetQualified), `target ${e.targetQualified} should be in set`);
|
||||
}
|
||||
});
|
||||
|
||||
it('getEdgesAmong() returns empty array for empty set', () => {
|
||||
const edges = reader.getEdgesAmong(new Set());
|
||||
assert.deepStrictEqual(edges, []);
|
||||
});
|
||||
|
||||
// -- searchNodes --------------------------------------------------------
|
||||
|
||||
it('searchNodes() finds nodes by name substring', () => {
|
||||
const results = reader.searchNodes('login');
|
||||
assert.ok(results.length >= 2); // login, handle_login, test_login
|
||||
const names = results.map((n) => n.name);
|
||||
assert.ok(names.includes('login'));
|
||||
assert.ok(names.includes('handle_login'));
|
||||
});
|
||||
|
||||
it('searchNodes() respects limit', () => {
|
||||
const results = reader.searchNodes('login', 1);
|
||||
assert.strictEqual(results.length, 1);
|
||||
});
|
||||
|
||||
it('searchNodes() returns empty for no match', () => {
|
||||
const results = reader.searchNodes('zzz_no_match_zzz');
|
||||
assert.deepStrictEqual(results, []);
|
||||
});
|
||||
|
||||
// -- getStats -----------------------------------------------------------
|
||||
|
||||
it('getStats() returns correct aggregate counts', () => {
|
||||
const stats = reader.getStats();
|
||||
assert.strictEqual(stats.totalNodes, TEST_NODES.length);
|
||||
assert.strictEqual(stats.totalEdges, TEST_EDGES.length);
|
||||
assert.strictEqual(stats.filesCount, 3); // 3 File nodes
|
||||
assert.deepStrictEqual(stats.languages, ['python']);
|
||||
assert.strictEqual(stats.lastUpdated, '2025-06-15T10:30:00Z');
|
||||
assert.strictEqual(stats.embeddingsCount, 0); // no embeddings table data
|
||||
|
||||
// Nodes by kind
|
||||
assert.strictEqual(stats.nodesByKind['File'], 3);
|
||||
assert.strictEqual(stats.nodesByKind['Function'], 3);
|
||||
assert.strictEqual(stats.nodesByKind['Test'], 1);
|
||||
|
||||
// Edges by kind
|
||||
assert.strictEqual(stats.edgesByKind['CALLS'], 1);
|
||||
assert.strictEqual(stats.edgesByKind['IMPORTS_FROM'], 1);
|
||||
assert.strictEqual(stats.edgesByKind['CONTAINS'], 2);
|
||||
assert.strictEqual(stats.edgesByKind['TESTED_BY'], 1);
|
||||
});
|
||||
|
||||
// -- getMetadata --------------------------------------------------------
|
||||
|
||||
it('getMetadata() returns stored value', () => {
|
||||
const val = reader.getMetadata('last_updated');
|
||||
assert.strictEqual(val, '2025-06-15T10:30:00Z');
|
||||
});
|
||||
|
||||
it('getMetadata() returns undefined for missing key', () => {
|
||||
const val = reader.getMetadata('nonexistent_key');
|
||||
assert.strictEqual(val, undefined);
|
||||
});
|
||||
|
||||
// -- getImpactRadius ----------------------------------------------------
|
||||
|
||||
it('getImpactRadius() finds changed and impacted nodes', () => {
|
||||
const result = reader.getImpactRadius(['src/auth.py'], 2);
|
||||
|
||||
// Changed nodes: everything in auth.py (File + login + logout)
|
||||
assert.strictEqual(result.changedNodes.length, 3);
|
||||
const changedNames = result.changedNodes.map((n) => n.name).sort();
|
||||
assert.deepStrictEqual(changedNames, ['auth.py', 'login', 'logout']);
|
||||
|
||||
// Impacted nodes: handle_login (calls login), test_login (tested_by),
|
||||
// routes.py (imports_from auth.py), test_auth.py file (contains test_login)
|
||||
assert.ok(
|
||||
result.impactedNodes.length > 0,
|
||||
'should have impacted nodes'
|
||||
);
|
||||
const impactedNames = result.impactedNodes.map((n) => n.name);
|
||||
assert.ok(
|
||||
impactedNames.includes('handle_login'),
|
||||
'handle_login should be impacted (calls login)'
|
||||
);
|
||||
assert.ok(
|
||||
impactedNames.includes('test_login'),
|
||||
'test_login should be impacted (tested_by)'
|
||||
);
|
||||
|
||||
// Impacted files should include routes.py and/or tests/test_auth.py
|
||||
assert.ok(
|
||||
result.impactedFiles.length > 0,
|
||||
'should have impacted files'
|
||||
);
|
||||
|
||||
// Edges among all involved nodes
|
||||
assert.ok(
|
||||
result.edges.length > 0,
|
||||
'should have connecting edges'
|
||||
);
|
||||
});
|
||||
|
||||
it('getImpactRadius() with depth 0 returns only seeds, no impacted nodes', () => {
|
||||
const result = reader.getImpactRadius(['src/auth.py'], 0);
|
||||
assert.strictEqual(result.changedNodes.length, 3);
|
||||
assert.strictEqual(result.impactedNodes.length, 0);
|
||||
});
|
||||
|
||||
it('getImpactRadius() for non-existent file returns empty', () => {
|
||||
const result = reader.getImpactRadius(['nonexistent.py']);
|
||||
assert.strictEqual(result.changedNodes.length, 0);
|
||||
assert.strictEqual(result.impactedNodes.length, 0);
|
||||
assert.strictEqual(result.impactedFiles.length, 0);
|
||||
});
|
||||
|
||||
// -- close / isValid after close ----------------------------------------
|
||||
|
||||
it('isValid() returns false after close()', () => {
|
||||
const tmpPath = createTestDb();
|
||||
const tmpReader = new SqliteReader(tmpPath);
|
||||
assert.strictEqual(tmpReader.isValid(), true);
|
||||
tmpReader.close();
|
||||
assert.strictEqual(tmpReader.isValid(), false);
|
||||
cleanup(tmpPath);
|
||||
});
|
||||
|
||||
// -- constructor retry on bad path --------------------------------------
|
||||
|
||||
it('constructor throws after retries for a non-existent path', () => {
|
||||
assert.throws(() => {
|
||||
new SqliteReader('/nonexistent/path/to/database.db');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test", "src/webview"]
|
||||
}
|
||||
Reference in New Issue
Block a user