UNPKG

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
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. */ }