flyde-vscode
Version:
Flyde is an open-source, visual programming language. It runs in the IDE, integrates with existing TypeScript code, both browser and Node.js.
510 lines (443 loc) • 16.7 kB
text/typescript
import path = require("path");
import * as vscode from "vscode";
import { getWebviewContent } from "./editor/open-flyde-panel";
var fp = require("find-free-port");
import { scanImportableNodes } from "@flyde/dev-server/dist/service/scan-importable-nodes";
import { generateAndSaveNode } from "@flyde/dev-server/dist/service/ai/generate-node-from-prompt";
import { getLibraryData } from "@flyde/dev-server/dist/service/get-library-data";
import {
deserializeFlow,
resolveFlow,
resolveFlowByPath,
serializeFlow,
} from "@flyde/resolver";
import {
FlydeFlow,
MAJOR_DEBUGGER_EVENT_TYPES,
formatEvent,
keys,
} from "@flyde/core";
import { findPackageRoot } from "./find-package-root";
import { randomInt } from "crypto";
import { reportEvent, reportException } from "./telemetry";
import { Uri } from "vscode";
import { FlowJob } from "@flyde/dev-server";
import { forkRunFlow } from "@flyde/dev-server/dist/runner/runFlow.host";
import { createEditorClient } from "@flyde/remote-debugger";
import { maybeAskToStarProject } from "./maybeAskToStarProject";
// export type EditorPortType = keyof any;
type Awaited<T> = T extends PromiseLike<infer U> ? U : T;
type EmitterFn = (...params: any) => Promise<any>;
type ListenerFn = (cb: (...params: any) => Promise<any>) => void;
type PortFn = EmitterFn | ListenerFn;
type PortConfig<T extends PortFn> = {
request: Parameters<T>;
response: ReturnType<Awaited<T>>;
};
// type PostMsgConfig = {
// [Property in keyof any]: PortConfig<EditorPorts[Property]>;
// };
type FlydePortMessage<T extends any> = {
type: T;
requestId: string;
params: any; // PostMsgConfig[T]['params']
};
const tryOrThrow = (fn: Function, msg: string) => {
try {
return fn();
} catch (e) {
console.error(`Error editor error: ${msg}. Full error:`, e);
return new Error(`Flyde editor error: ${msg}: ${e}`);
}
};
let runningJobs = <{ [webviewId: string]: FlowJob }>{};
let lastWebview: any = null;
export const getLastWebviewForTests = () => {
return lastWebview;
};
export interface FlydeEditorProviderParams {
port: number;
mainOutputChannel: vscode.OutputChannel;
debugOutputChannel: vscode.OutputChannel;
darkMode: boolean;
}
export class FlydeEditorEditorProvider
implements vscode.CustomTextEditorProvider
{
params!: FlydeEditorProviderParams;
public static register(
context: vscode.ExtensionContext,
params: FlydeEditorProviderParams
): vscode.Disposable {
const provider = new FlydeEditorEditorProvider(context);
provider.params = params;
const providerRegistration = vscode.window.registerCustomEditorProvider(
FlydeEditorEditorProvider.viewType,
provider
);
return providerRegistration;
}
private static readonly viewType = "flydeEditor";
constructor(private readonly context: vscode.ExtensionContext) {}
public async resolveCustomTextEditor(
document: vscode.TextDocument,
webviewPanel: vscode.WebviewPanel,
_token: vscode.CancellationToken
): Promise<void> {
// Setup initial content for the webview
webviewPanel.webview.options = {
enableScripts: true,
portMapping: [
{
webviewPort: 3000,
extensionHostPort: 3000,
},
],
};
const firstWorkspace =
vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0];
const fileRoot = firstWorkspace ? firstWorkspace.uri.fsPath : "";
const relative = path.relative(fileRoot, document.fileName);
let lastSaveBy = "";
let lastFlow: FlydeFlow;
const messageResponse = (event: FlydePortMessage<any>, payload: any) => {
console.info(
"Responding to message from webview",
event.type,
event.requestId,
payload
);
webviewPanel.webview.postMessage({
type: event.type,
requestId: event.requestId,
payload,
source: "extension",
});
};
const webviewId = `wv-${(Date.now() + randomInt(999)).toString(32)}`;
const fullDocumentPath = document.uri.fsPath;
const executionId = fullDocumentPath;
const renderWebview = async () => {
const raw = document.getText();
const fileName = (
document.uri.fsPath.split(path.sep).pop() ?? "Default"
).replace(".flyde", "");
const initialFlow = tryOrThrow(() => {
return raw.trim() !== ""
? deserializeFlow(raw, fullDocumentPath)
: {
node: {
id: fileName,
inputs: {},
inputsPosition: {},
outputs: {},
outputsPosition: {},
instances: [],
connections: [],
},
imports: {},
};
}, "Failed to deserialize flow");
const dependencies = tryOrThrow(
() =>
resolveFlow(initialFlow, "definition", fullDocumentPath).dependencies,
"Failed to resolve flow's dependencies"
);
const errors = [initialFlow, dependencies]
.filter((obj) => obj instanceof Error)
.map((err: Error) => err.message);
if (errors.length) {
webviewPanel.webview.html = `Errors: <code>${errors.join(
"\n"
)}</code><hr/>Try opening it with a text editor and fix the problem`;
return initialFlow as any;
}
// used to avoid triggering "onChange" of the same webview
webviewPanel.webview.html = await getWebviewContent({
extensionUri: this.context.extensionUri,
relativeFile: relative,
port: this.params.port,
webview: webviewPanel.webview,
initialFlow,
dependencies,
webviewId,
executionId,
darkMode: this.params.darkMode,
});
lastFlow = initialFlow;
lastWebview = webviewPanel.webview;
};
await renderWebview();
reportEvent("renderedWebview", { webviewId });
vscode.commands.executeCommand("setContext", "flyde.flowLoaded", true);
const _debugger = createEditorClient(
`http://localhost:${this.params.port}`,
executionId
);
_debugger.onBatchedEvents((events) => {
const { mainOutputChannel, debugOutputChannel } = this.params;
events.forEach((event) => {
debugOutputChannel.appendLine(formatEvent(event));
if (!event.ancestorsInsIds) {
if (MAJOR_DEBUGGER_EVENT_TYPES.includes(event.type)) {
mainOutputChannel.appendLine(formatEvent(event));
}
}
});
});
let didFocusOutput = false;
webviewPanel.webview.onDidReceiveMessage(
async (event: FlydePortMessage<any>) => {
if (event.type && event.requestId) {
console.info(
"Received message from webview",
event.type,
event.requestId
);
try {
switch (event.type) {
case "prompt": {
const { defaultValue, text } = event.params;
const value = await vscode.window.showInputBox({
value: defaultValue,
prompt: text,
});
messageResponse(event, value);
break;
}
case "confirm": {
const { text } = event.params;
const res = await vscode.window.showInformationMessage(
text,
"Yes",
"No"
);
messageResponse(event, res === "Yes");
break;
}
case "openFile": {
const { absPath } = event.params;
const uri = vscode.Uri.parse(absPath);
const isFlydeFlow = absPath.endsWith(".flyde");
if (isFlydeFlow) {
const res = await vscode.commands.executeCommand(
"vscode.openWith",
uri,
"flydeEditor"
);
messageResponse(event, res);
} else {
const activeColumn =
vscode.window.activeTextEditor?.viewColumn; // without passing the active column it seems to override the tab with the selection
const res = await vscode.commands.executeCommand(
"vscode.open",
uri,
activeColumn
);
messageResponse(event, res);
}
break;
}
case "readFlow": {
const raw = document.getText();
const flow = deserializeFlow(raw, fullDocumentPath);
messageResponse(event, flow);
break;
}
case "resolveDeps": {
const { flow: dtoFlow } = event.params;
if (dtoFlow) {
const deps = resolveFlow(
dtoFlow,
"definition",
fullDocumentPath
).dependencies;
messageResponse(event, deps);
} else {
const flow = resolveFlowByPath(
fullDocumentPath,
"definition"
);
messageResponse(event, flow);
}
break;
}
case "generateNodeFromPrompt": {
const config = vscode.workspace.getConfiguration("flyde");
let openAiToken = config.get<string>("openAiToken");
if (!openAiToken) {
await vscode.commands.executeCommand("flyde.setOpenAiToken");
openAiToken = config.get<string>("openAiToken");
}
if (!openAiToken) {
throw new Error("OpenAI token is required");
}
const { prompt } = event.params;
if (prompt.trim().length === 0) {
throw new Error("prompt is empty");
}
const targetPath = Uri.joinPath(document.uri, "..");
const importableNode = await generateAndSaveNode(
targetPath.fsPath,
prompt,
openAiToken
);
messageResponse(event, { importableNode });
break;
}
case "setFlow": {
const { flow } = event.params;
const serialized = serializeFlow(flow);
lastFlow = flow;
const edit = new vscode.WorkspaceEdit();
// replacing everything for simplicity. TODO - pass only delta changes
const range = new vscode.Range(0, 0, document.lineCount, 0);
edit.replace(document.uri, range, serialized);
lastSaveBy = webviewId;
await vscode.workspace.applyEdit(edit);
messageResponse(event, undefined);
break;
}
case "getImportables": {
const maybePackageRoot = await findPackageRoot(document.uri);
const root =
maybePackageRoot ?? Uri.joinPath(document.uri, "..");
const deps = await scanImportableNodes(
root.fsPath,
path.relative(root.fsPath, fullDocumentPath)
);
messageResponse(event, deps);
break;
}
case "onInstallRuntimeRequest": {
// show vscode selection dialog between "use yarn" and "use npm"
const res = await vscode.window.showQuickPick(
["Use Yarn", "Use NPM"],
{
placeHolder: "How do you want to install the runtime?",
}
);
const command =
res === "Use Yarn"
? "yarn add @flyde/runtime"
: "npm install @flyde/runtime";
// notify user
vscode.window.showInformationMessage(
`Running \`${command}\` from the integrated terminal. This may take a while. You can follow the progress in the terminal`
);
// create a terminal
const terminal = vscode.window.createTerminal({
name: "Flyde Runtime Installer",
});
// run the command
await terminal.show();
await terminal.sendText(command);
break;
}
case "onRunFlow": {
reportEvent("runFlow:before", {
inputsCount: `${keys(event.params.inputs).length}`,
});
const job = await forkRunFlow({
runFlowParams: [
lastFlow,
fullDocumentPath,
event.params.inputs,
this.params.port,
event.params.executionDelay,
],
cwd: vscode.workspace.workspaceFolders?.[0].uri.fsPath,
});
reportEvent("runFlow:after");
setTimeout(() => {
maybeAskToStarProject(5000);
}, 10000);
vscode.commands.executeCommand(
"setContext",
"flyde.ranFlow",
true
);
if (!didFocusOutput) {
didFocusOutput = true;
this.params.mainOutputChannel?.show();
this.params.mainOutputChannel?.appendLine(
`Running flow. Events will appear here. You can also hover the inputs and output pins to view data.`
);
}
return job;
}
case "hasOpenAiToken": {
const config = vscode.workspace.getConfiguration("flyde");
const openAiToken = config.get<string>("openAiToken");
messageResponse(event, !!openAiToken);
}
case "reportEvent": {
const { name, data } = event.params;
reportEvent(`flowEditor:${name}`, { ...data, webviewId });
break;
}
case "getLibraryData": {
const libraryData = getLibraryData();
messageResponse(event, libraryData);
break;
}
default: {
reportEvent("onDidReceiveMessage: unknown message", {
type: event.type,
webviewId: webviewId,
});
vscode.window.showErrorMessage(
`Handling of ${event.type} is not implemented yet`
);
break;
}
}
} catch (err: unknown) {
const error =
err instanceof Error ? err : new Error(`Unknown error: ${err}`);
console.error(`Error while handling message from webview`, error);
reportException(error, {
source: "webviewPanel.webview.onDidReceiveMessage",
});
vscode.window.showErrorMessage(
`Unexpected error while handling message from webview: ${error.message}`
);
}
}
}
);
webviewPanel.onDidChangeViewState(async (e) => {
// when the webview is refocused, it needs to receive a new html to contain the correct initial flow
if (e.webviewPanel.active) {
await renderWebview();
}
});
const changeDocumentSubscription = vscode.workspace.onDidChangeTextDocument(
(e) => {
const isSameUri = e.document.uri.toString() === document.uri.toString();
if (isSameUri && lastSaveBy !== webviewId) {
const raw = document.getText();
const flow: FlydeFlow = deserializeFlow(raw, fullDocumentPath);
const deps = resolveFlow(
flow,
"definition",
fullDocumentPath
).dependencies;
webviewPanel.webview.postMessage({
type: "onExternalFlowChange",
requestId: "TODO-cuid",
params: { flow, deps },
source: "extension",
});
}
}
);
// Make sure we get rid of the listener when our editor is closed.
webviewPanel.onDidDispose(() => {
changeDocumentSubscription.dispose();
});
}
/**
* Get the static html used for the editor webviews.
*/
}