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 1fddad0bb..4cda2be69 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 @@ -91,4 +91,12 @@ export class StliteKernelWithToast { error: "Failed to install", }); } + + public reboot(...args: Parameters) { + return stliteStyledPromiseToast(this.kernel.reboot(...args), { + pending: "Rebooting", + success: "Successfully rebooted", + error: "Failed to reboot", + }); + } } diff --git a/packages/kernel/py/stlite-lib/stlite_lib/server/server.py b/packages/kernel/py/stlite-lib/stlite_lib/server/server.py index 4d62fc899..93c334174 100644 --- a/packages/kernel/py/stlite-lib/stlite_lib/server/server.py +++ b/packages/kernel/py/stlite-lib/stlite_lib/server/server.py @@ -5,6 +5,7 @@ from typing import Callable, Final, cast import pyodide.ffi +from streamlit import source_util from streamlit.proto.BackMsg_pb2 import BackMsg from streamlit.proto.ForwardMsg_pb2 import ForwardMsg from streamlit.runtime import Runtime, RuntimeConfig, SessionClient @@ -210,7 +211,17 @@ def callback(future: asyncio.Future): def stop(self): self._websocket_handler.on_close() + + # `Runtime.stop()` doesn't stop the running tasks immediately, + # but we don't need to wait for them to finish for the current use case, + # e.g. booting up a new server and replacing the old one. self._runtime.stop() + Runtime._instance = None + + # `source_util.get_pages()`, which is used from `PagesStrategyV1.get_initial_active_script` + # to resolve the pages info, caches the pages in the module-level variable `source_util._cached_pages`. + # We need to invalidate this cache to avoid using the old pages info when booting up a new server. + source_util.invalidate_pages_cache() class WebSocketHandler(SessionClient): diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 941790dff..4c5e483b2 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -306,6 +306,20 @@ export class StliteKernel { }); } + /** + * Reboot the Streamlit server. + * Note that we also need to refresh (rerender) the frontend app after calling this method + * to reflect the changes on the user-facing side. + */ + public reboot(entrypoint: string): Promise { + return this._asyncPostMessage({ + type: "reboot", + data: { + entrypoint, + }, + }); + } + private _asyncPostMessage( message: InMessage, ): Promise; diff --git a/packages/kernel/src/types.ts b/packages/kernel/src/types.ts index b9e0dc12e..fb3cbb455 100644 --- a/packages/kernel/src/types.ts +++ b/packages/kernel/src/types.ts @@ -70,6 +70,12 @@ export interface InMessageInitData extends InMessageBase { type: "initData"; data: WorkerInitialData; } +export interface InMessageReboot extends InMessageBase { + type: "reboot"; + data: { + entrypoint: string; + }; +} export interface InMessageWebSocketConnect extends InMessageBase { type: "websocket:connect"; data: { @@ -117,6 +123,7 @@ export interface InMessageInstall extends InMessageBase { } export type InMessage = | InMessageInitData + | InMessageReboot | InMessageWebSocketConnect | InMessageWebSocketSend | InMessageHttpRequest diff --git a/packages/kernel/src/worker-runtime.ts b/packages/kernel/src/worker-runtime.ts index 430b79190..4bee94829 100644 --- a/packages/kernel/src/worker-runtime.ts +++ b/packages/kernel/src/worker-runtime.ts @@ -370,8 +370,8 @@ AppSession._on_scriptrunner_event = wrap_app_session_on_scriptrunner_event(AppSe } postProgressMessage("Booting up the Streamlit server."); - console.debug("Booting up the Streamlit server"); // The following Python code is based on streamlit.web.cli.main_run(). + console.debug("Setting up the Streamlit configuration"); self.__streamlitFlagOptions__ = { // gatherUsageStats is disabled as default, but can be enabled explicitly by setting it to true. "browser.gatherUsageStats": false, @@ -380,7 +380,6 @@ AppSession._on_scriptrunner_event = wrap_app_session_on_scriptrunner_event(AppSe }; await pyodide.runPythonAsync(` from stlite_lib.bootstrap import load_config_options, prepare -from stlite_lib.server import Server from js import __streamlitFlagOptions__ flag_options = __streamlitFlagOptions__.to_py() @@ -390,16 +389,14 @@ main_script_path = "${entrypoint}" args = [] prepare(main_script_path, args) - -server = Server(main_script_path) -server.start() `); - console.debug("Booted up the Streamlit server"); + console.debug("Set up the Streamlit configuration"); - console.debug("Setting up the HTTP server"); - // Pull the http server instance from Python world to JS world and set up it. - httpServer = pyodide.globals.get("server").copy(); - console.debug("Set up the HTTP server"); + console.debug("Booting up the Streamlit server"); + const Server = pyodide.pyimport("stlite_lib.server.Server"); + httpServer = Server(entrypoint); + httpServer.start(); + console.debug("Booted up the Streamlit server"); postMessage({ type: "event:loaded", @@ -438,6 +435,24 @@ server.start() try { switch (msg.type) { + case "reboot": { + console.debug("Reboot the Streamlit server", msg.data); + + const { entrypoint } = msg.data; + + httpServer.stop(); + + console.debug("Booting up the Streamlit server"); + const Server = pyodide.pyimport("stlite_lib.server.Server"); + httpServer = Server(entrypoint); + httpServer.start(); + console.debug("Booted up the Streamlit server"); + + messagePort.postMessage({ + type: "reply", + }); + break; + } case "websocket:connect": { console.debug("websocket:connect", msg.data); diff --git a/packages/sharing-common/src/messages.ts b/packages/sharing-common/src/messages.ts index c0e2057b4..9f880b986 100644 --- a/packages/sharing-common/src/messages.ts +++ b/packages/sharing-common/src/messages.ts @@ -7,6 +7,12 @@ export interface ForwardMessageBase { type: string; data?: unknown; } +export interface RebootMessage extends ForwardMessageBase { + type: "reboot"; + data: { + entrypoint: string; + }; +} export interface FileWriteMessage extends ForwardMessageBase { type: "file:write"; data: { @@ -34,6 +40,7 @@ export interface InstallMessage extends ForwardMessageBase { }; } export type ForwardMessage = + | RebootMessage | FileWriteMessage | FileRenameMessage | FileUnlinkMessage diff --git a/packages/sharing-editor/src/App.tsx b/packages/sharing-editor/src/App.tsx index a4da443c9..23438b72d 100644 --- a/packages/sharing-editor/src/App.tsx +++ b/packages/sharing-editor/src/App.tsx @@ -221,6 +221,25 @@ function App() { [updateAppData], ); + const handleEntrypointChange = useCallback( + (entrypoint) => { + iframeRef.current?.postMessage({ + type: "reboot", + data: { + entrypoint, + }, + }); + + updateAppData((cur) => { + return { + ...cur, + entrypoint, + }; + }); + }, + [updateAppData], + ); + const handleIframeMessage = useCallback< StliteSharingIFrameProps["onMessage"] >( @@ -272,6 +291,7 @@ function App() { onFileRename={handleFileRename} onFileDelete={handleFileDelete} onRequirementsChange={handleRequirementsChange} + onEntrypointChange={handleEntrypointChange} /> } right={ diff --git a/packages/sharing-editor/src/Editor/components/Tab.module.scss b/packages/sharing-editor/src/Editor/components/Tab.module.scss index 96fab0704..25c3265e4 100644 --- a/packages/sharing-editor/src/Editor/components/Tab.module.scss +++ b/packages/sharing-editor/src/Editor/components/Tab.module.scss @@ -1,7 +1,7 @@ @use "variables" as var; @use "mixins"; -.tabFrame { +.tab { position: relative; display: inline-flex; align-items: center; @@ -9,82 +9,126 @@ margin-bottom: 4px; font-size: 0.8rem; background: rgba(0, 0, 0, 0.05); - border: rgba(0, 0, 0, 0.1) 1px solid; + border: rgba(0, 0, 0, 0.1) var.$border-width solid; height: var.$tab-height; + line-height: normal; + + &:hover { + background: initial; + } + + &:has([role="tab"][aria-selected=true]) { + background: initial; + border-bottom: rgba(255,255,255,0) var.$border-width solid; + + &::before { + content: ''; + position: absolute; + top: - var.$border-width; + left: 0; + width: 100%; + height: var.$tab-highlight-height; + background-color: var(--c-primary); + } + } } -.tabFrame:hover { - background: initial; -} - -.tabFrameSelected { - background: initial; - border-top: var(--c-primary) var.$tab-highlight-height solid; - margin-top: -(var.$tab-highlight-height); - border-bottom: none; - position: relative; -} - -$tabPaddingLeft: 0.5rem; -$deleteButtonSpaceWidth: 1.2rem; - .tabButton { @include mixins.reset-button; + display: inline-flex; + align-items: center; width: 100%; height: 100%; - padding-left: $tabPaddingLeft; - padding-right: $deleteButtonSpaceWidth; + padding: 0 0.5rem; +} + +.editableTabBody { + display: inline-block; + position: relative; + + .fileNameForm { + position: absolute; + width: 100%; + left: 0; + top: 0; + } + + .fileNameInput { + @include mixins.reset-input; + + display: inline-block; + width: 100%; + } + .fileNameInputError { + border: red 1px solid; + } } -.deleteButtonContainer { - position: absolute; - right: 0; - top: 0; - bottom: 0; +.entrypointIndicator { display: flex; align-items: center; - padding-top: var.$tab-highlight-height; - pointer-events: none; + justify-content: center; + margin-right: 0.3rem; + position: relative; + + .tooltip { + display: none; + + position: absolute; + top: 0; + transform: translate(0, -50%); + left: 100%; + z-index: 1; + font-size: 0.7rem; + text-align: center; + border-radius: 6px; + padding: 0.3rem 0.5rem; + background-color: rgba(0,0,0,0.6); + color: #fff; + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + pointer-events: none; + } + + &:hover .tooltip { + display: block; + } } -.deleteButton { +.dropdownButton { @include mixins.reset-button; - font-size: 0.6rem; - - padding: 0.2rem; - - pointer-events: initial; -} -.deleteButton:not([disabled]):hover { - color: var(--c-primary); -} - -.selectedTab { - display: inline-block; - padding-left: $tabPaddingLeft; - padding-right: $deleteButtonSpaceWidth; -} + display: flex; + width: 1rem; + height: 100%; + align-items: center; + justify-content: center; + margin-left: -0.5rem; -.selectedTabInner { - position: relative; -} + font-size: 0.6rem; + cursor: pointer; -.fileNameForm { - display: inline-block; - position: absolute; - width: 100%; - left: 0; - top: 0; + &:hover { + color: var(--c-primary); + } } -.fileNameInput { - @include mixins.reset-input; - - display: inline-block; - width: 100%; -} -.fileNameInputError { - border: red 1px solid; +.dropdownContent { + display: flex; + flex-direction: column; + background-color: var(--c-background); + box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2); + + button { + background: none; + border: none; + padding: 8px 16px; + text-align: left; + cursor: pointer; + width: 100%; + + &:hover { + background-color: var(--c-background-hover); + } + } } diff --git a/packages/sharing-editor/src/Editor/components/Tab.tsx b/packages/sharing-editor/src/Editor/components/Tab.tsx index 57c4a32b0..84ab572d6 100644 --- a/packages/sharing-editor/src/Editor/components/Tab.tsx +++ b/packages/sharing-editor/src/Editor/components/Tab.tsx @@ -1,5 +1,13 @@ -import React, { useState, useCallback, useMemo } from "react"; -import { RiDeleteBinLine } from "react-icons/ri"; +import React, { + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from "react"; +import ReactDOM from "react-dom"; +import { AiTwotonePlaySquare } from "react-icons/ai"; +import { PiDotsThreeOutlineVertical } from "react-icons/pi"; import { isValidFilePath } from "../../path"; import styles from "./Tab.module.scss"; @@ -65,16 +73,16 @@ function FileNameForm({ } const WHITESPACE = "\u00A0"; -interface SelectedTabProps { +interface EditableTabBodyProps { fileName: string; shouldBeEditingByDefault: boolean; onFileNameChange: (fileName: string) => void; } -function SelectedTab({ +function EditableTabBody({ fileName, shouldBeEditingByDefault, onFileNameChange, -}: SelectedTabProps) { +}: EditableTabBodyProps) { const [fileNameEditing, setFileNameEditing] = useState( shouldBeEditingByDefault, ); @@ -99,50 +107,103 @@ function SelectedTab({ const displayFileNameNoSpace = displayFileName.length > 0 ? displayFileName : WHITESPACE; return ( - - - {displayFileNameNoSpace} - {fileNameEditing && ( - - )} - + + {displayFileNameNoSpace} + {fileNameEditing && ( + + )} ); } -interface DeleteButtonProps { - onClick: () => void; - disabled: boolean; +interface DropdownMenuProps { + onDelete?: () => void; + onSetEntrypoint?: () => void; } -function DeleteButton(props: DeleteButtonProps) { +function DropdownMenu(props: DropdownMenuProps) { + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState({ top: 0, left: 0 }); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + const handleButtonClick: React.MouseEventHandler = + useCallback((event) => { + event.stopPropagation(); // To prevent the dropdown from closing immediately by the document click event caught by `handleClickOutside` below. + + const clickedButton = event.currentTarget; + const rect = clickedButton.getBoundingClientRect(); + setPosition({ top: rect.bottom, left: rect.left }); + setIsOpen((cur) => !cur); + }, []); + + const handleClickOutside = useCallback((event: MouseEvent) => { + if (buttonRef.current && buttonRef.current.contains(event.target as Node)) { + return; + } + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [handleClickOutside]); + return ( - + <> + + {isOpen && + ReactDOM.createPortal( +
+ {props.onDelete && } + {props.onSetEntrypoint && ( + + )} +
, + document.body, + )} + ); } interface TabProps { + isEntrypoint: boolean; fileName: string; selected: boolean; fileNameEditable: boolean; initInEditingModeIfSelected: boolean; onSelect: () => void; - onDelete: () => void; + onDelete?: () => void; onFileNameChange: (fileName: string) => void; + onEntrypointSet?: () => void; } - function Tab({ + isEntrypoint, fileName, selected, fileNameEditable, @@ -150,25 +211,35 @@ function Tab({ onSelect, onDelete, onFileNameChange, + onEntrypointSet, }: TabProps) { return ( -
- {fileNameEditable && selected ? ( - - ) : ( - +
+ + {(onDelete || onEntrypointSet) && ( + )} -
- -
); } diff --git a/packages/sharing-editor/src/Editor/components/TabBar.tsx b/packages/sharing-editor/src/Editor/components/TabBar.tsx index 329a75050..17f5ca097 100644 --- a/packages/sharing-editor/src/Editor/components/TabBar.tsx +++ b/packages/sharing-editor/src/Editor/components/TabBar.tsx @@ -5,7 +5,11 @@ interface TabBarProps { children: React.ReactNode; } function TabBar(props: TabBarProps) { - return
{props.children}
; + return ( +
+ {props.children} +
+ ); } export default TabBar; diff --git a/packages/sharing-editor/src/Editor/components/_variables.scss b/packages/sharing-editor/src/Editor/components/_variables.scss index 42037c4f2..5612e461f 100644 --- a/packages/sharing-editor/src/Editor/components/_variables.scss +++ b/packages/sharing-editor/src/Editor/components/_variables.scss @@ -1,2 +1,3 @@ $tab-height: 1.6rem; $tab-highlight-height: 2px; +$border-width: 1px; diff --git a/packages/sharing-editor/src/Editor/index.tsx b/packages/sharing-editor/src/Editor/index.tsx index 87abe4f62..0ce94d2ad 100644 --- a/packages/sharing-editor/src/Editor/index.tsx +++ b/packages/sharing-editor/src/Editor/index.tsx @@ -38,11 +38,19 @@ export interface EditorProps { onFileRename: (oldPath: string, newPath: string) => void; onFileDelete: (path: string) => void; onRequirementsChange: (requirements: string[]) => void; + onEntrypointChange: (entrypoint: string) => void; } const Editor = React.forwardRef( ( - { appData, onFileWrite, onFileRename, onFileDelete, onRequirementsChange }, + { + appData, + onFileWrite, + onFileRename, + onFileDelete, + onRequirementsChange, + onEntrypointChange, + }, ref, ) => { // Keep the tab order @@ -224,26 +232,44 @@ const Editor = React.forwardRef( - {fileNames.map((fileName) => ( - setCurrentFileName(fileName)} - onDelete={() => handleFileDelete(fileName)} - onFileNameChange={(newPath) => { - onFileRename(fileName, newPath); - setTabFileNames((cur) => - cur.map((f) => (f === fileName ? newPath : f)), - ); - if (fileName === currentFileName) { - setCurrentFileName(newPath); + {fileNames.map((fileName) => { + const isEntrypoint = fileName === appData.entrypoint; + return ( + setCurrentFileName(fileName)} + onDelete={ + !isEntrypoint + ? () => handleFileDelete(fileName) + : undefined } - }} - /> - ))} + onFileNameChange={(newPath) => { + onFileRename(fileName, newPath); + setTabFileNames((cur) => + cur.map((f) => (f === fileName ? newPath : f)), + ); + if (fileName === currentFileName) { + setCurrentFileName(newPath); + } + if (isEntrypoint) { + onEntrypointChange(newPath); + } + }} + onEntrypointSet={ + !isEntrypoint && fileName.endsWith(".py") + ? () => { + onEntrypointChange(fileName); + } + : undefined + } + /> + ); + })}
@@ -254,12 +280,12 @@ const Editor = React.forwardRef(
setCurrentFileName(REQUIREMENTS_FILENAME)} - onDelete={() => null} onFileNameChange={() => null} />
diff --git a/packages/sharing-editor/src/global.scss b/packages/sharing-editor/src/global.scss index 47fe54694..8d0147552 100644 --- a/packages/sharing-editor/src/global.scss +++ b/packages/sharing-editor/src/global.scss @@ -1,4 +1,6 @@ :root { --c-primary: #f44b4b; --c-primary-focus: #ad4a51; + --c-background: #ffffff; + --c-background-hover: #f5f5f5; } diff --git a/packages/sharing/src/App.tsx b/packages/sharing/src/App.tsx index 3a8bf7643..38b249eb3 100644 --- a/packages/sharing/src/App.tsx +++ b/packages/sharing/src/App.tsx @@ -48,6 +48,7 @@ function convertFiles( function App() { const [kernel, setKernel] = useState(); + const [appKey, setAppKey] = useState(0); // This is used to force re-rendering of the StreamlitApp component when the kernel is rebooted. useEffect(() => { let unmounted = false; let _kernel: StliteKernel | null = null; @@ -132,6 +133,11 @@ st.write("Hello World")`, const msg = event.data; (() => { switch (msg.type) { + case "reboot": { + return kernelWithToast.reboot(msg.data.entrypoint).then(() => { + setAppKey((prev) => prev + 1); + }); + } case "file:write": { return kernelWithToast.writeFile( msg.data.path, @@ -175,7 +181,7 @@ st.write("Hello World")`, }; }, []); - return kernel ? : null; + return kernel ? : null; } export default App;