From b3e820714e669f8a994d8ca7bd1d5b36034f054c Mon Sep 17 00:00:00 2001 From: yuichkun Date: Sun, 25 Jan 2026 15:44:35 +0900 Subject: [PATCH 01/11] fix(package): add publisher field to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) 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", From c3bd780232e7b60ceb79421dd7cc1a3d64224855 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 06:51:06 +0000 Subject: [PATCH 02/11] chore(webview): add html-to-image dependency for PNG export --- webview-ui/package-lock.json | 7 +++++++ webview-ui/package.json | 1 + 2 files changed, 8 insertions(+) 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", From b31510946fd69e8fb336d5d983b2e61ff75a9c7a Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 06:53:38 +0000 Subject: [PATCH 03/11] feat(types): add saveImage message type for PNG export --- src/graph/types.ts | 3 ++- webview-ui/src/types/messages.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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 }; From 420177b7fbd1fdc636540e5bbad2cd35de4e8625 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 06:57:40 +0000 Subject: [PATCH 04/11] feat(webview): add PNG export button with viewport capture --- webview-ui/src/components/ReferenceGraph.tsx | 75 ++++++++++++++++++-- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index a0c530c..8c8dd9c 100644 --- a/webview-ui/src/components/ReferenceGraph.tsx +++ b/webview-ui/src/components/ReferenceGraph.tsx @@ -9,14 +9,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 +41,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 +144,54 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe } }, [focusedNodeId, getNode, setCenter]); + const handleExport = async () => { + if (!filteredData || isExporting) return; + + setIsExporting(true); + try { + const nodesList = getNodes(); + const nodesBounds = getNodesBounds(nodesList); + const padding = 50; + const width = Math.max(1024, nodesBounds.width + padding * 2); + const height = Math.max(768, nodesBounds.height + padding * 2); + + const viewport = getViewportForBounds(nodesBounds, width, height, 0.5, 2, padding); + + 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, + height, + style: { + width: `${width}px`, + height: `${height}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 +243,22 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe /> Hide imports (experimental) - - - - + + + + + + + ); } From c50427c00e052a7f5390de53865d4ee5bbbc5ddb Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 06:58:49 +0000 Subject: [PATCH 05/11] feat(extension): handle saveImage message with save dialog --- src/webview/WebviewProvider.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) 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); From aebfccb75a9c3aada4feb4d1d52b62a0ace11dc3 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 07:07:31 +0000 Subject: [PATCH 06/11] test(extension): add unit test for saveImage message handler --- src/test/extension.test.ts | 145 ++++++++++++++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 3 deletions(-) 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'); + }); +}); From 5bc607439cf6a3cf31a9458e973379086f27263f Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 07:08:03 +0000 Subject: [PATCH 07/11] style: fix eslint curly warnings in ReferenceGraph --- webview-ui/src/components/ReferenceGraph.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index 8c8dd9c..7b29009 100644 --- a/webview-ui/src/components/ReferenceGraph.tsx +++ b/webview-ui/src/components/ReferenceGraph.tsx @@ -145,7 +145,7 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe }, [focusedNodeId, getNode, setCenter]); const handleExport = async () => { - if (!filteredData || isExporting) return; + if (!filteredData || isExporting) {return;} setIsExporting(true); try { @@ -160,8 +160,8 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe 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'; + if (controls) {controls.style.display = 'none';} + if (background) {background.style.display = 'none';} const dataUrl = await toPng( document.querySelector('.react-flow__viewport') as HTMLElement, @@ -177,8 +177,8 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe } ); - if (controls) controls.style.display = ''; - if (background) background.style.display = ''; + if (controls) {controls.style.display = '';} + if (background) {background.style.display = '';} postMessage({ type: 'saveImage', From 61b1b541b29d42bec611c7cd292e511ddb19ffa7 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 07:19:01 +0000 Subject: [PATCH 08/11] fix(webview): remove min size constraints and fix UI update timing in export --- webview-ui/src/components/ReferenceGraph.tsx | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index 7b29009..ad44255 100644 --- a/webview-ui/src/components/ReferenceGraph.tsx +++ b/webview-ui/src/components/ReferenceGraph.tsx @@ -148,14 +148,19 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe if (!filteredData || isExporting) {return;} setIsExporting(true); + + // Allow browser to repaint (show "Exporting...") before heavy operation + await new Promise(resolve => requestAnimationFrame(resolve)); + try { const nodesList = getNodes(); const nodesBounds = getNodesBounds(nodesList); const padding = 50; - const width = Math.max(1024, nodesBounds.width + padding * 2); - const height = Math.max(768, nodesBounds.height + padding * 2); + // Use exact node bounds + padding (no minimum constraints) + const imageWidth = nodesBounds.width + padding * 2; + const imageHeight = nodesBounds.height + padding * 2; - const viewport = getViewportForBounds(nodesBounds, width, height, 0.5, 2, padding); + const viewport = getViewportForBounds(nodesBounds, imageWidth, imageHeight, 0.5, 2, padding); const controls = document.querySelector('.react-flow__controls') as HTMLElement; const background = document.querySelector('.react-flow__background') as HTMLElement; @@ -167,11 +172,11 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe document.querySelector('.react-flow__viewport') as HTMLElement, { backgroundColor: 'transparent', - width, - height, + width: imageWidth, + height: imageHeight, style: { - width: `${width}px`, - height: `${height}px`, + width: `${imageWidth}px`, + height: `${imageHeight}px`, transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, }, } From fe9bb3a7c9eeffacac33dbce40ad41080ac49a86 Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 08:01:54 +0000 Subject: [PATCH 09/11] fix(webview): use flushSync for immediate UI feedback on export --- webview-ui/src/components/ReferenceGraph.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index ad44255..f1c0b74 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, @@ -144,23 +145,20 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe } }, [focusedNodeId, getNode, setCenter]); - const handleExport = async () => { - if (!filteredData || isExporting) {return;} - - setIsExporting(true); - - // Allow browser to repaint (show "Exporting...") before heavy operation - await new Promise(resolve => requestAnimationFrame(resolve)); + const handleExport = async () => { + if (!filteredData || isExporting) {return;} + + flushSync(() => setIsExporting(true)); try { const nodesList = getNodes(); const nodesBounds = getNodesBounds(nodesList); - const padding = 50; + 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, padding); + 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; From 0136085e0b8fab814fa4398eafc37d91e7feb1ae Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 25 Jan 2026 08:11:16 +0000 Subject: [PATCH 10/11] fix(webview): add double RAF to ensure browser paint before export --- webview-ui/src/components/ReferenceGraph.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/webview-ui/src/components/ReferenceGraph.tsx b/webview-ui/src/components/ReferenceGraph.tsx index f1c0b74..3049383 100644 --- a/webview-ui/src/components/ReferenceGraph.tsx +++ b/webview-ui/src/components/ReferenceGraph.tsx @@ -146,11 +146,16 @@ function ReferenceGraphInner({ data, matchingNodeIds = [], focusedNodeId }: Refe }, [focusedNodeId, getNode, setCenter]); const handleExport = async () => { - if (!filteredData || isExporting) {return;} + if (!filteredData || isExporting) {return;} + + flushSync(() => setIsExporting(true)); + + // Yield to browser paint (double RAF ensures paint completes) + await new Promise(resolve => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ); - flushSync(() => setIsExporting(true)); - - try { + try { const nodesList = getNodes(); const nodesBounds = getNodesBounds(nodesList); const padding = 100; From e1f8ad02241cb7ee3c35a7580c4387f8a7c25547 Mon Sep 17 00:00:00 2001 From: yuichkun Date: Sun, 25 Jan 2026 17:25:05 +0900 Subject: [PATCH 11/11] docs: update README to include PNG export feature details --- README.md | 4 ++++ 1 file changed, 4 insertions(+) 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 |