diff --git a/README.md b/README.md
index 1c2aedc..cab7f49 100644
--- a/README.md
+++ b/README.md
@@ -93,6 +93,7 @@ This extension leverages cutting-edge technologies to deliver fast, accurate cod
> **Note on Language Support:**
> - **Function-Level Flowcharts**: Supports Python, TypeScript/JavaScript, Java, C++, C, Rust, Go, and PHP
> - **Codebase Dependency Visualization**: Currently supports TypeScript/JavaScript, Python, and PHP (more languages coming soon)
+> - **Function Usage Graphs**: Currently supports TypeScript/JavaScript only
> - **AI-Powered Features**: Available only for Function-Level Flowcharts
#### Function-Level Flowchart Generation
@@ -119,6 +120,16 @@ Analyze and visualize your entire codebase structure, revealing module dependenc
- **Interactive Navigation**: Zoom, pan, and explore even the largest dependency graphs smoothly
- **Folder Hierarchy**: Smart subgraphs organized by your directory structure
+#### Function Usage Graphs
+
+Trace where a TypeScript/JavaScript function is called across the workspace with an interactive reverse call graph.
+
+**Capabilities:**
+- **Caller Graphs**: Visualize direct and transitive callers for the function under the cursor
+- **Code Navigation**: Click graph nodes to jump to caller definitions
+- **Drill Into Code Flow**: Use the code-flow icon on a caller node to open that function's flowchart without leaving the usage graph
+- **Multi-Window Workflow**: Keep usage graphs and drilled-in flowcharts in linked VS Code editor groups for side-by-side exploration
+
#### AI-Powered Features (Function Flowcharts)
**Note:** AI features enhance function-level flowcharts only, making complex logic instantly readable.
@@ -161,7 +172,7 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic
- Or use Ollama for completely local AI processing
4. **Start Visualizing**
- - Right-click any function → "CodeVisualizer: Open flowchart in new window"
+ - Right-click any function → "Open Flowchart in New Window"
- Right-click any folder → "Visualize Codebase Flow"
(back to top)
@@ -173,7 +184,7 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic
1. Open any supported source file in VS Code
2. Right-click in the editor
-3. Select **"CodeVisualizer: Open flowchart in new window"**
+3. Select **"Open Flowchart in New Window"**
- Alternatively, use Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and type "CodeVisualizer"
4. Explore the interactive flowchart:
- Click nodes to jump to corresponding code
@@ -192,6 +203,16 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic
- Identify circular dependencies
- Understand your project architecture at a glance
+### Visualizing Function Usage
+
+1. Open a TypeScript/JavaScript source file in VS Code
+2. Place the cursor inside a named function
+3. Right-click in the editor and select **"Visualize Function Usage"**
+4. Explore the usage graph:
+ - Click a caller node to jump to its definition
+ - Click the code-flow icon on a caller node to open that caller's function flowchart
+ - Move or split the code-flow panel to keep usage and flow views side by side
+
### AI-Enhanced Labels (Function Flowcharts)
1. Enable AI labels in Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`) and type "CodeVisualizer: Enable AI Labels"
@@ -226,6 +247,14 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic
**Planned Support:** Java, C++, C, Rust, Go dependency analysis coming in future releases.
+### Function Usage Graphs
+
+| Language | Status | File Extensions |
+|----------|--------|----------------|
+| TypeScript/JavaScript | Supported | `.ts`, `.tsx`, `.js`, `.jsx`, `.mts`, `.cts`, `.mjs`, `.cjs` |
+
+**Planned Support:** Function usage graphs for additional languages are planned for future releases.
+
(back to top)
@@ -249,6 +278,14 @@ Get CodeVisualizer up and running in your VS Code environment in just a few clic
6. **Visualization**: Generates Mermaid flowchart with color-coded nodes and edges
7. **Rendering**: Displays an interactive graph with zoom, pan, and navigation features
+### Function Usage Analysis Pipeline
+
+1. **Workspace Scan**: Finds supported TypeScript/JavaScript source files
+2. **Function Indexing**: Extracts named functions, methods, and their call sites
+3. **Reverse Graph Building**: Builds a caller graph for the function under the cursor
+4. **Visualization**: Renders an interactive Mermaid graph with node navigation
+5. **Drill-In Flowcharts**: Opens caller function flowcharts from usage graph nodes for deeper inspection
+
### AI Label Generation (Function Flowcharts)
1. **Extraction**: Extracts node labels from generated Mermaid code
diff --git a/package-lock.json b/package-lock.json
index eba8496..9f18637 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -610,7 +610,6 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -1137,7 +1136,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1184,7 +1182,6 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -1353,7 +1350,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -1801,7 +1797,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4246,7 +4241,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4320,7 +4314,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4425,7 +4418,6 @@
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -4475,7 +4467,6 @@
"integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.6.1",
"@webpack-cli/configtest": "^3.0.1",
diff --git a/package.json b/package.json
index 5d70741..86180a1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "codevisualizer",
"displayName": "CodeVisualizer",
- "version": "1.0.6",
+ "version": "1.1.2",
"publisher": "ducphamngoc",
"description": "Real-time interactive flowcharts for your code",
"repository": {
@@ -31,6 +31,23 @@
"php"
],
"main": "./dist/extension.js",
+ "scripts": {
+ "vscode:prepublish": "npm run package",
+ "clean": "rimraf dist out",
+ "compile": "npm run clean && webpack",
+ "watch": "webpack --watch",
+ "package": "npm run clean && webpack --mode production --devtool hidden-source-map",
+ "compile-tests": "tsc -p . --outDir out",
+ "watch-tests": "tsc -p . -w --outDir out",
+ "pretest": "npm run compile-tests && npm run compile && npm run lint",
+ "lint": "eslint src --ext ts",
+ "lint:fix": "eslint src --ext ts --fix",
+ "test": "vscode-test",
+ "publish:patch": "vsce publish patch",
+ "publish:minor": "vsce publish minor",
+ "publish:major": "vsce publish major",
+ "install:local": "node ./scripts/install-local.js"
+ },
"activationEvents": [
"onStartupFinished"
],
@@ -57,71 +74,111 @@
"commands": [
{
"command": "codevisualizer.generateFlowchart",
- "title": "CodeVisualizer: Generate Flowchart",
+ "title": "Generate Flowchart",
+ "category": "CodeVisualizer",
"icon": "$(graph)"
},
{
"command": "codevisualizer.openFlowchartInPanel",
- "title": "CodeVisualizer: Open Flowchart in New Window",
+ "title": "Open Flowchart in New Window",
+ "category": "CodeVisualizer",
"icon": "$(open-preview)"
},
{
"command": "codevisualizer.openFlowchartToSide",
- "title": "CodeVisualizer: Open Flowchart to the Side",
+ "title": "Open Flowchart to the Side",
+ "category": "CodeVisualizer",
"icon": "$(split-horizontal)"
},
{
"command": "codevisualizer.openFlowchartInNewColumn",
- "title": "CodeVisualizer: Open Flowchart in New Column",
+ "title": "Open Flowchart in New Column",
+ "category": "CodeVisualizer",
"icon": "$(split-vertical)"
},
{
"command": "codevisualizer.maximizeFlowchartPanel",
- "title": "CodeVisualizer: Maximize Flowchart Panel",
+ "title": "Maximize Flowchart Panel",
+ "category": "CodeVisualizer",
"icon": "$(screen-full)"
},
{
"command": "codevisualizer.refreshFlowchart",
- "title": "CodeVisualizer: Refresh Flowchart",
+ "title": "Refresh Flowchart",
+ "category": "CodeVisualizer",
"icon": "$(refresh)"
},
{
"command": "codevisualizer.exportFlowchart",
- "title": "CodeVisualizer: Export Flowchart",
+ "title": "Export Flowchart",
+ "category": "CodeVisualizer",
"icon": "$(export)"
},
{
"command": "codevisualizer.llm.enableLabels",
- "title": "CodeVisualizer: Enable AI Labels",
+ "title": "Enable AI Labels",
+ "category": "CodeVisualizer",
"icon": "$(sparkle)"
},
{
"command": "codevisualizer.llm.changeModel",
- "title": "CodeVisualizer: Change AI Model",
+ "title": "Change AI Model",
+ "category": "CodeVisualizer",
"icon": "$(settings-gear)"
},
{
"command": "codevisualizer.llm.resetCache",
- "title": "CodeVisualizer: Reset AI Cache",
+ "title": "Reset AI Cache",
+ "category": "CodeVisualizer",
"icon": "$(trash)"
},
{
"command": "codevisualizer.visualizeCodebase",
"title": "Visualize Codebase Flow",
+ "category": "CodeVisualizer",
"icon": "$(organization)"
+ },
+ {
+ "command": "codevisualizer.visualizeFunctionUsage",
+ "title": "Visualize Function Usage",
+ "category": "CodeVisualizer",
+ "icon": "$(references)"
+ }
+ ],
+ "submenus": [
+ {
+ "id": "codevisualizer.editorContextMenu",
+ "label": "Code Visualizer",
+ "icon": "media/icon.svg"
+ },
+ {
+ "id": "codevisualizer.explorerContextMenu",
+ "label": "Code Visualizer",
+ "icon": "media/icon.svg"
}
],
"menus": {
"editor/context": [
+ {
+ "submenu": "codevisualizer.editorContextMenu",
+ "when": "editorTextFocus",
+ "group": "navigation@99"
+ }
+ ],
+ "codevisualizer.editorContextMenu": [
{
"command": "codevisualizer.generateFlowchart",
- "when": "editorTextFocus && editorHasSelection",
- "group": "navigation@1"
+ "when": "editorHasSelection",
+ "group": "1_flowchart@1"
},
{
"command": "codevisualizer.openFlowchartInPanel",
- "when": "editorTextFocus",
- "group": "navigation@2"
+ "group": "1_flowchart@2"
+ },
+ {
+ "command": "codevisualizer.visualizeFunctionUsage",
+ "when": "editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/",
+ "group": "1_flowchart@3"
}
],
"view/title": [
@@ -164,14 +221,25 @@
},
{
"command": "codevisualizer.visualizeCodebase"
+ },
+ {
+ "command": "codevisualizer.visualizeFunctionUsage",
+ "when": "editorLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact)$/"
}
],
"explorer/context": [
{
- "command": "codevisualizer.visualizeCodebase",
+ "submenu": "codevisualizer.explorerContextMenu",
"when": "explorerResourceIsFolder",
"group": "navigation@1"
}
+ ],
+ "codevisualizer.explorerContextMenu": [
+ {
+ "command": "codevisualizer.visualizeCodebase",
+ "when": "explorerResourceIsFolder",
+ "group": "1_flowchart@1"
+ }
]
},
"configuration": {
@@ -294,22 +362,6 @@
}
}
},
- "scripts": {
- "vscode:prepublish": "npm run package",
- "clean": "rimraf dist out",
- "compile": "npm run clean && webpack",
- "watch": "webpack --watch",
- "package": "npm run clean && webpack --mode production --devtool hidden-source-map",
- "compile-tests": "tsc -p . --outDir out",
- "watch-tests": "tsc -p . -w --outDir out",
- "pretest": "npm run compile-tests && npm run compile && npm run lint",
- "lint": "eslint src --ext ts",
- "lint:fix": "eslint src --ext ts --fix",
- "test": "vscode-test",
- "publish:patch": "vsce publish patch",
- "publish:minor": "vsce publish minor",
- "publish:major": "vsce publish major"
- },
"devDependencies": {
"@types/mocha": "^10.0.10",
"@types/node": "^20.x",
diff --git a/scripts/install-local.js b/scripts/install-local.js
new file mode 100644
index 0000000..0f60665
--- /dev/null
+++ b/scripts/install-local.js
@@ -0,0 +1,214 @@
+#!/usr/bin/env node
+/*
+ * Build, package, and (re)install CodeVisualizer into your local VS Code globally.
+ *
+ * One command to go from source -> installed extension:
+ * 1. Builds the production webpack bundle (npm run package).
+ * 2. Packages a .vsix with @vscode/vsce (via npx, no global install needed).
+ * 3. Installs/updates it into VS Code with `code --install-extension --force`.
+ *
+ * After it finishes, the extension is available in EVERY VS Code window.
+ * Reload the window (Ctrl+Shift+P -> "Developer: Reload Window") or restart
+ * VS Code to pick up the new build.
+ *
+ * Usage:
+ * node scripts/install-local.js [options]
+ * npm run install:local -- [options]
+ *
+ * Options:
+ * --code VS Code CLI to install into (default: "code"; use "code-insiders").
+ * --skip-build Repackage + reinstall the current dist/ without rebuilding.
+ * --keep-vsix Keep the generated .vsix (default: removed after install).
+ */
+"use strict";
+
+const { spawnSync } = require("child_process");
+const fs = require("fs");
+const path = require("path");
+
+const repoRoot = path.resolve(__dirname, "..");
+
+// --- arg parsing -------------------------------------------------------------
+const args = process.argv.slice(2);
+function flag(name) {
+ return args.includes(name);
+}
+function opt(name, fallback) {
+ const i = args.indexOf(name);
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
+}
+const codeCli = opt("--code", "code");
+const skipBuild = flag("--skip-build");
+const keepVsix = flag("--keep-vsix");
+
+// --- helpers -----------------------------------------------------------------
+const colors = {
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
+ gray: (s) => `\x1b[90m${s}\x1b[0m`,
+};
+
+function run(parts) {
+ // Pass the full command line as a single string with shell:true so Windows
+ // resolves npm/npx/code (.cmd shims) just like a terminal. Building one string
+ // (rather than an args array) avoids Node's DEP0190 shell-args warning.
+ const line = parts.join(" ");
+ const res = spawnSync(line, {
+ cwd: repoRoot,
+ stdio: "inherit",
+ shell: true,
+ });
+ if (res.status !== 0) {
+ throw new Error(`Command failed (exit ${res.status}): ${line}`);
+ }
+}
+
+// Quote a path/arg for safe inclusion in a shell command line.
+function q(s) {
+ return `"${s}"`;
+}
+
+function which(cmd) {
+ if (path.isAbsolute(cmd)) {
+ return fs.existsSync(cmd);
+ }
+ const probe = process.platform === "win32" ? "where" : "which";
+ return spawnSync(`${probe} ${cmd}`, { shell: true }).status === 0;
+}
+
+function windowsCodeCliCandidates(cmd) {
+ const suffix = cmd === "code-insiders" ? "Insiders\\bin\\code-insiders.cmd" : "bin\\code.cmd";
+ return [
+ process.env.LOCALAPPDATA &&
+ path.join(process.env.LOCALAPPDATA, "Programs", `Microsoft VS Code${cmd === "code-insiders" ? " Insiders" : ""}`, "bin", cmd === "code-insiders" ? "code-insiders.cmd" : "code.cmd"),
+ process.env.PROGRAMFILES &&
+ path.join(process.env.PROGRAMFILES, "Microsoft VS Code", suffix),
+ process.env["PROGRAMFILES(X86)"] &&
+ path.join(process.env["PROGRAMFILES(X86)"], "Microsoft VS Code", suffix),
+ ].filter(Boolean);
+}
+
+function resolveCodeCli(cmd) {
+ if (which(cmd)) {
+ return cmd;
+ }
+
+ if (process.platform !== "win32" || (cmd !== "code" && cmd !== "code-insiders")) {
+ return null;
+ }
+
+ const found = windowsCodeCliCandidates(cmd).find((candidate) => fs.existsSync(candidate));
+ return found ? q(found) : null;
+}
+
+// Return the installed version of an extension id (e.g. "publisher.name"), or
+// null if it is not installed. Uses `code --list-extensions --show-versions`,
+// whose lines look like "publisher.name@1.2.3".
+function installedVersion(cli, extId) {
+ const res = spawnSync(`${cli} --list-extensions --show-versions`, {
+ shell: true,
+ encoding: "utf8",
+ });
+ if (res.status !== 0 || !res.stdout) {
+ return null;
+ }
+ const prefix = `${extId}@`;
+ const line = res.stdout
+ .split(/\r?\n/)
+ .map((l) => l.trim())
+ .find((l) => l.startsWith(prefix));
+ return line ? line.slice(prefix.length) : null;
+}
+
+// --- main --------------------------------------------------------------------
+function main() {
+ console.log(colors.cyan(`==> Repo: ${repoRoot}`));
+
+ if (!which("npm")) {
+ throw new Error("npm not found on PATH. Install Node.js first.");
+ }
+ const resolvedCodeCli = resolveCodeCli(codeCli);
+ if (!resolvedCodeCli) {
+ throw new Error(
+ `VS Code CLI '${codeCli}' not found on PATH. In VS Code run ` +
+ `Ctrl+Shift+P -> "Shell Command: Install 'code' command in PATH", then retry.`
+ );
+ }
+
+ const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
+ const vsixName = `${pkg.name}-${pkg.version}.vsix`;
+ const vsixPath = path.join(repoRoot, vsixName);
+ const extId = `${pkg.publisher}.${pkg.name}`;
+ console.log(colors.cyan(`==> Extension: ${extId}@${pkg.version}`));
+
+ // 0. Refuse to reinstall over an identical version. VS Code only picks up a
+ // new build when the version changes, so installing the same version again is
+ // almost always a mistake (the editor keeps running the old bits). If a
+ // different version is already installed, remember it so we can uninstall it
+ // first and replace it cleanly.
+ const existingVersion = installedVersion(resolvedCodeCli, extId);
+ if (existingVersion === pkg.version) {
+ throw new Error(
+ `'${extId}' ${pkg.version} is already installed. Bump the "version" in ` +
+ `package.json before reinstalling, otherwise VS Code will keep running ` +
+ `the old build.`
+ );
+ }
+ if (existingVersion) {
+ console.log(
+ colors.gray(`==> Found installed ${extId}@${existingVersion}; will replace it.`)
+ );
+ }
+
+ // 1. Install deps if needed
+ if (!fs.existsSync(path.join(repoRoot, "node_modules"))) {
+ console.log(colors.yellow("==> node_modules missing; running npm install..."));
+ run(["npm", "install"]);
+ }
+
+ // 2. Build
+ if (!skipBuild) {
+ console.log(colors.yellow("==> Building production bundle (npm run package)..."));
+ run(["npm", "run", "package"]);
+ } else {
+ console.log(colors.gray("==> Skipping build (--skip-build)."));
+ }
+
+ // 3. Package the .vsix
+ // node_modules is excluded via .vscodeignore and everything is bundled by
+ // webpack into dist/, so package with --no-dependencies.
+ console.log(colors.yellow("==> Packaging .vsix..."));
+ run(["npx", "--yes", "@vscode/vsce", "package", "--no-dependencies", "--out", q(vsixPath)]);
+
+ // 4. Uninstall the previously installed version (if any), then install.
+ if (existingVersion) {
+ console.log(colors.yellow(`==> Uninstalling ${extId}@${existingVersion}...`));
+ run([resolvedCodeCli, "--uninstall-extension", extId]);
+ }
+ console.log(colors.yellow(`==> Installing into '${codeCli}'...`));
+ run([resolvedCodeCli, "--install-extension", q(vsixPath), "--force"]);
+
+ // 5. Cleanup
+ if (!keepVsix) {
+ fs.rmSync(vsixPath, { force: true });
+ console.log(colors.gray(`==> Removed ${vsixName} (use --keep-vsix to keep it).`));
+ } else {
+ console.log(colors.gray(`==> Kept ${vsixPath}`));
+ }
+
+ console.log("");
+ console.log(colors.green(`DONE. '${pkg.displayName}' ${pkg.version} installed globally.`));
+ console.log(
+ colors.green(
+ "Reload VS Code (Ctrl+Shift+P -> 'Developer: Reload Window') to use the new build."
+ )
+ );
+}
+
+try {
+ main();
+} catch (err) {
+ console.error(`\x1b[31mERROR:\x1b[0m ${err.message}`);
+ process.exit(1);
+}
diff --git a/src/core/callgraph/CallGraphAnalyzer.ts b/src/core/callgraph/CallGraphAnalyzer.ts
new file mode 100644
index 0000000..492e0d4
--- /dev/null
+++ b/src/core/callgraph/CallGraphAnalyzer.ts
@@ -0,0 +1,320 @@
+import * as path from "path";
+import * as fs from "fs";
+import { getTypeScriptParser } from "../language-services/typescript";
+import { TsFunctionInfo } from "../language-services/typescript/TsAstParser";
+
+/** A single function/method definition discovered in the workspace. */
+export interface FunctionDef {
+ name: string;
+ file: string; // absolute path
+ startIndex: number;
+ endIndex: number;
+ line: number; // 1-based, for display
+ kind: TsFunctionInfo["kind"];
+ calls: string[];
+}
+
+/** A node in the rendered usage graph (one per function name). */
+export interface UsageGraphNode {
+ id: string; // sanitized, stable id used as the Mermaid node id
+ name: string; // original function name
+ file: string; // absolute path of the representative definition
+ relativePath: string;
+ line: number; // 1-based
+ startIndex: number;
+ endIndex: number;
+ isTarget: boolean;
+}
+
+export interface UsageGraphEdge {
+ from: string; // node id (caller)
+ to: string; // node id (callee)
+}
+
+export interface UsageGraph {
+ nodes: UsageGraphNode[];
+ edges: UsageGraphEdge[];
+ /** True if the graph was truncated because the node limit was reached. */
+ truncated: boolean;
+ /** Number of distinct definitions found for the target name. */
+ targetDefinitionCount: number;
+}
+
+/** Hard caps to keep large codebases responsive. */
+const MAX_NODES = 200;
+const MAX_DEPTH = 25;
+
+/**
+ * Scans TypeScript/JavaScript files in a workspace and builds a reverse call
+ * (usage) graph: given a function, who calls it, recursively up the chain.
+ *
+ * Resolution is name-based (a call to `foo()` matches any definition named
+ * `foo`). This is approximate but matches the lightweight, dependency-free
+ * style of the rest of the extension and works well in practice.
+ */
+export class CallGraphAnalyzer {
+ private workspaceRoot: string;
+ private supportedExtensions = new Set([
+ ".ts",
+ ".tsx",
+ ".mts",
+ ".cts",
+ ".js",
+ ".jsx",
+ ".mjs",
+ ".cjs",
+ ]);
+
+ private allDefs: FunctionDef[] = [];
+ private defsByName: Map = new Map();
+ /** callee name -> definitions whose body calls that name. */
+ private callersByCallee: Map = new Map();
+
+ constructor(workspaceRoot: string) {
+ this.workspaceRoot = workspaceRoot;
+ }
+
+ /** Parses every supported file and indexes definitions and call relations. */
+ public async analyze(): Promise {
+ this.allDefs = [];
+ this.defsByName.clear();
+ this.callersByCallee.clear();
+
+ const parser = await getTypeScriptParser();
+ const files = await this.getAllSupportedFiles();
+
+ for (const file of files) {
+ let content: string;
+ try {
+ content = await fs.promises.readFile(file, "utf-8");
+ } catch {
+ continue;
+ }
+
+ let functions: TsFunctionInfo[];
+ try {
+ functions = parser.extractFunctionsWithCalls(content);
+ } catch (error) {
+ console.error(`CallGraphAnalyzer: failed to parse ${file}:`, error);
+ continue;
+ }
+
+ for (const fn of functions) {
+ const def: FunctionDef = {
+ name: fn.name,
+ file,
+ startIndex: fn.startIndex,
+ endIndex: fn.endIndex,
+ line: fn.startLine + 1,
+ kind: fn.kind,
+ calls: fn.calls,
+ };
+ this.allDefs.push(def);
+
+ const byName = this.defsByName.get(def.name);
+ if (byName) {
+ byName.push(def);
+ } else {
+ this.defsByName.set(def.name, [def]);
+ }
+
+ for (const callee of fn.calls) {
+ const callers = this.callersByCallee.get(callee);
+ if (callers) {
+ callers.push(def);
+ } else {
+ this.callersByCallee.set(callee, [def]);
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Builds the usage graph for a target function: all (transitive) callers.
+ * @param targetName the function name under the cursor.
+ * @param targetFile absolute path of the file the cursor is in.
+ * @param targetPosition byte offset of the cursor (to pick the right
+ * definition when several share the same name).
+ */
+ public buildUsageGraph(
+ targetName: string,
+ targetFile?: string,
+ targetPosition?: number
+ ): UsageGraph {
+ const nodes = new Map();
+ const edgeKeys = new Set();
+ const edges: UsageGraphEdge[] = [];
+ let truncated = false;
+
+ const targetDefs = this.defsByName.get(targetName) || [];
+
+ const ensureNode = (name: string, isTarget: boolean): UsageGraphNode => {
+ const id = this.sanitizeId(name);
+ let node = nodes.get(id);
+ if (node) {
+ if (isTarget) {
+ node.isTarget = true;
+ }
+ return node;
+ }
+ const def = this.pickDefinition(
+ name,
+ isTarget ? targetFile : undefined,
+ isTarget ? targetPosition : undefined
+ );
+ node = {
+ id,
+ name,
+ file: def?.file ?? targetFile ?? "",
+ relativePath: def?.file
+ ? path.relative(this.workspaceRoot, def.file)
+ : "",
+ line: def?.line ?? 0,
+ startIndex: def?.startIndex ?? targetPosition ?? 0,
+ endIndex: def?.endIndex ?? targetPosition ?? 0,
+ isTarget,
+ };
+ nodes.set(id, node);
+ return node;
+ };
+
+ const addEdge = (fromName: string, toName: string) => {
+ const from = this.sanitizeId(fromName);
+ const to = this.sanitizeId(toName);
+ const key = `${from} ${to}`;
+ if (edgeKeys.has(key)) {
+ return;
+ }
+ edgeKeys.add(key);
+ edges.push({ from, to });
+ };
+
+ ensureNode(targetName, true);
+
+ // Breadth-first walk up the caller chain.
+ const visited = new Set([targetName]);
+ let frontier: string[] = [targetName];
+ let depth = 0;
+
+ while (frontier.length > 0 && depth < MAX_DEPTH) {
+ const next: string[] = [];
+ for (const current of frontier) {
+ const callers = this.callersByCallee.get(current) || [];
+ for (const callerDef of callers) {
+ const caller = callerDef.name;
+ if (caller === current) {
+ continue; // ignore direct self-recursion edges
+ }
+ ensureNode(caller, false);
+ addEdge(caller, current);
+
+ if (!visited.has(caller)) {
+ if (nodes.size >= MAX_NODES) {
+ truncated = true;
+ continue;
+ }
+ visited.add(caller);
+ next.push(caller);
+ }
+ }
+ }
+ frontier = next;
+ depth++;
+ }
+
+ if (depth >= MAX_DEPTH && frontier.length > 0) {
+ truncated = true;
+ }
+
+ return {
+ nodes: [...nodes.values()],
+ edges,
+ truncated,
+ targetDefinitionCount: targetDefs.length,
+ };
+ }
+
+ /**
+ * Chooses the most relevant definition for a name. When a cursor file and
+ * position are provided, prefer the definition that contains it.
+ */
+ private pickDefinition(
+ name: string,
+ preferFile?: string,
+ preferPosition?: number
+ ): FunctionDef | undefined {
+ const defs = this.defsByName.get(name);
+ if (!defs || defs.length === 0) {
+ return undefined;
+ }
+
+ if (preferFile !== undefined && preferPosition !== undefined) {
+ const exact = defs.find(
+ (d) =>
+ d.file === preferFile &&
+ preferPosition >= d.startIndex &&
+ preferPosition <= d.endIndex
+ );
+ if (exact) {
+ return exact;
+ }
+ }
+ if (preferFile !== undefined) {
+ const sameFile = defs.find((d) => d.file === preferFile);
+ if (sameFile) {
+ return sameFile;
+ }
+ }
+ return defs[0];
+ }
+
+ private sanitizeId(name: string): string {
+ const cleaned =
+ name
+ .replace(/[^A-Za-z0-9_]/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_+|_+$/g, "") || "anonymous";
+ return `fn_${cleaned}`;
+ }
+
+ private async getAllSupportedFiles(): Promise {
+ const files: string[] = [];
+
+ const walkDir = async (dir: string): Promise => {
+ let entries: fs.Dirent[];
+ try {
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+
+ for (const entry of entries) {
+ if (
+ entry.name.startsWith(".") ||
+ entry.name === "node_modules" ||
+ entry.name === "dist" ||
+ entry.name === "build" ||
+ entry.name === "out"
+ ) {
+ continue;
+ }
+
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ await walkDir(fullPath);
+ } else if (entry.isFile()) {
+ if (
+ this.supportedExtensions.has(path.extname(entry.name)) &&
+ !entry.name.endsWith(".d.ts")
+ ) {
+ files.push(fullPath);
+ }
+ }
+ }
+ };
+
+ await walkDir(this.workspaceRoot);
+ return files;
+ }
+}
diff --git a/src/core/callgraph/CallGraphMermaidGenerator.ts b/src/core/callgraph/CallGraphMermaidGenerator.ts
new file mode 100644
index 0000000..6bfb020
--- /dev/null
+++ b/src/core/callgraph/CallGraphMermaidGenerator.ts
@@ -0,0 +1,76 @@
+import { UsageGraph, UsageGraphNode } from "./CallGraphAnalyzer";
+
+/** Location metadata used by the webview to navigate to a function on click. */
+export interface CallGraphNodeLocation {
+ file: string;
+ start: number;
+ end: number;
+}
+
+export interface CallGraphRenderResult {
+ mermaid: string;
+ /** node id -> source location, consumed by the webview for navigation. */
+ locations: Record;
+}
+
+/**
+ * Renders a usage (reverse call) graph as Mermaid. Edges point from caller to
+ * callee, so the target function sits at the bottom and its (transitive)
+ * callers flow in from the top.
+ */
+export class CallGraphMermaidGenerator {
+ public generate(graph: UsageGraph): CallGraphRenderResult {
+ const lines: string[] = ["flowchart TD"];
+ const locations: Record = {};
+
+ for (const node of graph.nodes) {
+ lines.push(` ${node.id}["${this.renderLabel(node)}"]`);
+ locations[node.id] = {
+ file: node.file,
+ start: node.startIndex,
+ end: node.endIndex,
+ };
+ }
+
+ for (const edge of graph.edges) {
+ lines.push(` ${edge.from} --> ${edge.to}`);
+ }
+
+ // Highlight the target node.
+ const target = graph.nodes.find((n) => n.isTarget);
+ lines.push(
+ " classDef targetStyle fill:#ffd54f,stroke:#ff6f00,stroke-width:3px,color:#000"
+ );
+ lines.push(
+ " classDef callerStyle fill:#e3f2fd,stroke:#1976d2,stroke-width:1.5px,color:#000"
+ );
+ if (target) {
+ lines.push(` class ${target.id} targetStyle`);
+ }
+ const callerIds = graph.nodes
+ .filter((n) => !n.isTarget)
+ .map((n) => n.id);
+ if (callerIds.length > 0) {
+ lines.push(` class ${callerIds.join(",")} callerStyle`);
+ }
+
+ return { mermaid: lines.join("\n"), locations };
+ }
+
+ private renderLabel(node: UsageGraphNode): string {
+ const second = node.relativePath
+ ? `${this.escape(node.relativePath.replace(/\\/g, "/"))}:${node.line}`
+ : "definition not found";
+ return `${this.escape(node.name)} - ${second}`;
+ }
+
+ private escape(text: string): string {
+ return text
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, "#quot;")
+ .replace(//g, "#62;")
+ .replace(/`/g, "#96;")
+ .replace(/\r?\n/g, " ");
+ }
+}
diff --git a/src/core/language-services/typescript/TsAstParser.ts b/src/core/language-services/typescript/TsAstParser.ts
index 451d29c..4b86392 100644
--- a/src/core/language-services/typescript/TsAstParser.ts
+++ b/src/core/language-services/typescript/TsAstParser.ts
@@ -9,6 +9,21 @@ import {
import { ProcessResult, LoopContext } from "../../common/AstParserTypes";
import { ensureParserInit } from "../common/ParserInit";
+/**
+ * Raw information about a single function/method definition together with the
+ * names of the functions it calls. Used to build cross-file call graphs.
+ */
+export interface TsFunctionInfo {
+ name: string;
+ kind: "function" | "method" | "arrow";
+ startIndex: number;
+ endIndex: number;
+ /** 0-based line number where the definition starts. */
+ startLine: number;
+ /** Distinct names of functions/methods called within this definition's body. */
+ calls: string[];
+}
+
export class TsAstParser extends AbstractParser {
private currentFunctionIsArrow = false;
@@ -96,6 +111,141 @@ export class TsAstParser extends AbstractParser {
return arrowFunc?.childForFieldName("name")?.text || "[anonymous arrow]";
}
+ /** Function-expression node types (may be anonymous or bound to a name). */
+ private static readonly FUNCTION_EXPRESSION_TYPES = new Set([
+ "arrow_function",
+ "function",
+ "function_expression",
+ "generator_function",
+ ]);
+
+ /** Node types that are always their own named definition. */
+ private static readonly NAMED_DEFINITION_TYPES = new Set([
+ "function_declaration",
+ "generator_function_declaration",
+ "method_definition",
+ ]);
+
+ /**
+ * Extracts every named function/method/arrow-function definition in the
+ * source along with the names of the functions it calls. Calls made inside a
+ * nested *named* function are attributed to that function; calls inside
+ * anonymous callbacks are attributed to the enclosing function. This is the
+ * building block for cross-file call/usage graphs.
+ */
+ public extractFunctionsWithCalls(sourceCode: string): TsFunctionInfo[] {
+ const tree = this.parser.parse(sourceCode);
+ const results: TsFunctionInfo[] = [];
+
+ const pushDef = (
+ defNode: Parser.SyntaxNode,
+ nameNode: Parser.SyntaxNode | null,
+ bodyNode: Parser.SyntaxNode | null,
+ kind: TsFunctionInfo["kind"]
+ ) => {
+ const name = nameNode?.text;
+ if (!name) return;
+ results.push({
+ name,
+ kind,
+ startIndex: defNode.startIndex,
+ endIndex: defNode.endIndex,
+ startLine: defNode.startPosition.row,
+ calls: this.collectCalleeNames(bodyNode),
+ });
+ };
+
+ for (const f of tree.rootNode.descendantsOfType("function_declaration")) {
+ pushDef(f, f.childForFieldName("name"), f.childForFieldName("body"), "function");
+ }
+
+ for (const m of tree.rootNode.descendantsOfType("method_definition")) {
+ pushDef(m, m.childForFieldName("name"), m.childForFieldName("body"), "method");
+ }
+
+ // Arrow/function expressions bound to a variable (const fn = () => {}).
+ for (const v of tree.rootNode.descendantsOfType("variable_declarator")) {
+ const value = v.childForFieldName("value");
+ if (value && TsAstParser.FUNCTION_EXPRESSION_TYPES.has(value.type)) {
+ pushDef(v, v.childForFieldName("name"), value.childForFieldName("body"), "arrow");
+ }
+ }
+
+ // Class field arrow functions (onClick = () => {}), common in React etc.
+ for (const p of tree.rootNode.descendantsOfType("public_field_definition")) {
+ const value = p.childForFieldName("value");
+ if (value && TsAstParser.FUNCTION_EXPRESSION_TYPES.has(value.type)) {
+ pushDef(p, p.childForFieldName("name"), value.childForFieldName("body"), "method");
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Collects the distinct names of functions/methods invoked within a body.
+ * Calls inside anonymous callbacks (e.g. `arr.map(x => foo(x))`) are
+ * attributed to the enclosing function, but calls inside nested *named*
+ * definitions are not (they are recorded against that definition instead).
+ */
+ private collectCalleeNames(body: Parser.SyntaxNode | null): string[] {
+ const names = new Set();
+ if (!body) return [];
+
+ const visit = (node: Parser.SyntaxNode) => {
+ for (const child of node.namedChildren) {
+ // Skip nested named definitions; their calls belong to them.
+ if (this.isNamedDefinition(child)) {
+ continue;
+ }
+ if (child.type === "call_expression") {
+ const callee = this.calleeName(child.childForFieldName("function"));
+ if (callee) names.add(callee);
+ }
+ visit(child);
+ }
+ };
+
+ visit(body);
+ return [...names];
+ }
+
+ /**
+ * True when a node is a function that has its own definition entry: a
+ * declaration/method, or a function expression bound to a name (variable or
+ * class field). Anonymous function expressions return false so their calls
+ * are attributed to the enclosing function.
+ */
+ private isNamedDefinition(node: Parser.SyntaxNode): boolean {
+ if (TsAstParser.NAMED_DEFINITION_TYPES.has(node.type)) {
+ return true;
+ }
+ if (TsAstParser.FUNCTION_EXPRESSION_TYPES.has(node.type)) {
+ const parentType = node.parent?.type;
+ return (
+ parentType === "variable_declarator" ||
+ parentType === "public_field_definition"
+ );
+ }
+ return false;
+ }
+
+ /**
+ * Resolves the simple name being called from a call expression's "function"
+ * node (e.g. `foo()` -> "foo", `obj.bar()` / `this.bar()` -> "bar").
+ */
+ private calleeName(fn: Parser.SyntaxNode | null): string | undefined {
+ if (!fn) return undefined;
+ if (fn.type === "identifier") return fn.text;
+ if (fn.type === "member_expression") {
+ return fn.childForFieldName("property")?.text || undefined;
+ }
+ if (fn.type === "parenthesized_expression" && fn.namedChild(0)) {
+ return this.calleeName(fn.namedChild(0));
+ }
+ return undefined;
+ }
+
public generateFlowchart(
sourceCode: string,
functionName?: string,
diff --git a/src/core/language-services/typescript/index.ts b/src/core/language-services/typescript/index.ts
index dccff9e..2dc488d 100644
--- a/src/core/language-services/typescript/index.ts
+++ b/src/core/language-services/typescript/index.ts
@@ -25,4 +25,15 @@ export async function analyzeTypeScriptCode(
return parser.generateFlowchart(code, undefined, position);
}
+/**
+ * Returns the initialized shared TypeScript parser instance. Used by features
+ * that need lower-level parsing (e.g. cross-file call graphs).
+ */
+export async function getTypeScriptParser(): Promise {
+ if (!parserPromise) {
+ throw new Error("TypeScript language service not initialized.");
+ }
+ return parserPromise;
+}
+
export { TsAstParser };
diff --git a/src/extension.ts b/src/extension.ts
index 089bf5a..150f1b5 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -2,6 +2,8 @@ import * as vscode from "vscode";
import { FlowchartViewProvider } from "./view/FlowchartViewProvider";
import { FlowchartPanelProvider } from "./view/FlowchartPanelProvider";
import { CodebaseFlowProvider } from "./view/CodebaseFlowProvider";
+import { FunctionUsageProvider } from "./view/FunctionUsageProvider";
+import { getTypeScriptParser } from "./core/language-services/typescript";
import { initLanguageServices } from "./core/language-services";
import { LLMManager } from "./core/llm/LLMManager";
import { setExtensionContext } from "./core/llm/LLMContext";
@@ -235,6 +237,49 @@ export async function activate(context: vscode.ExtensionContext) {
}
}),
+ // Function Usage: reverse call graph for the function under the cursor (TS/JS)
+ vscode.commands.registerCommand("codevisualizer.visualizeFunctionUsage", async () => {
+ console.log("Command codevisualizer.visualizeFunctionUsage executed");
+ try {
+ const editor = vscode.window.activeTextEditor;
+ if (!editor) {
+ vscode.window.showWarningMessage("Open a file and place the cursor inside a function.");
+ return;
+ }
+
+ const supportedLanguages = [
+ "typescript",
+ "typescriptreact",
+ "javascript",
+ "javascriptreact",
+ ];
+ if (!supportedLanguages.includes(editor.document.languageId)) {
+ vscode.window.showInformationMessage(
+ "Function usage graphs are currently only supported for TypeScript/JavaScript."
+ );
+ return;
+ }
+
+ const parser = await getTypeScriptParser();
+ const sourceCode = editor.document.getText();
+ const position = editor.document.offsetAt(editor.selection.active);
+ const functionName = parser.findFunctionAtPosition(sourceCode, position);
+
+ if (!functionName || functionName.startsWith("[anonymous")) {
+ vscode.window.showWarningMessage(
+ "Place the cursor inside a named function to visualize its usage."
+ );
+ return;
+ }
+
+ const usageProvider = new FunctionUsageProvider(context.extensionUri);
+ await usageProvider.show(functionName, editor.document.uri.fsPath, position);
+ } catch (error) {
+ console.error("Error in visualizeFunctionUsage:", error);
+ vscode.window.showErrorMessage(`Failed to visualize function usage: ${error}`);
+ }
+ }),
+
);
}
diff --git a/src/view/BaseFlowchartProvider.ts b/src/view/BaseFlowchartProvider.ts
index 944961a..1572d72 100644
--- a/src/view/BaseFlowchartProvider.ts
+++ b/src/view/BaseFlowchartProvider.ts
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import { analyzeCode } from "../core/analyzer";
-import { LocationMapEntry } from "../ir/ir";
+import { LocationMapEntry, NodeType } from "../ir/ir";
import { EnhancedMermaidGenerator } from "../core/EnhancedMermaidGenerator";
import { LLMManager } from "../core/llm/LLMManager";
import { getExtensionContext } from "../core/llm/LLMContext";
@@ -62,6 +62,11 @@ export type UserInteractionEndMessage = {
payload: {};
};
+export type DrillIntoFunctionMessage = {
+ command: "drillIntoFunction";
+ payload: { start: number; end: number };
+};
+
export type WebviewMessage =
| HighlightCodeMessage
| ExportMessage
@@ -72,7 +77,16 @@ export type WebviewMessage =
| DisableLLMLabelsMessage
| SetupLLMMessage
| UserInteractionStartMessage
- | UserInteractionEndMessage;
+ | UserInteractionEndMessage
+ | DrillIntoFunctionMessage;
+
+/** Node types that represent a call to another function/method worth drilling into. */
+const DRILLABLE_NODE_TYPES = new Set([
+ NodeType.FUNCTION_CALL,
+ NodeType.METHOD_CALL,
+ NodeType.MACRO_CALL,
+ NodeType.SUBROUTINE,
+]);
export interface FlowchartViewContext {
isPanel: boolean;
@@ -213,12 +227,25 @@ export abstract class BaseFlowchartProvider {
switch (message.command) {
case "highlightCode": {
const { start, end } = message.payload;
- const editor = vscode.window.activeTextEditor;
- if (editor) {
- const startPos = editor.document.positionAt(start);
- const endPos = editor.document.positionAt(end);
+ // Use the document this flowchart was generated from (rather than
+ // whatever editor happens to be active) so that clicking a box in a
+ // popup flowchart always navigates to the right file.
+ const document = this._currentDocument;
+ if (document) {
+ const startPos = document.positionAt(start);
+ const endPos = document.positionAt(end);
const range = new vscode.Range(startPos, endPos);
+ // Reuse the already-active editor if it's already showing this
+ // document (the common case) so we don't disturb its view column;
+ // otherwise (e.g. clicking a box inside a popup) open it fresh.
+ const activeEditor = vscode.window.activeTextEditor;
+ const editor =
+ activeEditor && activeEditor.document === document
+ ? activeEditor
+ : await vscode.window.showTextDocument(document, {
+ viewColumn: vscode.ViewColumn.One,
+ });
editor.selection = new vscode.Selection(range.start, range.end);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
@@ -285,6 +312,60 @@ export abstract class BaseFlowchartProvider {
this._userInteracting = false;
break;
}
+
+ case "drillIntoFunction": {
+ await this.drillIntoFunction(message.payload);
+ break;
+ }
+ }
+ }
+
+ /**
+ * Resolves the definition of the call at the given offset (using the
+ * workspace's language providers) and opens a standalone popup showing
+ * that callee's own code flow, without disturbing this flowchart.
+ */
+ private async drillIntoFunction(payload: {
+ start: number;
+ end: number;
+ }): Promise {
+ const document = this._currentDocument;
+ if (!document) {
+ return;
+ }
+ try {
+ const position = document.positionAt(payload.start);
+ const results = await vscode.commands.executeCommand<
+ (vscode.Location | vscode.LocationLink)[] | undefined
+ >("vscode.executeDefinitionProvider", document.uri, position);
+
+ if (!results || results.length === 0) {
+ vscode.window.showInformationMessage(
+ "Could not resolve a definition for this call."
+ );
+ return;
+ }
+
+ const first = results[0];
+ const targetUri = "targetUri" in first ? first.targetUri : first.uri;
+ const targetRange =
+ "targetSelectionRange" in first && first.targetSelectionRange
+ ? first.targetSelectionRange
+ : "targetRange" in first
+ ? first.targetRange
+ : first.range;
+
+ // Lazily required to avoid a circular import: FunctionFlowPopupProvider
+ // extends BaseFlowchartProvider.
+ const { FunctionFlowPopupProvider } = require("./FunctionFlowPopupProvider") as
+ typeof import("./FunctionFlowPopupProvider");
+ const popup = new FunctionFlowPopupProvider(this._extensionUri);
+ await popup.showFor(targetUri, targetRange);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ vscode.window.showErrorMessage(
+ `Could not open function definition: ${message}`
+ );
}
}
@@ -303,6 +384,13 @@ export abstract class BaseFlowchartProvider {
return line.startsWith("click ", index);
}
+ private static sanitizeMermaidNodeId(nodeId: string): string {
+ return nodeId
+ .replace(/\s+/g, "_")
+ .replace(/[^\w]/g, "")
+ .replace(/_+/g, "_");
+ }
+
private extractClickHandlers(src: string): string[] {
if (this._cachedClickHandlers && this._cachedClickHandlers.source === src) {
return this._cachedClickHandlers.lines;
@@ -429,7 +517,7 @@ export abstract class BaseFlowchartProvider {
const webview = this.getWebview();
if (webview) {
// Sanitize nodeId before sending to webview to match sanitized IDs in the diagram
- const sanitizedNodeId = nodeId ? nodeId.replace(/\s+/g, '_').replace(/[^\w-]/g, '_').replace(/_+/g, '_') : null;
+ const sanitizedNodeId = nodeId ? BaseFlowchartProvider.sanitizeMermaidNodeId(nodeId) : null;
webview.postMessage({
command: "highlightNode",
payload: { nodeId: sanitizedNodeId },
@@ -484,8 +572,29 @@ export abstract class BaseFlowchartProvider {
return;
}
- const position = editor.document.offsetAt(editor.selection.active);
- const document = editor.document;
+ await this.updateViewForDocument(editor.document, editor.selection.active);
+ }
+
+ protected async updateViewForDocument(
+ document: vscode.TextDocument,
+ activePosition: vscode.Position
+ ): Promise {
+ const webview = this.getWebview();
+ if (!webview) {
+ return;
+ }
+
+ // Prevent updates when user is interacting with the webview
+ if (this._userInteracting) {
+ return;
+ }
+
+ // Prevent multiple simultaneous updates
+ if (this._isUpdating) {
+ return;
+ }
+
+ const position = document.offsetAt(activePosition);
// Check if we need to update - avoid unnecessary regeneration
const shouldUpdate = this._shouldUpdate(document, position);
@@ -493,7 +602,7 @@ export abstract class BaseFlowchartProvider {
// Just update highlighting if we're still in the same function
if (
this._currentFunctionRange &&
- this._currentFunctionRange.contains(editor.selection.active)
+ this._currentFunctionRange.contains(activePosition)
) {
const entry = this._locationMap.find(
(e) => position >= e.start && position <= e.end
@@ -550,12 +659,31 @@ export abstract class BaseFlowchartProvider {
// Calculate code metrics
const codeMetrics = MetricsAnalyzer.analyze(flowchartIR, document.getText());
+ // Node ids are sanitized in-place by the generator above, so this map's
+ // keys line up with the ids that end up in the rendered SVG.
+ const callNodeLocations: Record = {};
+ const nodeLocations: Record = {};
+ for (const entry of flowchartIR.locationMap) {
+ nodeLocations[BaseFlowchartProvider.sanitizeMermaidNodeId(entry.nodeId)] = {
+ start: entry.start,
+ end: entry.end,
+ };
+ }
+ for (const node of flowchartIR.nodes) {
+ if (node.nodeType && DRILLABLE_NODE_TYPES.has(node.nodeType) && node.location) {
+ callNodeLocations[node.id] = {
+ start: node.location.start,
+ end: node.location.end,
+ };
+ }
+ }
+
const ctx = getExtensionContext();
const availability = ctx ? await LLMManager.getAvailability(ctx) : { enabled: false, provider: "openai", model: "" };
- this.setWebviewHtml(this.getWebviewContent(mermaidCode, this.getNonce(), availability, codeMetrics));
+ this.setWebviewHtml(this.getWebviewContent(mermaidCode, this.getNonce(), availability, codeMetrics, callNodeLocations, nodeLocations));
// After updating the view, immediately highlight the node for the current cursor
- const offset = editor.document.offsetAt(editor.selection.active);
+ const offset = document.offsetAt(activePosition);
const entry = this._locationMap.find(
(e) => offset >= e.start && offset <= e.end
);
@@ -614,7 +742,9 @@ export abstract class BaseFlowchartProvider {
flowchartSyntax: string,
nonce: string,
llmAvailability?: { enabled: boolean; provider: string; model: string },
- codeMetrics?: CodeMetrics
+ codeMetrics?: CodeMetrics,
+ callNodeLocations?: Record,
+ nodeLocations?: Record
): string {
const theme =
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Dark
@@ -723,6 +853,25 @@ export abstract class BaseFlowchartProvider {
#mermaid-source { display: none; }
+ /* Drill-in icon shown on function/method-call nodes */
+ .drill-icon { cursor: pointer; }
+ .drill-icon .drill-icon-bg {
+ fill: var(--vscode-button-background);
+ stroke: var(--vscode-button-border, var(--vscode-panel-border));
+ stroke-width: 1px;
+ opacity: 0.85;
+ }
+ .drill-icon:hover .drill-icon-bg {
+ fill: var(--vscode-button-hoverBackground);
+ opacity: 1;
+ }
+ .drill-icon .drill-icon-glyph {
+ font-size: 13px;
+ fill: var(--vscode-button-foreground);
+ pointer-events: none;
+ user-select: none;
+ }
+
/* Other styles for controls */
${ this.getStylesForControls(context) }
@@ -737,13 +886,32 @@ export abstract class BaseFlowchartProvider {
+
+
+
+
+
+
+
${mermaidCode.replace(/<\/script>/gi, "<\\/script>")}
+
+ ${mermaidCode.replace(//g, ">")}
+
+
+
+