Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Visualize code references as an interactive graph.
- **Smart Layout**: Automatic graph layout using ELKjs that considers node sizes
- **Search**: Press `Cmd/Ctrl+F` to search nodes by symbol name or file path
- **File Filtering**: Filter nodes by file path using glob patterns (e.g., `src/**/*.ts`, `**/test/**`)
- **Export PNG**: Save the current graph view as a PNG image
- **Click to Navigate**: Click any node to jump to that location in your editor

## Usage
Expand All @@ -33,6 +34,9 @@ Use the filter panel (top-left) to focus on specific files:

Patterns use glob syntax — `**` matches any directory depth, `*` matches any filename.

### Exporting as PNG
Click the **Export PNG** button (top-right) to save the current graph view as an image. A save dialog will open to choose the destination.

## Keyboard Shortcuts

| Shortcut | Action |
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"name": "reference-graph",
"publisher": "yuichkun",
"displayName": "Reference Graph",
"description": "Visualize recursive references as an interactive graph",
"version": "0.0.2",
Expand Down
3 changes: 2 additions & 1 deletion src/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,5 @@ export type ExtensionMessage =
export type WebviewMessage =
| { type: 'openFile'; uri: string; line: number; character: number }
| { type: 'ready' }
| { type: 'expandNode'; nodeId: string; uri: string; line: number; character: number };
| { type: 'expandNode'; nodeId: string; uri: string; line: number; character: number }
| { type: 'saveImage'; imageData: string; filename: string };
145 changes: 142 additions & 3 deletions src/test/extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import * as assert from 'assert';

// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../../extension';
import { WebviewProvider } from '../webview/WebviewProvider';

suite('Extension Test Suite', () => {
vscode.window.showInformationMessage('Start all tests.');
Expand All @@ -13,3 +11,144 @@ suite('Extension Test Suite', () => {
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
});
});

suite('WebviewProvider Message Handling', () => {
let provider: WebviewProvider;
let originalShowSaveDialog: typeof vscode.window.showSaveDialog;
let originalShowInformationMessage: typeof vscode.window.showInformationMessage;
let originalShowErrorMessage: typeof vscode.window.showErrorMessage;

let showSaveDialogCalls: any[] = [];
let showInformationMessageCalls: any[] = [];
let showErrorMessageCalls: any[] = [];

setup(() => {
const extensionUri = vscode.Uri.file('/test/extension');
provider = new WebviewProvider(extensionUri);

originalShowSaveDialog = vscode.window.showSaveDialog;
originalShowInformationMessage = vscode.window.showInformationMessage;
originalShowErrorMessage = vscode.window.showErrorMessage;

showSaveDialogCalls = [];
showInformationMessageCalls = [];
showErrorMessageCalls = [];

(vscode.window as any).showSaveDialog = async (options: any) => {
showSaveDialogCalls.push(options);
return showSaveDialogCalls[showSaveDialogCalls.length - 1]._mockResult;
};

(vscode.window as any).showInformationMessage = (message: string) => {
showInformationMessageCalls.push(message);
};

(vscode.window as any).showErrorMessage = (message: string) => {
showErrorMessageCalls.push(message);
};
});

teardown(() => {
vscode.window.showSaveDialog = originalShowSaveDialog;
vscode.window.showInformationMessage = originalShowInformationMessage;
vscode.window.showErrorMessage = originalShowErrorMessage;
});

test('handleMessage accepts saveImage message type', async () => {
const base64ImageData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const filename = 'test-graph.png';

const mockPanel = {
webview: {
postMessage: () => {},
onDidReceiveMessage: () => {},
html: ''
},
reveal: () => {},
onDidDispose: () => {},
dispose: () => {}
} as any;

(provider as any).panel = mockPanel;

(vscode.window.showSaveDialog as any)._mockResult = undefined;

(provider as any).handleMessage({
type: 'saveImage',
imageData: base64ImageData,
filename: filename
});

await new Promise(resolve => setTimeout(resolve, 100));

assert.strictEqual(showSaveDialogCalls.length, 1, 'showSaveDialog should be called for saveImage message');
const dialogOptions = showSaveDialogCalls[0];
assert.strictEqual(dialogOptions.filters?.['PNG Images'][0], 'png', 'Filter should be PNG');
assert.ok(dialogOptions.defaultUri?.fsPath.includes(filename), 'Default URI should include filename');
});

test('handleMessage silently returns when user cancels save dialog', async () => {
const base64ImageData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const filename = 'test-graph.png';

(vscode.window.showSaveDialog as any)._mockResult = undefined;

const mockPanel = {
webview: {
postMessage: () => {},
onDidReceiveMessage: () => {},
html: ''
},
reveal: () => {},
onDidDispose: () => {},
dispose: () => {}
} as any;

(provider as any).panel = mockPanel;

(provider as any).handleMessage({
type: 'saveImage',
imageData: base64ImageData,
filename: filename
});

await new Promise(resolve => setTimeout(resolve, 100));

assert.strictEqual(showErrorMessageCalls.length, 0, 'showErrorMessage should not be called on cancel');
assert.strictEqual(showInformationMessageCalls.length, 0, 'showInformationMessage should not be called on cancel');
});

test('handleMessage calls showSaveDialog with PNG filter', async () => {
const base64ImageData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
const filename = 'reference-graph-1234567890.png';

(vscode.window.showSaveDialog as any)._mockResult = undefined;

const mockPanel = {
webview: {
postMessage: () => {},
onDidReceiveMessage: () => {},
html: ''
},
reveal: () => {},
onDidDispose: () => {},
dispose: () => {}
} as any;

(provider as any).panel = mockPanel;

(provider as any).handleMessage({
type: 'saveImage',
imageData: base64ImageData,
filename: filename
});

await new Promise(resolve => setTimeout(resolve, 100));

assert.strictEqual(showSaveDialogCalls.length, 1, 'showSaveDialog should be called');
const options = showSaveDialogCalls[0];
assert.ok(options.filters, 'showSaveDialog should have filters');
assert.ok(options.filters['PNG Images'], 'PNG Images filter should exist');
assert.strictEqual(options.filters['PNG Images'][0], 'png', 'PNG filter should be png');
});
});
31 changes: 31 additions & 0 deletions src/webview/WebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export class WebviewProvider {
case 'expandNode':
this.handleExpandNode(message.nodeId, message.uri, message.line, message.character);
break;
case 'saveImage':
this.handleSaveImage(message.imageData, message.filename);
break;
case 'ready':
break;
}
Expand All @@ -139,6 +142,34 @@ export class WebviewProvider {
}
}

