diff --git a/README.md b/README.md index 99a3e9b..c468805 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/package.json b/package.json index 390a974..bc7e0a1 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "reference-graph", + "publisher": "yuichkun", "displayName": "Reference Graph", "description": "Visualize recursive references as an interactive graph", "version": "0.0.2", diff --git a/src/graph/types.ts b/src/graph/types.ts index 2df6b9b..119a6e4 100644 --- a/src/graph/types.ts +++ b/src/graph/types.ts @@ -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 }; diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 4ca0ab4..7b24132 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -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.'); @@ -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'); + }); +}); diff --git a/src/webview/WebviewProvider.ts b/src/webview/WebviewProvider.ts index 4c5eebd..d3bfa65 100644 --- a/src/webview/WebviewProvider.ts +++ b/src/webview/WebviewProvider.ts @@ -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; } @@ -139,6 +142,34 @@ export class WebviewProvider { } } + private async handleSaveImage(imageData: string, filename: string): Promise { + 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 { try { const uri = vscode.Uri.parse(uriString); diff --git a/webview-ui/package-lock.json b/webview-ui/package-lock.json index ece1d14..73c3be1 100644 --- a/webview-ui/package-lock.json +++ b/webview-ui/package-lock.json @@ -10,6 +10,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", @@ -3704,6 +3705,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-to-image": { + "version": "1.11.11", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.11.tgz", + "integrity": "sha512-9gux8QhvjRO/erSnDPv28noDZcPZmYE7e1vFsBLKLlRlKDSqNJYebj6Qz1TGd5lsRV+X+xYyjCKjuZdABinWjA==", + "license": "MIT" + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", diff --git a/webview-ui/package.json b/webview-ui/package.json index ccbfd10..5891130 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -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", diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index a0c530c..3049383 100644 --- a/webview-ui/src/components/ReferenceGraph.tsx +++ b/webview-ui/src/components/ReferenceGraph.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useMemo, useRef } from 'react'; +import { flushSync } from 'react-dom'; import { ReactFlow, ReactFlowProvider, @@ -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'; @@ -37,9 +42,12 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe const [edges, setEdges, onEdgesChange] = useEdgesState([]); 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(null); const filteredData = useMemo(() => { @@ -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 (
@@ -188,10 +251,22 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe /> Hide imports (experimental) - - - - + + + + + + + ); } diff --git a/webview-ui/src/types/messages.ts b/webview-ui/src/types/messages.ts index 415cc12..5293765 100644 --- a/webview-ui/src/types/messages.ts +++ b/webview-ui/src/types/messages.ts @@ -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 };