chore: init from code-review-graph-main snapshot (v2.3.7)

This commit is contained in:
dev
2026-08-05 13:16:12 +08:00
commit 82b7c6dc9e
358 changed files with 118525 additions and 0 deletions
+220
View File
@@ -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;
}
}
+983
View File
@@ -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">&rarr; ${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();