private async handleSaveImage(imageData: string, filename: string): Promise<void> {
try {
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file(filename),
filters: {
'PNG Images': ['png']
}
});

if (!uri) {
// User cancelled - silent return
return;
}

// Convert base64 to buffer
const base64Data = imageData.replace(/^data:image\/png;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');

// Write file
await vscode.workspace.fs.writeFile(uri, buffer);

vscode.window.showInformationMessage(`Graph exported to ${uri.fsPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to save image';
vscode.window.showErrorMessage(`Export failed: ${message}`);
}
}

private async openFile(uriString: string, line: number, character: number): Promise<void> {
try {
const uri = vscode.Uri.parse(uriString);
Expand Down
7 changes: 7 additions & 0 deletions webview-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@xyflow/react": "^12.4.0",
"elkjs": "^0.9.3",
"html-to-image": "1.11.11",
"picomatch": "^4.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
83 changes: 79 additions & 4 deletions webview-ui/src/components/ReferenceGraph.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import { flushSync } from 'react-dom';
import {
ReactFlow,
ReactFlowProvider,
Expand All @@ -9,14 +10,18 @@ import {
useNodesState,
useEdgesState,
useReactFlow,
getNodesBounds,
getViewportForBounds,
type Node,
type Edge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { toPng } from 'html-to-image';

import { ReferenceNode } from './ReferenceNode';
import { useElkLayout } from '../hooks/useElkLayout';
import { useFileFilter } from '../hooks/useFileFilter';
import { useVSCodeAPI } from '../hooks/useVSCodeAPI';
import { FileFilterPanel } from './FileFilterPanel';
import type { GraphData, GraphNode } from '../types/messages';

Expand All @@ -37,9 +42,12 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [isLayouting, setIsLayouting] = useState(false);
const [hideImports, setHideImports] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const fileFilter = useFileFilter(data?.nodes || []);
const { calculateLayout } = useElkLayout();
const { setCenter, getNode, fitView } = useReactFlow();
const { postMessage } = useVSCodeAPI();
const { getNodes } = useReactFlow();
const prevRootIdRef = useRef<string | null>(null);

const filteredData = useMemo(() => {
Expand Down Expand Up @@ -137,6 +145,61 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe
}
}, [focusedNodeId, getNode, setCenter]);

const handleExport = async () => {
if (!filteredData || isExporting) {return;}

flushSync(() => setIsExporting(true));

// Yield to browser paint (double RAF ensures paint completes)
await new Promise(resolve =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
);

try {
const nodesList = getNodes();
const nodesBounds = getNodesBounds(nodesList);
const padding = 100;
// Use exact node bounds + padding (no minimum constraints)
const imageWidth = nodesBounds.width + padding * 2;
const imageHeight = nodesBounds.height + padding * 2;

const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2, 0);

const controls = document.querySelector('.react-flow__controls') as HTMLElement;
const background = document.querySelector('.react-flow__background') as HTMLElement;

if (controls) {controls.style.display = 'none';}
if (background) {background.style.display = 'none';}

const dataUrl = await toPng(
document.querySelector('.react-flow__viewport') as HTMLElement,
{
backgroundColor: 'transparent',
width: imageWidth,
height: imageHeight,
style: {
width: `${imageWidth}px`,
height: `${imageHeight}px`,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
},
}
);

if (controls) {controls.style.display = '';}
if (background) {background.style.display = '';}

postMessage({
type: 'saveImage',
imageData: dataUrl,
filename: `reference-graph-${Date.now()}.png`,
});
} catch (error) {
console.error('Export failed:', error);
} finally {
setIsExporting(false);
}
};

if (!filteredData || filteredData.nodes.length === 0) {
return (
<div className="empty-state">
Expand Down Expand Up @@ -188,10 +251,22 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe
/>
Hide imports (experimental)
</label>
</Panel>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
</ReactFlow>
</Panel>
<Panel position="top-right">
<button
onClick={handleExport}
disabled={!filteredData || filteredData.nodes.length === 0 || isLayouting || isExporting}
style={{
padding: '8px 12px',
cursor: isExporting ? 'wait' : 'pointer',
}}
>
{isExporting ? 'Exporting...' : 'Export PNG'}
</button>
</Panel>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
</ReactFlow>
);
}

Expand Down
3 changes: 2 additions & 1 deletion webview-ui/src/types/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,5 @@ export type ExtensionMessage =
export type WebviewMessage =
| { type: 'openFile'; uri: string; line: number; character: number }
| { type: 'ready' }
| { type: 'expandNode'; nodeId: string; uri: string; line: number; character: number };
| { type: 'expandNode'; nodeId: string; uri: string; line: number; character: number }
| { type: 'saveImage'; imageData: string; filename: string };