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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ dist
.vscode-test
.vscode-test-web
*.vsix
test-workspace
80 changes: 47 additions & 33 deletions src/flowr/diagrams/diagram-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ import * as vscode from 'vscode';
import type { DiagramOptions, DiagramOptionsCheckbox, DiagramOptionsDropdown } from './diagram-definitions';

export interface DiagramGeneratorData {
mermaid: string;
options: DiagramOptions;
documentationUrl: string;
editorUrl: string;
id: string;
name: string;
}
Expand All @@ -18,7 +16,7 @@ const Checkbox = {
},
js: (option: DiagramOptionsCheckbox) => {
return `document.getElementById('${option.keyInSet ?? option.key}').addEventListener('change', (e) => {
vscode.postMessage({ key: '${option.key}', value: event.currentTarget.checked, keyInSet: ${option.keyInSet ? `'${option.keyInSet}'` : undefined} });
vscode.postMessage({ type: 'settings', key: '${option.key}', value: event.currentTarget.checked, keyInSet: ${option.keyInSet ? `'${option.keyInSet}'` : undefined} });
});`;
}
};
Expand All @@ -31,7 +29,7 @@ const Dropdown = {
js: (option: DiagramOptionsDropdown) => {
return `const input = document.getElementById('${option.key}');
input.addEventListener('change', (e) => {
vscode.postMessage({ key: '${option.key}', value: input.options[e.currentTarget.selectedIndex].value});
vscode.postMessage({ type: 'settings', key: '${option.key}', value: input.options[e.currentTarget.selectedIndex].value});
});`;
}
};
Expand All @@ -56,7 +54,7 @@ function generateOptionsJS(options: DiagramOptions): string {
}).join('\n');
}

