From 0c6760d22724fe56564a204f0324dd345a5ef891 Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" <t.yic.yt@gmail.com> Date: Wed, 5 Jun 2024 16:07:20 +0100 Subject: [PATCH] Auto-install the imported packages (#902) * Add autoInstall option to the kernel * Run pyodide.loadPackagesFromImports() at init and file writing * Send the auto-installed packages to the editor and update the requirements tab content * Update the auto install event emits a promise to resolve the installed package list * Update to call loadPackage() only when needed * Improve the toast notification * Fix * Fix * Update the editor content * Fix * Implement module auto-load at each run and split the autoInstall option into moduleAutoLoadOnRun and moduleAutoLoadOnSave * Revert "Implement module auto-load at each run and split the autoInstall option into moduleAutoLoadOnRun and moduleAutoLoadOnSave" This reverts commit 61cfd1c72c58be4a0db8d4aaa7b5197696b05d89. * Rename auto-install to module-auto-load * Update findImports impl * Fix to await the auto-load promise in the script runner * Fix * Rename messages * Fix * Apply formatter * Add comment * Refactoring * Refactoring * Fix inter-window messaging * Fix * Fix * Fix <Editor /> to handle addRequirements() in an imperative way --- .../stlite-kernel-with-toast.tsx | 74 ++- packages/desktop/electron/worker.ts | 9 +- packages/kernel/src/kernel.ts | 46 +- packages/kernel/src/module-auto-load.ts | 71 +++ packages/kernel/src/types.ts | 27 +- packages/kernel/src/worker-runtime.ts | 50 +- packages/kernel/src/worker.ts | 3 +- packages/sharing-common/src/messages.ts | 24 +- packages/sharing-editor/src/App.tsx | 44 +- .../src/Editor/components/Tab.module.scss | 2 +- packages/sharing-editor/src/Editor/index.tsx | 456 ++++++++++-------- .../src/StliteSharingIFrame/index.tsx | 24 +- packages/sharing/src/App.tsx | 31 +- streamlit | 2 +- 14 files changed, 607 insertions(+), 256 deletions(-) create mode 100644 packages/kernel/src/module-auto-load.ts diff --git a/packages/common-react/src/toastify-components/stlite-kernel-with-toast.tsx b/packages/common-react/src/toastify-components/stlite-kernel-with-toast.tsx index 7293fdf29..0892c3f7a 100644 --- a/packages/common-react/src/toastify-components/stlite-kernel-with-toast.tsx +++ b/packages/common-react/src/toastify-components/stlite-kernel-with-toast.tsx @@ -1,34 +1,36 @@ import React from "react"; -import { StliteKernel } from "@stlite/kernel"; -import { toast } from "react-toastify"; +import type { StliteKernel, StliteKernelOptions } from "@stlite/kernel"; +import { toast, ToastPromiseParams } from "react-toastify"; import ErrorToastContent from "./ErrorToastContent"; -type ToastPromiseParams<TData> = Parameters<typeof toast.promise<TData>>; -type ToastPromiseMessages<TData> = Partial< - Record<keyof ToastPromiseParams<TData>[1], string> ->; -type ToastPromiseReturnType = ReturnType<typeof toast.promise>; -function stliteStyledPromiseToast<TData>( - promise: ToastPromiseParams<TData>[0], - messages: ToastPromiseMessages<TData> -): ToastPromiseReturnType { +function stliteStyledPromiseToast< + TData = unknown, + TError extends Error | undefined = undefined, + TPending = unknown +>( + promise: Promise<TData>, + messages: ToastPromiseParams<TData, TError, TPending> +): ReturnType<typeof toast.promise> { const errorMessage = messages.error; - return toast.promise<TData, Error>( + return toast.promise<TData, TError, TPending>( promise, { pending: messages.pending, success: messages.success, - error: errorMessage && { - render({ data }) { - return data ? ( - <ErrorToastContent message={errorMessage} error={data} /> - ) : ( - messages.error - ); - }, - autoClose: false, - closeOnClick: false, - }, + error: + typeof errorMessage === "string" + ? { + render({ data }) { + return data ? ( + <ErrorToastContent message={errorMessage} error={data} /> + ) : ( + <>messages.error</> + ); + }, + autoClose: false, + closeOnClick: false, + } + : errorMessage, }, { hideProgressBar: true, @@ -37,8 +39,32 @@ function stliteStyledPromiseToast<TData>( ); } +export interface StliteKernelWithToastOptions { + onModuleAutoLoad?: StliteKernelOptions["onModuleAutoLoad"]; +} export class StliteKernelWithToast { - constructor(private kernel: StliteKernel) {} + constructor( + private kernel: StliteKernel, + options?: StliteKernelWithToastOptions + ) { + kernel.onModuleAutoLoad = (packagesToLoad, installPromise) => { + if (options?.onModuleAutoLoad) { + options.onModuleAutoLoad(packagesToLoad, installPromise); + } + + stliteStyledPromiseToast(installPromise, { + success: { + render({ data }) { + return `Auto-loaded${ + data ? ": " + data.map((pkg) => pkg.name).join(", ") : " packages" + }`; + }, + }, + error: "Failed to auto-load packages", + pending: "Auto-loading packages", + }); + }; + } public writeFile(...args: Parameters<StliteKernel["writeFile"]>) { return stliteStyledPromiseToast<void>(this.kernel.writeFile(...args), { diff --git a/packages/desktop/electron/worker.ts b/packages/desktop/electron/worker.ts index 6ab704cfc..1b9e12482 100644 --- a/packages/desktop/electron/worker.ts +++ b/packages/desktop/electron/worker.ts @@ -1,11 +1,14 @@ import { parentPort } from "node:worker_threads"; -import { startWorkerEnv } from "@stlite/kernel/src/worker-runtime"; +import { + startWorkerEnv, + type PostMessageFn, +} from "@stlite/kernel/src/worker-runtime"; import { loadNodefsMountpoints } from "./worker-options"; -function postMessage(value: any) { +const postMessage: PostMessageFn = (value) => { console.debug("[worker thread] postMessage from worker", value); parentPort?.postMessage(value); -} +}; const handleMessage = startWorkerEnv( process.env.PYODIDE_URL as string, diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 62d230408..0d840cea8 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -1,5 +1,6 @@ // Ref: https://github.com/jupyterlite/jupyterlite/blob/f2ecc9cf7189cb19722bec2f0fc7ff5dfd233d47/packages/pyolite-kernel/src/kernel.ts +import type { PackageData } from "pyodide"; import { PromiseDelegate } from "@stlite/common"; import type { IHostConfigResponse } from "@streamlit/lib/src/hostComm/types"; @@ -21,6 +22,7 @@ import type { StliteWorker, WorkerInitialData, StreamlitConfig, + ModuleAutoLoadMessage, } from "./types"; import { assertStreamlitConfig } from "./types"; @@ -117,6 +119,13 @@ export interface StliteKernelOptions { idbfsMountpoints?: WorkerInitialData["idbfsMountpoints"]; + moduleAutoLoad?: WorkerInitialData["moduleAutoLoad"]; + + onModuleAutoLoad?: ( + packagesToLoad: string[], + installPromise: Promise<PackageData[]> + ) => void; + onProgress?: (message: string) => void; onLoad?: () => void; @@ -143,11 +152,10 @@ export class StliteKernel { public readonly hostConfigResponse: IHostConfigResponse; // Will be passed to ConnectionManager to call `onHostConfigResp` from it. - private onProgress: StliteKernelOptions["onProgress"]; - - private onLoad: StliteKernelOptions["onLoad"]; - - private onError: StliteKernelOptions["onError"]; + public onProgress: StliteKernelOptions["onProgress"]; + public onLoad: StliteKernelOptions["onLoad"]; + public onError: StliteKernelOptions["onError"]; + public onModuleAutoLoad: StliteKernelOptions["onModuleAutoLoad"]; constructor(options: StliteKernelOptions) { this.basePath = (options.basePath ?? window.location.pathname) @@ -157,6 +165,7 @@ export class StliteKernel { this.onProgress = options.onProgress; this.onLoad = options.onLoad; this.onError = options.onError; + this.onModuleAutoLoad = options.onModuleAutoLoad; if (options.worker) { this._worker = options.worker; @@ -168,7 +177,8 @@ export class StliteKernel { } this._worker.onmessage = (e) => { - this._processWorkerMessage(e.data); + const messagePort: MessagePort | undefined = e.ports[0]; + this._processWorkerMessage(e.data, messagePort); }; let wheels: WorkerInitialData["wheels"] = undefined; @@ -209,6 +219,7 @@ export class StliteKernel { options.mountedSitePackagesSnapshotFilePath, streamlitConfig: options.streamlitConfig, idbfsMountpoints: options.idbfsMountpoints, + moduleAutoLoad: options.moduleAutoLoad ?? false, }; } @@ -337,7 +348,7 @@ export class StliteKernel { * * @param msg The worker message to process. */ - private _processWorkerMessage(msg: OutMessage): void { + private _processWorkerMessage(msg: OutMessage, port?: MessagePort): void { switch (msg.type) { case "event:start": { this._worker.postMessage({ @@ -364,6 +375,27 @@ export class StliteKernel { this.handleWebSocketMessage && this.handleWebSocketMessage(payload); break; } + case "event:moduleAutoLoad": { + if (port == null) { + throw new Error("Port is required for moduleAutoLoad event"); + } + this.onModuleAutoLoad && + this.onModuleAutoLoad( + msg.data.packagesToLoad, + new Promise((resolve, reject) => { + port.onmessage = (e) => { + const msg: ModuleAutoLoadMessage = e.data; + if (msg.type === "moduleAutoLoad:success") { + resolve(msg.data.loadedPackages); + } else { + reject(msg.error); + } + port.close(); + }; + }) + ); + break; + } } } diff --git a/packages/kernel/src/module-auto-load.ts b/packages/kernel/src/module-auto-load.ts new file mode 100644 index 000000000..69f1f04be --- /dev/null +++ b/packages/kernel/src/module-auto-load.ts @@ -0,0 +1,71 @@ +import type { PackageData, PyodideInterface } from "pyodide"; +import type { ModuleAutoLoadMessage } from "./types"; +import type { PostMessageFn } from "./worker-runtime"; + +function findImports(pyodide: PyodideInterface, source: string): string[] { + return pyodide.pyodide_py.ffi._pyodide._base + .find_imports(source) + .toJs() as string[]; +} + +export async function tryModuleAutoLoad( + pyodide: PyodideInterface, + postMessage: PostMessageFn, + sources: string[] +): Promise<void> { + // Ref: `pyodide.loadPackagesFromImports` (https://github.com/pyodide/pyodide/blob/0.26.0/src/js/api.ts#L191) + + const importsArr = sources.map((source) => findImports(pyodide, source)); + const imports = Array.from(new Set(importsArr.flat())); + + const notFoundImports = imports.filter( + (name) => + !pyodide.runPython(`__import__('importlib').util.find_spec('${name}')`) + ); + + const packagesToLoad = notFoundImports + .map((name) => + ( + pyodide as unknown as { + _api: { _import_name_to_package_name: Map<string, string> }; + } + )._api._import_name_to_package_name.get(name) + ) + .filter((name) => name) as string[]; + + if (packagesToLoad.length === 0) { + return; + } + + const channel = new MessageChannel(); + + postMessage( + { + type: "event:moduleAutoLoad", + data: { + packagesToLoad, + }, + }, + channel.port2 + ); + + try { + const loadedPackages = await pyodide.loadPackage(packagesToLoad); + + channel.port1.postMessage({ + type: "moduleAutoLoad:success", + data: { + loadedPackages, + }, + } as ModuleAutoLoadMessage); + channel.port1.close(); + return; + } catch (error) { + channel.port1.postMessage({ + type: "moduleAutoLoad:error", + error: error as Error, + } as ModuleAutoLoadMessage); + channel.port1.close(); + throw error; + } +} diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index 35179673c..31434bfbb 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -1,4 +1,4 @@ -import type { PyodideInterface } from "pyodide"; +import type { PyodideInterface, PackageData } from "pyodide"; export type PyodideConvertiblePrimitive = | string @@ -57,6 +57,7 @@ export interface WorkerInitialData { streamlitConfig?: StreamlitConfig; idbfsMountpoints?: string[]; nodefsMountpoints?: Record<string, string>; + moduleAutoLoad: boolean; } /** @@ -161,12 +162,34 @@ export interface OutMessageWebSocketBack extends OutMessageBase { payload: Uint8Array | string; }; } +export interface OutMessageModuleAutoLoadEvent extends OutMessageBase { + type: "event:moduleAutoLoad"; + data: { + packagesToLoad: string[]; + }; +} export type OutMessage = | OutMessageStartEvent | OutMessageProgressEvent | OutMessageErrorEvent | OutMessageLoadedEvent - | OutMessageWebSocketBack; + | OutMessageWebSocketBack + | OutMessageModuleAutoLoadEvent; + +export interface ModuleAutoLoadMessageBase { + type: string; +} +export interface ModuleAutoLoadSuccess extends ModuleAutoLoadMessageBase { + type: "moduleAutoLoad:success"; + data: { + loadedPackages: PackageData[]; + }; +} +export interface ModuleAutoLoadError extends ModuleAutoLoadMessageBase { + type: "moduleAutoLoad:error"; + error: Error; +} +export type ModuleAutoLoadMessage = ModuleAutoLoadSuccess | ModuleAutoLoadError; /** * Reply message to InMessage diff --git a/packages/kernel/src/worker-runtime.ts b/packages/kernel/src/worker-runtime.ts index 3cc8875d4..f2b9dc3f0 100644 --- a/packages/kernel/src/worker-runtime.ts +++ b/packages/kernel/src/worker-runtime.ts @@ -4,6 +4,7 @@ import { PromiseDelegate } from "@stlite/common"; import { writeFileWithParents, renameWithParents } from "./file"; import { validateRequirements } from "@stlite/common/src/requirements"; import { mockPyArrow } from "./mock"; +import { tryModuleAutoLoad } from "./module-auto-load"; import type { WorkerInitialData, OutMessage, @@ -34,15 +35,34 @@ async function initPyodide( return loadPyodide({ ...loadPyodideOptions, indexURL: indexUrl }); } +export type PostMessageFn = (message: OutMessage, port?: MessagePort) => void; + const self = global as typeof globalThis & { __logCallback__: (levelno: number, msg: string) => void; __streamlitFlagOptions__: Record<string, PyodideConvertiblePrimitive>; __scriptFinishedCallback__: () => void; + __moduleAutoLoadPromise__: Promise<unknown> | undefined; }; +function dispatchModuleAutoLoading( + pyodide: Pyodide.PyodideInterface, + postMessage: PostMessageFn, + sources: string[] +): void { + const autoLoadPromise = tryModuleAutoLoad(pyodide, postMessage, sources); + // `autoInstallPromise` will be awaited in the script_runner on the Python side. + self.__moduleAutoLoadPromise__ = autoLoadPromise; + pyodide.runPythonAsync(` +from streamlit.runtime.scriptrunner import script_runner +from js import __moduleAutoLoadPromise__ + +script_runner.moduleAutoLoadPromise = __moduleAutoLoadPromise__ +`); +} + export function startWorkerEnv( defaultPyodideUrl: string, - postMessage: (message: OutMessage) => void, + postMessage: PostMessageFn, presetInitialData?: Partial<WorkerInitialData> ) { function postProgressMessage(message: string): void { @@ -88,6 +108,7 @@ export function startWorkerEnv( streamlitConfig, idbfsMountpoints, nodefsMountpoints, + moduleAutoLoad, } = initData; const requirements = validateRequirements(unvalidatedRequirements); // Blocks the not allowed wheel URL schemes. @@ -133,6 +154,7 @@ export function startWorkerEnv( // Mount files postProgressMessage("Mounting files."); + const pythonFilePaths: string[] = []; await Promise.all( Object.keys(files).map(async (path) => { const file = files[path]; @@ -150,6 +172,10 @@ export function startWorkerEnv( console.debug(`Write a file "${path}"`); writeFileWithParents(pyodide, path, data, opts); + + if (path.endsWith(".py")) { + pythonFilePaths.push(path); + } }) ); @@ -241,6 +267,12 @@ with tarfile.open("${mountedSitePackagesSnapshotFilePath}", "r") as tar_gz_file: await micropip.install.callKwargs(requirements, { keep_going: true }); console.debug("Installed the requirements"); } + if (moduleAutoLoad) { + const sources = pythonFilePaths.map((path) => + pyodide.FS.readFile(path, { encoding: "utf8" }) + ); + dispatchModuleAutoLoading(pyodide, postMessage, sources); + } // The following code is necessary to avoid errors like `NameError: name '_imp' is not defined` // at importing installed packages. @@ -422,6 +454,8 @@ server.start() postMessage({ type: "event:loaded", }); + + return initData; } const pyodideReadyPromise = loadPyodideAndPackages().catch((error) => { @@ -448,7 +482,7 @@ server.start() return; } - await pyodideReadyPromise; + const { moduleAutoLoad } = await pyodideReadyPromise; const messagePort = event.ports[0]; @@ -543,6 +577,18 @@ server.start() case "file:write": { const { path, data: fileData, opts } = msg.data; + if ( + moduleAutoLoad && + typeof fileData === "string" && + path.endsWith(".py") + ) { + // Auto-install must be dispatched before writing the file + // because its promise should be set before saving the file triggers rerunning. + console.debug(`Auto install the requirements in ${path}`); + + dispatchModuleAutoLoading(pyodide, postMessage, [fileData]); + } + console.debug(`Write a file "${path}"`); writeFileWithParents(pyodide, path, fileData, opts); messagePort.postMessage({ diff --git a/packages/kernel/src/worker.ts b/packages/kernel/src/worker.ts index 6be09cef7..66eb07390 100644 --- a/packages/kernel/src/worker.ts +++ b/packages/kernel/src/worker.ts @@ -1,6 +1,7 @@ import { startWorkerEnv } from "./worker-runtime"; +const postMessage = (self as DedicatedWorkerGlobalScope).postMessage; self.onmessage = startWorkerEnv( "https://cdn.jsdelivr.net/pyodide/v0.26.0/full/pyodide.js", - self.postMessage + (event, port) => postMessage(event, port ? [port] : undefined) ); diff --git a/packages/sharing-common/src/messages.ts b/packages/sharing-common/src/messages.ts index e0d47409c..c0e2057b4 100644 --- a/packages/sharing-common/src/messages.ts +++ b/packages/sharing-common/src/messages.ts @@ -1,3 +1,5 @@ +import type { PackageData } from "pyodide"; + /** * Messages from editor to app */ @@ -38,9 +40,27 @@ export type ForwardMessage = | InstallMessage; /** - * Reply message + * Reply to a forward message. */ export interface ReplyMessage { type: "reply"; - error?: any; + error?: Error; } + +/** + * Messages from app to editor + */ +export interface BackwardMessageBase { + type: string; + data?: unknown; + stlite: true; // To distinguish from other messages such as from the Streamlit app like https://github.com/streamlit/streamlit/blob/1.35.0/frontend/lib/src/hostComm/types.ts#L49 +} +export interface ModuleAutoLoadSuccessMessage extends BackwardMessageBase { + type: "moduleAutoLoadSuccess"; + data: { + packagesToLoad: string[]; + loadedPackages: PackageData[]; + }; +} + +export type BackwardMessage = ModuleAutoLoadSuccessMessage; diff --git a/packages/sharing-editor/src/App.tsx b/packages/sharing-editor/src/App.tsx index 8eee17b03..445f7b077 100644 --- a/packages/sharing-editor/src/App.tsx +++ b/packages/sharing-editor/src/App.tsx @@ -1,12 +1,18 @@ import React, { useEffect, useCallback, useRef } from "react"; import "./App.css"; -import { embedAppDataToUrl, AppData, File } from "@stlite/sharing-common"; +import { + embedAppDataToUrl, + AppData, + File, + BackwardMessage, +} from "@stlite/sharing-common"; import { LoaderFunctionArgs, useLoaderData, redirect } from "react-router-dom"; import { useAppData } from "./use-app-data"; import StliteSharingIFrame, { + StliteSharingIFrameProps, StliteSharingIFrameRef, } from "./StliteSharingIFrame"; -import Editor, { EditorProps } from "./Editor"; +import Editor, { EditorProps, EditorRef } from "./Editor"; import PreviewToolBar from "./components/PreviewToolBar"; import { extractAppDataFromUrl } from "@stlite/sharing-common"; import { @@ -83,6 +89,7 @@ function App() { }, [initialAppData, initializeAppData]); const iframeRef = useRef<StliteSharingIFrameRef>(null); + const editorRef = useRef<EditorRef>(null); const handleFileWrite = useCallback<EditorProps["onFileWrite"]>( (path, value) => { @@ -208,6 +215,37 @@ function App() { [updateAppData] ); + const handleIframeMessage = useCallback< + StliteSharingIFrameProps["onMessage"] + >( + (e) => { + if (e.data.stlite !== true) { + return; + } + const msg = e.data as BackwardMessage; + switch (msg.type) { + case "moduleAutoLoadSuccess": { + if (msg.data.loadedPackages.length > 0) { + const additionalRequirements = msg.data.packagesToLoad; + const editor = editorRef.current; + if (editor == null) { + return; + } + editor.addRequirements( + additionalRequirements.map((r) => r + " # auto-loaded") + ); + updateAppData((cur) => ({ + ...cur, + requirements: cur.requirements.concat(additionalRequirements), + })); + } + break; + } + } + }, + [updateAppData] + ); + return ( <div className="App"> {!embedMode && ( @@ -222,6 +260,7 @@ function App() { left={ <Editor key={initAppDataKey} + ref={editorRef} appData={appData} onFileWrite={handleFileWrite} onFileRename={handleFileRename} @@ -246,6 +285,7 @@ function App() { messageTargetOrigin={SHARING_APP_ORIGIN} title="stlite app" className="preview-iframe" + onMessage={handleIframeMessage} /> )} </> diff --git a/packages/sharing-editor/src/Editor/components/Tab.module.scss b/packages/sharing-editor/src/Editor/components/Tab.module.scss index a7d149b3b..96fab0704 100644 --- a/packages/sharing-editor/src/Editor/components/Tab.module.scss +++ b/packages/sharing-editor/src/Editor/components/Tab.module.scss @@ -22,7 +22,7 @@ border-top: var(--c-primary) var.$tab-highlight-height solid; margin-top: -(var.$tab-highlight-height); border-bottom: none; - position: relative + position: relative; } $tabPaddingLeft: 0.5rem; diff --git a/packages/sharing-editor/src/Editor/index.tsx b/packages/sharing-editor/src/Editor/index.tsx index 9942c7c7f..0f6b912a8 100644 --- a/packages/sharing-editor/src/Editor/index.tsx +++ b/packages/sharing-editor/src/Editor/index.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useRef, useEffect, + useImperativeHandle, } from "react"; import MonacoEditor, { OnMount } from "@monaco-editor/react"; import { AppData } from "@stlite/sharing-common"; @@ -25,6 +26,9 @@ let newFileCount = 1; const REQUIREMENTS_FILENAME = "requirements"; +export interface EditorRef { + addRequirements: (requirements: string[]) => void; +} export interface EditorProps { appData: AppData; onFileWrite: (path: string, value: string | Uint8Array) => void; @@ -33,239 +37,275 @@ export interface EditorProps { onRequirementsChange: (requirements: string[]) => void; } -function Editor({ - appData, - onFileWrite, - onFileRename, - onFileDelete, - onRequirementsChange, -}: EditorProps) { - // Keep the tab order - const [tabFileNames, setTabFileNames] = useState<string[]>( - Object.keys(appData.files) - ); - - const fileNames = useMemo( - () => - Object.keys(appData.files).sort((a, b) => { - const aIdx = tabFileNames.indexOf(a); - const bIdx = tabFileNames.indexOf(b); - - // If the key is not found in `tabFileNames` unexpectedly, put its tab at the right most. - if (aIdx === -1) { - return 1; - } - if (bIdx === -1) { - return -1; - } - - // Sort the keys of appData.files according to `tabFileNames`. - return aIdx - bIdx; - }), - [appData, tabFileNames] - ); - const [currentFileName, setCurrentFileName] = useState<string | null>( - fileNames.length > 0 ? fileNames[0] : null - ); +const Editor = React.forwardRef<EditorRef, EditorProps>( + ( + { appData, onFileWrite, onFileRename, onFileDelete, onRequirementsChange }, + ref + ) => { + // Keep the tab order + const [tabFileNames, setTabFileNames] = useState<string[]>( + Object.keys(appData.files) + ); - const currentFile = - typeof currentFileName === "string" ? appData.files[currentFileName] : null; + const fileNames = useMemo( + () => + Object.keys(appData.files).sort((a, b) => { + const aIdx = tabFileNames.indexOf(a); + const bIdx = tabFileNames.indexOf(b); - const editorRef = useRef<Parameters<OnMount>[0]>(null); - const monacoRef = useRef<any>(null); - const handleEditorDitMount = useCallback<OnMount>((editor, monaco) => { - editorRef.current = editor; - monacoRef.current = monaco; - }, []); - useEffect(() => { - return () => { - const monaco = monacoRef.current; - if (monaco) { - // Clear all the existing models. Ref: https://stackoverflow.com/a/62466612/13103190 - // If we don't do it, the previous content will remain after changing the sample apps. - // @ts-ignore - monaco.editor.getModels().forEach((model) => model.dispose()); - } - }; - }, []); + // If the key is not found in `tabFileNames` unexpectedly, put its tab at the right most. + if (aIdx === -1) { + return 1; + } + if (bIdx === -1) { + return -1; + } - const handleSave = useCallback(() => { - if (currentFileName == null) { - return; - } - const editor = editorRef.current; - if (editor == null) { - return; - } + // Sort the keys of appData.files according to `tabFileNames`. + return aIdx - bIdx; + }), + [appData, tabFileNames] + ); + const [currentFileName, setCurrentFileName] = useState<string | null>( + fileNames.length > 0 ? fileNames[0] : null + ); - const value: string = editor.getValue(); - if (currentFileName === REQUIREMENTS_FILENAME) { - const requirements = parseRequirementsTxt(value); - onRequirementsChange(requirements); - return; - } + const currentFile = + typeof currentFileName === "string" + ? appData.files[currentFileName] + : null; - onFileWrite(currentFileName, value); - }, [onFileWrite, onRequirementsChange, currentFileName]); + const editorRef = useRef<Parameters<OnMount>[0]>(null); + const monacoRef = useRef<any>(null); + const handleEditorDitMount = useCallback<OnMount>((editor, monaco) => { + editorRef.current = editor; + monacoRef.current = monaco; + }, []); + useEffect(() => { + return () => { + const monaco = monacoRef.current; + if (monaco) { + // Clear all the existing models. Ref: https://stackoverflow.com/a/62466612/13103190 + // If we don't do it, the previous content will remain after changing the sample apps. + // @ts-ignore + monaco.editor.getModels().forEach((model) => model.dispose()); + } + }; + }, []); - const handleBinaryFileChange = useCallback( - (data: Uint8Array) => { + const handleSave = useCallback(() => { if (currentFileName == null) { return; } - if (typeof currentFileName !== "string") { + const editor = editorRef.current; + if (editor == null) { return; } - onFileWrite(currentFileName, data); - }, - [onFileWrite, currentFileName] - ); - const [addedFileName, setAddedFileName] = useState<string>(); - const focusTabNext = useCallback((fileName: string) => { - setCurrentFileName(fileName); - setAddedFileName(fileName); - }, []); + const value: string = editor.getValue(); + if (currentFileName === REQUIREMENTS_FILENAME) { + const requirements = parseRequirementsTxt(value); + onRequirementsChange(requirements); + return; + } - const handleFileUpload = useCallback<FileUploaderProps["onUpload"]>( - (files) => { - files.forEach((file) => { - if (file.type.startsWith("text")) { - const text = new TextDecoder().decode(file.data); - onFileWrite(file.name, text); - focusTabNext(file.name); - } else { - onFileWrite(file.name, file.data); - focusTabNext(file.name); + onFileWrite(currentFileName, value); + }, [onFileWrite, onRequirementsChange, currentFileName]); + + const handleBinaryFileChange = useCallback( + (data: Uint8Array) => { + if (currentFileName == null) { + return; + } + if (typeof currentFileName !== "string") { + return; } - setTabFileNames((cur) => [...cur, file.name]); - }); - }, - [onFileWrite, focusTabNext] - ); + onFileWrite(currentFileName, data); + }, + [onFileWrite, currentFileName] + ); - const handleFileDelete = useCallback( - (fileName: string) => { - const confirmed = window.confirm(`Delete ${fileName}?`); - if (!confirmed) { - return; - } + const [addedFileName, setAddedFileName] = useState<string>(); + const focusTabNext = useCallback((fileName: string) => { + setCurrentFileName(fileName); + setAddedFileName(fileName); + }, []); + + const handleFileUpload = useCallback<FileUploaderProps["onUpload"]>( + (files) => { + files.forEach((file) => { + if (file.type.startsWith("text")) { + const text = new TextDecoder().decode(file.data); + onFileWrite(file.name, text); + focusTabNext(file.name); + } else { + onFileWrite(file.name, file.data); + focusTabNext(file.name); + } + setTabFileNames((cur) => [...cur, file.name]); + }); + }, + [onFileWrite, focusTabNext] + ); - onFileDelete(fileName); - setTabFileNames((cur) => cur.filter((f) => f !== fileName)); - }, - [onFileDelete] - ); + const handleFileDelete = useCallback( + (fileName: string) => { + const confirmed = window.confirm(`Delete ${fileName}?`); + if (!confirmed) { + return; + } - const handleCreateFile = useCallback(() => { - const fileName = `file${newFileCount}.py`; - newFileCount += 1; + onFileDelete(fileName); + setTabFileNames((cur) => cur.filter((f) => f !== fileName)); + }, + [onFileDelete] + ); - onFileWrite(fileName, ""); - focusTabNext(fileName); - setTabFileNames((cur) => [...cur, fileName]); - }, [onFileWrite, focusTabNext]); + const handleCreateFile = useCallback(() => { + const fileName = `file${newFileCount}.py`; + newFileCount += 1; - const showTextEditor = - currentFile?.content?.$case === "text" || - currentFileName === REQUIREMENTS_FILENAME; + onFileWrite(fileName, ""); + focusTabNext(fileName); + setTabFileNames((cur) => [...cur, fileName]); + }, [onFileWrite, focusTabNext]); - const defaultRequirementsTextValue = useMemo( - () => appData.requirements.join("\n"), - [appData.requirements] - ); + const showTextEditor = + currentFile?.content?.$case === "text" || + currentFileName === REQUIREMENTS_FILENAME; - const [isDarkTheme, setIsDarkTheme] = useLocalStorage( - "editor-theme", - isDarkMode() - ); + const defaultRequirementsTextValue = useMemo( + () => appData.requirements.join("\n"), + [appData.requirements] + ); - return ( - <div className={styles.container}> - <ResizableHeader - resizableArea={ - <TabBar> - {fileNames.map((fileName) => ( - <Tab - key={fileName} - selected={fileName === currentFileName} - fileNameEditable={fileName !== appData.entrypoint} - initInEditingModeIfSelected={fileName === addedFileName} - fileName={fileName} - onSelect={() => setCurrentFileName(fileName)} - onDelete={() => handleFileDelete(fileName)} - onFileNameChange={(newPath) => { - onFileRename(fileName, newPath); - setTabFileNames((cur) => - cur.map((f) => (f === fileName ? newPath : f)) - ); - if (fileName === currentFileName) { - setCurrentFileName(newPath); - } - }} - /> - ))} - <div className={styles.controlButtonGroup}> - <AddButton onClick={handleCreateFile} /> - <FileUploader onUpload={handleFileUpload} /> - </div> + const [isDarkTheme, setIsDarkTheme] = useLocalStorage( + "editor-theme", + isDarkMode() + ); + + useImperativeHandle( + ref, + () => ({ + addRequirements: (additionalRequirements) => { + const monaco = monacoRef.current; + if (monaco == null) { + return; + } + + // Handle Monaco Editor's model directly imperatively to update the value. Ref: https://github.com/suren-atoyan/monaco-react/blob/v4.6.0/src/utils/index.ts#L21 + const uri = monaco.Uri.parse(REQUIREMENTS_FILENAME); + const model = + monaco.editor.getModel(uri) ?? + monaco.editor.createModel( + defaultRequirementsTextValue, + "text", + uri + ); + + const curValue = model.getValue(); + const newLineNeeded = curValue !== "" && !curValue.endsWith("\n"); + const newValue = + curValue + + (newLineNeeded ? "\n" : "") + + additionalRequirements.join("\n"); - <div className={styles.requirementsTabContainer}> - <Tab - selected={currentFileName === REQUIREMENTS_FILENAME} - fileNameEditable={false} - initInEditingModeIfSelected={false} - fileName={REQUIREMENTS_FILENAME} - onSelect={() => setCurrentFileName(REQUIREMENTS_FILENAME)} - onDelete={() => null} - onFileNameChange={() => null} + model.setValue(newValue); + }, + }), + [defaultRequirementsTextValue] + ); + + return ( + <div className={styles.container}> + <ResizableHeader + resizableArea={ + <TabBar> + {fileNames.map((fileName) => ( + <Tab + key={fileName} + selected={fileName === currentFileName} + fileNameEditable={fileName !== appData.entrypoint} + initInEditingModeIfSelected={fileName === addedFileName} + fileName={fileName} + onSelect={() => setCurrentFileName(fileName)} + onDelete={() => handleFileDelete(fileName)} + onFileNameChange={(newPath) => { + onFileRename(fileName, newPath); + setTabFileNames((cur) => + cur.map((f) => (f === fileName ? newPath : f)) + ); + if (fileName === currentFileName) { + setCurrentFileName(newPath); + } + }} + /> + ))} + <div className={styles.controlButtonGroup}> + <AddButton onClick={handleCreateFile} /> + <FileUploader onUpload={handleFileUpload} /> + </div> + + <div className={styles.requirementsTabContainer}> + <Tab + selected={currentFileName === REQUIREMENTS_FILENAME} + fileNameEditable={false} + initInEditingModeIfSelected={false} + fileName={REQUIREMENTS_FILENAME} + onSelect={() => setCurrentFileName(REQUIREMENTS_FILENAME)} + onDelete={() => null} + onFileNameChange={() => null} + /> + </div> + </TabBar> + } + fixedFooter={ + showTextEditor && ( + <Toolbar> + <SaveButton onClick={handleSave} /> + <ThemeSelect isDark={isDarkTheme} onChange={setIsDarkTheme} /> + </Toolbar> + ) + } + /> + <div className={styles.editorArea}> + <div + // NOTE: Keep the monaco-editor component being mounted + // and control its visibility with the hidden attribute here + // instead of mounting/unmounting the component according to the file type + // because it leads to flickering. + hidden={!showTextEditor} + style={{ height: "100%" }} + > + <MonacoEditor + path={ + typeof currentFileName === "string" + ? currentFileName + : undefined + } + defaultValue={ + currentFileName === REQUIREMENTS_FILENAME + ? defaultRequirementsTextValue + : currentFile?.content?.$case === "text" + ? currentFile.content.text + : undefined + } + onMount={handleEditorDitMount} + theme={isDarkTheme ? "vs-dark" : "vs"} + /> + </div> + {currentFileName != null && + currentFile?.content?.$case === "data" && ( + <BinaryFileEditor + path={currentFileName} + data={currentFile.content.data} + onChange={handleBinaryFileChange} /> - </div> - </TabBar> - } - fixedFooter={ - showTextEditor && ( - <Toolbar> - <SaveButton onClick={handleSave} /> - <ThemeSelect isDark={isDarkTheme} onChange={setIsDarkTheme} /> - </Toolbar> - ) - } - /> - <div className={styles.editorArea}> - <div - // NOTE: Keep the monaco-editor component being mounted - // and control its visibility with the hidden attribute here - // instead of mounting/unmounting the component according to the file type - // because it leads to flickering. - hidden={!showTextEditor} - style={{ height: "100%" }} - > - <MonacoEditor - path={ - typeof currentFileName === "string" ? currentFileName : undefined - } - defaultValue={ - currentFileName === REQUIREMENTS_FILENAME - ? defaultRequirementsTextValue - : currentFile?.content?.$case === "text" - ? currentFile.content.text - : undefined - } - onMount={handleEditorDitMount} - theme={isDarkTheme ? "vs-dark" : "light"} - /> + )} </div> - {currentFileName != null && currentFile?.content?.$case === "data" && ( - <BinaryFileEditor - path={currentFileName} - data={currentFile.content.data} - onChange={handleBinaryFileChange} - /> - )} </div> - </div> - ); -} + ); + } +); export default Editor; diff --git a/packages/sharing-editor/src/StliteSharingIFrame/index.tsx b/packages/sharing-editor/src/StliteSharingIFrame/index.tsx index e6722d445..a6dc89985 100644 --- a/packages/sharing-editor/src/StliteSharingIFrame/index.tsx +++ b/packages/sharing-editor/src/StliteSharingIFrame/index.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useMemo, useImperativeHandle } from "react"; +import React, { useRef, useMemo, useImperativeHandle, useEffect } from "react"; import { AppData, embedAppDataToUrl, @@ -15,13 +15,20 @@ export interface StliteSharingIFrameProps extends Omit<IFrameProps, "src"> { sharingAppSrc: string; initialAppData: AppData; messageTargetOrigin: string; + onMessage: (event: MessageEvent) => void; } const StliteSharingIFrame = React.forwardRef< StliteSharingIFrameRef, StliteSharingIFrameProps >( ( - { sharingAppSrc, initialAppData, messageTargetOrigin, ...iframeProps }, + { + sharingAppSrc, + initialAppData, + messageTargetOrigin, + onMessage, + ...iframeProps + }, ref ) => { const iframeRef = useRef<HTMLIFrameElement>(null); @@ -66,6 +73,19 @@ const StliteSharingIFrame = React.forwardRef< [messageTargetOrigin] ); + useEffect(() => { + const windowMessageEventListener = (event: MessageEvent) => { + if (event.source === iframeRef.current?.contentWindow) { + onMessage(event); + } + }; + + window.addEventListener("message", windowMessageEventListener); + return () => { + window.removeEventListener("message", windowMessageEventListener); + }; + }, [onMessage]); + return ( // eslint-disable-next-line jsx-a11y/iframe-has-title <iframe diff --git a/packages/sharing/src/App.tsx b/packages/sharing/src/App.tsx index 41ba5a7c2..be5d068a1 100644 --- a/packages/sharing/src/App.tsx +++ b/packages/sharing/src/App.tsx @@ -5,6 +5,7 @@ import { extractAppDataFromUrl, ForwardMessage, ReplyMessage, + ModuleAutoLoadSuccessMessage, } from "@stlite/sharing-common"; import StreamlitApp from "./StreamlitApp"; import { @@ -24,6 +25,8 @@ function isEditorOrigin(origin: string): boolean { return origin === process.env.REACT_APP_EDITOR_APP_ORIGIN; } +let communicatedEditorOrigin = ""; + function convertFiles( appDataFiles: AppData["files"] ): StliteKernelOptions["files"] { @@ -83,11 +86,35 @@ st.write("Hello World")`, requirements: appData.requirements, prebuiltPackageNames: [], ...makeToastKernelCallbacks(), + moduleAutoLoad: true, }); _kernel = kernel; setKernel(kernel); - const kernelWithToast = new StliteKernelWithToast(kernel); + const kernelWithToast = new StliteKernelWithToast(kernel, { + onModuleAutoLoad: (packagesToLoad, installPromise) => { + console.log("Module auto-load started", packagesToLoad); + installPromise + .then((loadedPackages) => { + console.log("Module auto-load success", loadedPackages); + window.parent.postMessage( + { + type: "moduleAutoLoadSuccess", + data: { + packagesToLoad, + loadedPackages, + }, + stlite: true, + } as ModuleAutoLoadSuccessMessage, + process.env.REACT_APP_EDITOR_APP_ORIGIN ?? + communicatedEditorOrigin // Fall back to the origin of the last message from the editor app if the REACT_APP_EDITOR_APP_ORIGIN env var is not set, i.e. in preview deployments. + ); + }) + .catch((error) => { + console.error("Auto install failed", error); + }); + }, + }); // Handle messages from the editor onMessage = (event) => { @@ -95,6 +122,8 @@ st.write("Hello World")`, return; } + communicatedEditorOrigin = event.origin; + const port2 = event.ports[0]; function postReplyMessage(msg: ReplyMessage) { port2.postMessage(msg); diff --git a/streamlit b/streamlit index 026039e5d..f76a269dc 160000 --- a/streamlit +++ b/streamlit @@ -1 +1 @@ -Subproject commit 026039e5dff8f4c3d37e0c6b6932ff177a20d800 +Subproject commit f76a269dc4695f0e4724c6a6c52bbfb3e8b99f86