diff --git a/frontend/src/core/islands/main.ts b/frontend/src/core/islands/main.ts index ad750b12d63..25e8aaf61a0 100644 --- a/frontend/src/core/islands/main.ts +++ b/frontend/src/core/islands/main.ts @@ -202,6 +202,8 @@ export async function initialize() { case "model-lifecycle": handleWidgetMessage(MODEL_MANAGER, msg.data); return; + case "update-css": + return; default: logNever(msg.data); } diff --git a/frontend/src/core/websocket/useMarimoKernelConnection.tsx b/frontend/src/core/websocket/useMarimoKernelConnection.tsx index 92078b18f51..6f9a6a3ede0 100644 --- a/frontend/src/core/websocket/useMarimoKernelConnection.tsx +++ b/frontend/src/core/websocket/useMarimoKernelConnection.tsx @@ -304,6 +304,16 @@ export function useMarimoKernelConnection(opts: { case "update-cell-ids": setCellIds({ cellIds: msg.data.cell_ids as CellId[] }); return; + case "update-css": { + let el = document.querySelector("style[title='marimo-custom']"); + if (!el) { + el = document.createElement("style"); + el.setAttribute("title", "marimo-custom"); + document.head.append(el); + } + el.textContent = msg.data.css; + return; + } default: logNever(msg.data); } diff --git a/marimo/_cli/development/commands.py b/marimo/_cli/development/commands.py index 6baed3a6ce8..79ea57f82bb 100644 --- a/marimo/_cli/development/commands.py +++ b/marimo/_cli/development/commands.py @@ -133,6 +133,7 @@ def _generate_server_api_schema() -> dict[str, Any]: notifications.UpdateCellCodesNotification, notifications.UpdateCellIdsNotification, notifications.FocusCellNotification, + notifications.UpdateCssNotification, notifications.NotificationMessage, # ai ChatMessage, diff --git a/marimo/_messaging/notification.py b/marimo/_messaging/notification.py index 74e5860754b..34397d9bf20 100644 --- a/marimo/_messaging/notification.py +++ b/marimo/_messaging/notification.py @@ -731,6 +731,17 @@ class UpdateCellIdsNotification(Notification, tag="update-cell-ids"): cell_ids: list[CellId_t] +class UpdateCssNotification(Notification, tag="update-css"): + """Pushes updated CSS content to the frontend. + + Attributes: + css: The new CSS content (empty string clears custom CSS). + """ + + name: ClassVar[str] = "update-css" + css: str + + NotificationMessage = Union[ # Cell operations CellNotification, @@ -779,4 +790,6 @@ class UpdateCellIdsNotification(Notification, tag="update-cell-ids"): FocusCellNotification, UpdateCellCodesNotification, UpdateCellIdsNotification, + # CSS hot reload + UpdateCssNotification, ] diff --git a/marimo/_server/api/endpoints/files.py b/marimo/_server/api/endpoints/files.py index c75dbbb1c51..00b0f58bd3f 100644 --- a/marimo/_server/api/endpoints/files.py +++ b/marimo/_server/api/endpoints/files.py @@ -244,6 +244,32 @@ async def save_app_config( app_state = AppState(request) body = await parse_request(request, cls=SaveAppConfigurationRequest) session = app_state.require_current_session() + + old_css_file = session.app_file_manager.app.config.css_file contents = session.app_file_manager.save_app_config(body.config) + # If css_file changed, push new CSS to the frontend and update watcher + if "css_file" in body.config: + new_css_file = session.app_file_manager.app.config.css_file + if old_css_file != new_css_file: + from marimo._messaging.notification import ( + UpdateCssNotification, + ) + from marimo._session.file_watcher_integration import ( + SessionFileWatcherExtension, + ) + from marimo._session.session import SessionImpl + + css_content = session.app_file_manager.read_css_file() or "" + session.notify( + UpdateCssNotification(css=css_content), + from_consumer_id=None, + ) + + if isinstance(session, SessionImpl): + for ext in session.extensions: + if isinstance(ext, SessionFileWatcherExtension): + ext.update_css_watcher(session) + break + return PlainTextResponse(content=contents) diff --git a/marimo/_session/file_watcher_integration.py b/marimo/_session/file_watcher_integration.py index 917ec3678ab..d2e2cce6e02 100644 --- a/marimo/_session/file_watcher_integration.py +++ b/marimo/_session/file_watcher_integration.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Callable from marimo import _loggers +from marimo._messaging.notification import UpdateCssNotification from marimo._session.events import ( SessionEventBus, SessionEventListener, @@ -26,6 +27,20 @@ from collections.abc import Awaitable +def _resolve_css_path(session: Session) -> Path | None: + """Resolve the absolute path to the CSS file from session config.""" + css_file = session.app_file_manager.app.config.css_file + notebook_path = session.app_file_manager.path + if not css_file or not notebook_path: + return None + + filepath = Path(css_file) + if not filepath.is_absolute(): + filepath = Path(notebook_path).parent / filepath + + return filepath + + class SessionFileWatcherExtension(SessionExtension, SessionEventListener): """Manages file watcher lifecycle for sessions.""" @@ -44,6 +59,7 @@ def __init__( self._event_bus: SessionEventBus | None = None self._session: Session | None = None self._on_change_callback = on_change_callback + self._css_path: Path | None = None def on_attach(self, session: Session, event_bus: SessionEventBus) -> None: """Attach the file watcher extension to a session.""" @@ -54,17 +70,59 @@ def on_attach(self, session: Session, event_bus: SessionEventBus) -> None: if not session.app_file_manager.path: return - # Register with the watcher manager + # Register notebook file watcher self._watcher_manager.add_callback( Path(session.app_file_manager.path), self._handle_file_change ) + # Register CSS file watcher if configured + self._start_css_watcher(session) + LOGGER.info( "Attached file watcher for session %s at path %s", session.initialization_id, session.app_file_manager.path, ) + def _start_css_watcher(self, session: Session) -> None: + """Start watching the CSS file if configured.""" + css_path = _resolve_css_path(session) + if css_path and css_path.exists(): + self._css_path = css_path + self._watcher_manager.add_callback( + css_path, self._handle_css_change + ) + LOGGER.debug("Watching CSS file: %s", css_path) + + def _stop_css_watcher(self) -> None: + """Stop watching the current CSS file if any.""" + if self._css_path: + self._watcher_manager.remove_callback( + self._css_path, self._handle_css_change + ) + LOGGER.debug("Stopped watching CSS file: %s", self._css_path) + self._css_path = None + + def update_css_watcher(self, session: Session) -> None: + """Update the CSS file watcher after a config change. + + Compares the currently watched path with the new config and + registers/deregisters watchers as needed. + """ + new_css_path = _resolve_css_path(session) + + # Nothing changed + if self._css_path == new_css_path: + return + + self._stop_css_watcher() + if new_css_path and new_css_path.exists(): + self._css_path = new_css_path + self._watcher_manager.add_callback( + new_css_path, self._handle_css_change + ) + LOGGER.debug("Updated CSS watcher to: %s", new_css_path) + async def _handle_file_change(self, path: Path) -> None: """Handle a file change.""" if not self._session: @@ -72,6 +130,18 @@ async def _handle_file_change(self, path: Path) -> None: await self._on_change_callback(path, self._session) + async def _handle_css_change(self, path: Path) -> None: + """Handle a CSS file change by pushing new content to the frontend.""" + if not self._session: + return + + css_content = self._session.app_file_manager.read_css_file() or "" + self._session.notify( + UpdateCssNotification(css=css_content), + from_consumer_id=None, + ) + LOGGER.debug("Sent CSS update for: %s", path) + def on_detach(self) -> None: """Detach the file watcher extension from a session.""" if not self._session: @@ -80,12 +150,15 @@ def on_detach(self) -> None: if not self._session.app_file_manager.path: return - # Remove from watcher manager + # Remove notebook file watcher self._watcher_manager.remove_callback( self._canonicalize_path(self._session.app_file_manager.path), self._handle_file_change, ) + # Remove CSS file watcher + self._stop_css_watcher() + LOGGER.info( "Detached file watcher for session %s from path %s", self._session.initialization_id, diff --git a/packages/openapi/api.yaml b/packages/openapi/api.yaml index 26808828d31..01df54bead2 100644 --- a/packages/openapi/api.yaml +++ b/packages/openapi/api.yaml @@ -2453,6 +2453,7 @@ components: - $ref: '#/components/schemas/FocusCellNotification' - $ref: '#/components/schemas/UpdateCellCodesNotification' - $ref: '#/components/schemas/UpdateCellIdsNotification' + - $ref: '#/components/schemas/UpdateCssNotification' discriminator: mapping: alert: '#/components/schemas/AlertNotification' @@ -2488,6 +2489,7 @@ components: storage-namespaces: '#/components/schemas/StorageNamespacesNotification' update-cell-codes: '#/components/schemas/UpdateCellCodesNotification' update-cell-ids: '#/components/schemas/UpdateCellIdsNotification' + update-css: '#/components/schemas/UpdateCssNotification' validate-sql-result: '#/components/schemas/ValidateSQLResultNotification' variable-values: '#/components/schemas/VariableValuesNotification' variables: '#/components/schemas/VariablesNotification' @@ -4581,6 +4583,20 @@ components: - cellIdsToOutput title: UpdateCellOutputsRequest type: object + UpdateCssNotification: + description: "Pushes updated CSS content to the frontend.\n\n Attributes:\n\ + \ css: The new CSS content (empty string clears custom CSS)." + properties: + css: + type: string + op: + enum: + - update-css + required: + - op + - css + title: UpdateCssNotification + type: object UpdateUIElementCommand: description: "Update UI element values.\n\n Triggered when users interact\ \ with UI elements (sliders, inputs, dropdowns, etc.).\n Updates element\ @@ -4938,7 +4954,7 @@ components: type: object info: title: marimo API - version: 0.19.10 + version: 0.19.11 openapi: 3.1.0 paths: /@file/{filename_and_length}: @@ -6311,3 +6327,4 @@ paths: summary: Submit login form tags: - auth + diff --git a/packages/openapi/src/api.ts b/packages/openapi/src/api.ts index 867900cfbe5..563ea2a9e1b 100644 --- a/packages/openapi/src/api.ts +++ b/packages/openapi/src/api.ts @@ -4555,7 +4555,8 @@ export interface components { | components["schemas"]["CacheInfoNotification"] | components["schemas"]["FocusCellNotification"] | components["schemas"]["UpdateCellCodesNotification"] - | components["schemas"]["UpdateCellIdsNotification"]; + | components["schemas"]["UpdateCellIdsNotification"] + | components["schemas"]["UpdateCssNotification"]; }; /** * LanguageServersConfig @@ -5838,6 +5839,18 @@ export interface components { ]; }; }; + /** + * UpdateCssNotification + * @description Pushes updated CSS content to the frontend. + * + * Attributes: + * css: The new CSS content (empty string clears custom CSS). + */ + UpdateCssNotification: { + css: string; + /** @enum {unknown} */ + op: "update-css"; + }; /** * UpdateUIElementCommand * @description Update UI element values. diff --git a/tests/_session/test_file_watcher_integration.py b/tests/_session/test_file_watcher_integration.py index 92ed94846ff..4611dbda978 100644 --- a/tests/_session/test_file_watcher_integration.py +++ b/tests/_session/test_file_watcher_integration.py @@ -17,11 +17,15 @@ from marimo._utils.file_watcher import FileWatcherManager -def create_mock_session(file_path: str | None): +def create_mock_session(file_path: str | None, css_file: str | None = None): """Create a mock session for testing.""" session = MagicMock() session.app_file_manager = MagicMock() session.app_file_manager.path = file_path + session.app_file_manager.app.config.css_file = css_file + session.app_file_manager.read_css_file.return_value = ( + "body { color: red; }" if css_file else None + ) return session @@ -204,3 +208,167 @@ async def test_listener_on_session_closed_disabled( # Verify watcher was not detached watcher_manager.remove_callback.assert_not_called() + + +def test_attach_with_css_file( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test that CSS file watcher is registered when css_file is configured.""" + css_path = tmp_path / "custom.css" + css_path.write_text("body { color: red; }") + notebook_path = str(tmp_path / "notebook.py") + + session = create_mock_session(notebook_path, css_file="custom.css") + lifecycle.on_attach(session, SessionEventBus()) + + # Should register both notebook and CSS watchers + assert watcher_manager.add_callback.call_count == 2 + css_call = watcher_manager.add_callback.call_args_list[1] + assert css_call[0][0] == css_path + + +def test_attach_without_css_file( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, +) -> None: + """Test that no CSS watcher is registered when css_file is not set.""" + session = create_mock_session("/path/to/notebook.py") + lifecycle.on_attach(session, SessionEventBus()) + + # Should only register the notebook watcher + assert watcher_manager.add_callback.call_count == 1 + + +def test_attach_css_file_does_not_exist( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test that no CSS watcher is registered when css_file doesn't exist.""" + notebook_path = str(tmp_path / "notebook.py") + session = create_mock_session(notebook_path, css_file="missing.css") + lifecycle.on_attach(session, SessionEventBus()) + + # Should only register the notebook watcher + assert watcher_manager.add_callback.call_count == 1 + + +def test_detach_removes_css_watcher( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test that CSS watcher is cleaned up on detach.""" + css_path = tmp_path / "custom.css" + css_path.write_text("body { color: red; }") + notebook_path = str(tmp_path / "notebook.py") + + session = create_mock_session(notebook_path, css_file="custom.css") + lifecycle.on_attach(session, SessionEventBus()) + lifecycle.on_detach() + + # Should remove both notebook and CSS watchers + assert watcher_manager.remove_callback.call_count == 2 + + +async def test_css_change_sends_notification( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test that CSS file change sends UpdateCssNotification.""" + from marimo._messaging.notification import UpdateCssNotification + + css_path = tmp_path / "custom.css" + css_path.write_text("body { color: red; }") + notebook_path = str(tmp_path / "notebook.py") + + session = create_mock_session(notebook_path, css_file="custom.css") + lifecycle.on_attach(session, SessionEventBus()) + + # Get the CSS callback (second add_callback call) + css_callback = watcher_manager.add_callback.call_args_list[1][0][1] + + # Simulate CSS file change + await css_callback(css_path) + + # Verify notification was sent + session.notify.assert_called_once() + notification = session.notify.call_args[0][0] + assert isinstance(notification, UpdateCssNotification) + assert notification.css == "body { color: red; }" + + +def test_update_css_watcher_add( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test adding a CSS watcher mid-session.""" + notebook_path = str(tmp_path / "notebook.py") + session = create_mock_session(notebook_path) + lifecycle.on_attach(session, SessionEventBus()) + + # Initially no CSS watcher + assert watcher_manager.add_callback.call_count == 1 + + # Now user sets css_file + css_path = tmp_path / "custom.css" + css_path.write_text("body { color: blue; }") + session.app_file_manager.app.config.css_file = "custom.css" + + lifecycle.update_css_watcher(session) + + # Should have registered the new CSS watcher + assert watcher_manager.add_callback.call_count == 2 + css_call = watcher_manager.add_callback.call_args_list[1] + assert css_call[0][0] == css_path + + +def test_update_css_watcher_remove( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test removing a CSS watcher mid-session.""" + css_path = tmp_path / "custom.css" + css_path.write_text("body { color: red; }") + notebook_path = str(tmp_path / "notebook.py") + + session = create_mock_session(notebook_path, css_file="custom.css") + lifecycle.on_attach(session, SessionEventBus()) + + # Now user clears css_file + session.app_file_manager.app.config.css_file = None + + lifecycle.update_css_watcher(session) + + # Should have removed the old CSS watcher + assert watcher_manager.remove_callback.call_count == 1 + + +def test_update_css_watcher_change( + lifecycle: SessionFileWatcherExtension, + watcher_manager: MagicMock, + tmp_path: Path, +) -> None: + """Test changing from one CSS file to another mid-session.""" + old_css = tmp_path / "old.css" + old_css.write_text("body { color: red; }") + new_css = tmp_path / "new.css" + new_css.write_text("body { color: blue; }") + notebook_path = str(tmp_path / "notebook.py") + + session = create_mock_session(notebook_path, css_file="old.css") + lifecycle.on_attach(session, SessionEventBus()) + + # Change to new CSS file + session.app_file_manager.app.config.css_file = "new.css" + lifecycle.update_css_watcher(session) + + # Should remove old and add new + assert watcher_manager.remove_callback.call_count == 1 + # 2 from attach (notebook + old CSS) + 1 from update (new CSS) + assert watcher_manager.add_callback.call_count == 3