function createDiagramDocument({ mermaid, options, documentationUrl, editorUrl }: DiagramGeneratorData): string {
function createDiagramDocument({ options, documentationUrl }: DiagramGeneratorData): string {
const theme = vscode.window.activeColorTheme.kind == vscode.ColorThemeKind.Light ? 'default' : 'dark';
// Use 'leet-html' extension for VS Code to get intellisense for the following string:
return `
Expand All @@ -66,7 +64,7 @@ function createDiagramDocument({ mermaid, options, documentationUrl, editorUrl }
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.6.1/dist/svg-pan-zoom.min.js"></script>
<script>
const mermaidConfig = {
Expand Down Expand Up @@ -131,12 +129,10 @@ function createDiagramDocument({ mermaid, options, documentationUrl, editorUrl }
<div class="container">
<div class="header">
<a href="${documentationUrl}">Documentation</a>
<a href="${editorUrl}">Open in Mermaid</a>
<a href="" id="a-open">Open in Mermaid</a>
</div>

<div class="mermaid" id="diagram">
${mermaid}
</div>
<div class="mermaid" id="diagram"></div>

<div class="footer">
${generateOptionsHTML(options)}
Expand All @@ -145,30 +141,57 @@ function createDiagramDocument({ mermaid, options, documentationUrl, editorUrl }


<script>
/* The vscode object is needed by the code returned by generateOptions */
const vscode = acquireVsCodeApi();

/* Mermaid Rendering */
let panZoom;
mermaid.run().then(() => {
panZoom = svgPanZoom('.mermaid svg', { controlIconsEnabled: true })
addEventListener("resize", () => panZoom.resize())
});
const diagramContainer = document.getElementById('diagram');
const openEditorLink = document.getElementById('a-open');

let panZoom;
let pan = { x: 0, y: 0 };
let zoom = 1;
window.addEventListener("resize", () => panZoom.resize());

/* Communication with extension */
window.addEventListener('message', async event => {
const msg = event.data;
switch(msg.type) {
case 'content_update':
const el = document.getElementById('diagram');
const { svg, bindFunctions } = await mermaid.render('flowr-diagram', msg.content);
el.innerHTML = svg;
bindFunctions?.(el);
panZoom = svgPanZoom('.mermaid svg', { controlIconsEnabled: true })
openEditorLink.href = msg.editorUrl;

try {
const { svg, bindFunctions } = await mermaid.render('flowr-diagram', msg.content);
diagramContainer.innerHTML = svg;
bindFunctions?.(diagramContainer);

if(panZoom) {
pan = panZoom.getPan();
zoom = panZoom.getZoom();
panZoom.destroy();
}

panZoom = svgPanZoom('.mermaid svg', {
controlIconsEnabled: true,
minZoom: Number.MIN_SAFE_INTEGER,
maxZoom: Number.MAX_SAFE_INTEGER
});
panZoom.resize();
panZoom.zoom(zoom);
panZoom.pan(pan);

vscode.postMessage({ type: 'diagram_generated' });
} catch(e) {
const error = JSON.stringify(e, null, 2);
diagramContainer.innerHTML = '<p>Failed to generate diagram using mermaid.</p><span>Mermaid Error:</span><pre>' + error + '</pre>';
vscode.postMessage({ type: 'error', message: error });
}
break;
}
});

${generateOptionsJS(options)}

/* Tell the extension that we are ready to recieve the first content_update message */
vscode.postMessage({ type: 'ready' });
</script>
</body>
</html>`;
Expand All @@ -181,17 +204,8 @@ function mermaidMaxTextLength() {
/**
*
*/
export function createDiagramWebview(data: DiagramGeneratorData, output: vscode.OutputChannel): vscode.WebviewPanel | undefined {
// https://github.com/mermaid-js/mermaid/blob/47601ac311f7ad7aedfaf280d319d75434680622/packages/mermaid/src/mermaidAPI.ts#L315-L317
if(data.mermaid.length > mermaidMaxTextLength()){
void vscode.window.showErrorMessage('The diagram is too large to be displayed by Mermaid. You can find its code in the flowR output panel instead. Additionally, you can change the maximum diagram length in the extension settings.');
output.appendLine(data.mermaid);
return undefined;
}

const panel = vscode.window.createWebviewPanel(data.id, data.name, vscode.ViewColumn.Beside, {
enableScripts: true
});
export function createDiagramWebview(data: DiagramGeneratorData): vscode.WebviewPanel | undefined {
const panel = vscode.window.createWebviewPanel(data.id, data.name, vscode.ViewColumn.Beside, { enableScripts: true });
panel.webview.html = createDiagramDocument(data);
return panel;
}
Expand Down
102 changes: 72 additions & 30 deletions src/flowr/diagrams/diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export function registerDiagramCommands(context: vscode.ExtensionContext, output

for(const type in DiagramDefinitions) {
const definition = DiagramDefinitions[type as FlowrDiagramType];
registerCommand(context, definition.command, async() => {
registerCommand(context, definition.command, (callbacks?: WebviewCallbacks) => {
const activeEditor = vscode.window.activeTextEditor;
return await coordinator.createDiagramPanel(type as FlowrDiagramType, activeEditor);
return coordinator.createDiagramPanel(type as FlowrDiagramType, activeEditor, callbacks);
});
}
}
Expand All @@ -34,24 +34,60 @@ interface DiagramPanelInformation {
options: DiagramOptions;
}

/**
* Sent by the extension to the webview when there is new mermaid code to show
*/
interface ContentUpdateMessage {
type: 'content_update',
content: string
type: 'content_update';
content: string;
editorUrl: string;
}

/**
* Sent when the webview is ready to recieve mermaid code
*/
interface WebviewReadyMessage {
type: 'ready';
}

interface WebviewMessage {
/**
* Sent by the webview when an error occured during diagram conversion using mermaid
*/
interface WebviewErrorMessage {
type: 'error';
message: string;
}

/**
* Sent by the webview when a mermaid diagram
* was successfully genereted and converted into svg
*/
interface WebviewDiagramGeneratedMessage {
type: 'diagram_generated';
}

/**
* Sent by the Webview when settings were changed by the user.
* Settings include the checkboxes and dropdowns in the webview pane
*/
interface WebviewSettingsMessage {
type: 'settings'
key: string
/** @see DiagramOptionsCheckbox.keyInSet */
keyInSet?: string
value: unknown
}

type WebviewMessage = WebviewReadyMessage | WebviewSettingsMessage | WebviewErrorMessage | WebviewDiagramGeneratedMessage;

export type WebviewCallbacks = { onError: (message: string) => void, onGenerated: () => void };


/**
* Manages Webview Panels created through flowr commands (like Show Dataflow Graph)
* This also routes updates to the correct panel when the text selection updates in a panel
*/
class DiagramUpdateCoordinator {
export class DiagramUpdateCoordinator {
private documentToDiagramPanel: Map<vscode.TextDocument, Set<DiagramPanelInformation>>;
private output: vscode.OutputChannel;
private debounceTimeout: NodeJS.Timeout | undefined;
Expand All @@ -62,29 +98,19 @@ class DiagramUpdateCoordinator {
this.output = output;
}

public async createDiagramPanel(type: FlowrDiagramType, editor: vscode.TextEditor | undefined) {
public createDiagramPanel(type: FlowrDiagramType, editor: vscode.TextEditor | undefined, callbacks?: WebviewCallbacks) {
if(!editor) {
return;
}

const definition = DiagramDefinitions[type];
const options = optionsFromDiagramType(type);
const mermaid = await definition.retrieve(options as never, editor);

// Don't show a panel if generation failed
if(mermaid === '') {
await vscode.window.showErrorMessage('Failed to generate diagram - FlowR Analyzer Session is not ready. Check if flowrR is connected and try again.');
return;
}

const panel = createDiagramWebview({
mermaid: mermaid,
options: options,
documentationUrl: definition.documentationUrl,
editorUrl: Mermaid.codeToUrl(mermaid, true),
id: type as string,
name: `${definition.title} (${path.basename(editor.document.fileName)})`
}, this.output);
});

if(!panel) {
return undefined;
Expand All @@ -97,8 +123,18 @@ class DiagramUpdateCoordinator {
this.documentToDiagramPanel.get(editor.document)?.delete(info);
});

// Handle settings update messages from panel
panel.webview.onDidReceiveMessage((msg: WebviewMessage) => {
// Add panel to map for tracking selection updates
if(!this.documentToDiagramPanel.has(editor.document)) {
this.documentToDiagramPanel.set(editor.document, new Set<DiagramPanelInformation>());
}

this.documentToDiagramPanel.get(editor.document)?.add(info);

const onReady = () => {
void this.updateWebviewPanel(info, editor);
};

const onSettingsChanged = (msg: WebviewSettingsMessage) => {
const key = `${DiagramSettingsPrefix}.${msg.key}`;
if(msg.keyInSet) { // If setKey is set, the checkboxes are grouped into an array
const current = new Set(getConfig().get<string[]>(key, []));
Expand All @@ -115,17 +151,22 @@ class DiagramUpdateCoordinator {
}

void this.updateWebviewPanel(info, editor);
});

// Add panel to map for tracking selection updates
if(!this.documentToDiagramPanel.has(editor.document)) {
this.documentToDiagramPanel.set(editor.document, new Set<DiagramPanelInformation>());
}
};

this.documentToDiagramPanel.get(editor.document)?.add(info);
// Handle messages from panel
panel.webview.onDidReceiveMessage((msg: WebviewMessage) => {
switch(msg.type) {
case 'ready': onReady(); break;
case 'settings': onSettingsChanged(msg); break;
case 'error':
this.output.appendLine(`[Diagram]: Failed to convert mermaid code into svg: ${msg.message}`);
callbacks?.onError(msg.message);
break;
case 'diagram_generated': callbacks?.onGenerated(); break;
}
});

return {
mermaid,
webview: panel
};
}
Expand Down Expand Up @@ -155,8 +196,9 @@ class DiagramUpdateCoordinator {
public async updateWebviewPanel(info: DiagramPanelInformation, textEditor: vscode.TextEditor) {
const mermaid = await DiagramDefinitions[info.type].retrieve(info.options as never, textEditor);
info.panel.webview.postMessage({
type: 'content_update',
content: mermaid
type: 'content_update',
content: mermaid,
editorUrl: Mermaid.codeToUrl(mermaid, true)
} satisfies ContentUpdateMessage);
}
}
Expand Down
40 changes: 33 additions & 7 deletions src/test/diagram.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,44 @@
import * as vscode from 'vscode';
import * as assert from 'assert';import { activateExtension, openTestFile } from './test-util';
import { activateExtension, openTestFile } from './test-util';
import assert from 'assert';
import type { WebviewCallbacks } from '../flowr/diagrams/diagram';


async function testDiagramGeneration(command: string) {
await openTestFile('simple-example.R');
const result = await new Promise<{ error: boolean }>((resolve) => {
vscode.commands.executeCommand(command, {
onError(_message) {
resolve({ error: true });
},
onGenerated() {
resolve({ error: false });
}
} as WebviewCallbacks);
});

assert.equal(result.error, false);
}

suite('diagram', () => {
suiteSetup(async() => {
await activateExtension();
});

test('dataflow', async() => {
await openTestFile('simple-example.R');
const result: { webview: vscode.WebviewPanel, mermaid: string } | undefined =
await vscode.commands.executeCommand('vscode-flowr.dataflow');
assert.ok(result);
assert.equal(result.webview.title, 'Dataflow Graph (simple-example.R)');
assert.ok(result.mermaid.startsWith('flowchart'));
await testDiagramGeneration('vscode-flowr.dataflow');
});

test('ast', async() => {
await testDiagramGeneration('vscode-flowr.ast');
});

test('cfg', async() => {
await testDiagramGeneration('vscode-flowr.cfg');
});

test('call graph', async() => {
await testDiagramGeneration('vscode-flowr.call-graph');
});
});

Loading