From 5b84f3f2b27027aa865a0dc44d778f626be5f2e2 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Sun, 26 Oct 2025 20:11:03 +0000 Subject: [PATCH 1/3] Let Spyder know when notebook becomes dirty Connect to the JavaScript signal emitted when a notebook becomes dirty or non-dirty. On receiving this signal, call alert() with a specially crafted message. Monitor alerts in Spyder and on receiving such an alert, emit a Qt signal. Using alert() as a side channel to communicate messages from JavaScript to Spyder is perhaps a bit hacky but it is simple and it works (as long as the alert message does not occur naturally). I expect that this mechanism is more widely applicable. --- spyder_notebook/server/app/package.json | 1 + .../application-extension/package.json | 4 +- .../application-extension/src/index.ts | 53 ++++++++++++++- spyder_notebook/server/yarn.lock | 3 + spyder_notebook/widgets/client.py | 67 +++++++++++++++++++ spyder_notebook/widgets/dom.py | 21 ++++++ 6 files changed, 146 insertions(+), 3 deletions(-) diff --git a/spyder_notebook/server/app/package.json b/spyder_notebook/server/app/package.json index 3aaf7a7a..a3ba4cab 100644 --- a/spyder_notebook/server/app/package.json +++ b/spyder_notebook/server/app/package.json @@ -127,6 +127,7 @@ "@jupyterlab/markedparser-extension": "~4.4.9", "@jupyterlab/mathjax-extension": "~4.4.9", "@jupyterlab/metadataform-extension": "~4.4.9", + "@jupyterlab/notebook": "~4.4.9", "@jupyterlab/notebook-extension": "~4.4.9", "@jupyterlab/pdf-extension": "~4.4.9", "@jupyterlab/services-extension": "~4.4.9", diff --git a/spyder_notebook/server/packages/application-extension/package.json b/spyder_notebook/server/packages/application-extension/package.json index 8f91f1f4..2f39be72 100644 --- a/spyder_notebook/server/packages/application-extension/package.json +++ b/spyder_notebook/server/packages/application-extension/package.json @@ -16,9 +16,11 @@ "build": "tsc -b" }, "dependencies": { + "@jupyter-notebook/application": "~7.4.7", "@jupyterlab/application": "~4.4.9", "@jupyterlab/docmanager": "~4.4.9", - "@jupyterlab/mainmenu": "~4.4.9" + "@jupyterlab/mainmenu": "~4.4.9", + "@jupyterlab/notebook": "~4.4.9" }, "devDependencies": { "typescript": "~5.5.4" diff --git a/spyder_notebook/server/packages/application-extension/src/index.ts b/spyder_notebook/server/packages/application-extension/src/index.ts index ac6b05b8..84856d89 100644 --- a/spyder_notebook/server/packages/application-extension/src/index.ts +++ b/spyder_notebook/server/packages/application-extension/src/index.ts @@ -11,12 +11,22 @@ import { import { IThemeManager } from '@jupyterlab/apputils'; -import { PageConfig } from '@jupyterlab/coreutils'; +import { + IChangedArgs, + PageConfig +} from '@jupyterlab/coreutils'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { IMainMenu } from '@jupyterlab/mainmenu'; +import { + INotebookModel, + NotebookPanel +} from '@jupyterlab/notebook'; + +import { INotebookShell } from '@jupyter-notebook/application'; + /** * A regular expression to match path to notebooks and documents * @@ -104,13 +114,52 @@ const theme: JupyterFrontEndPlugin = { } }; +/** + * Send message to Spyder if notebook becomes dirty or non-dirty + */ +const monitorDirty: JupyterFrontEndPlugin = { + id: '@spyder-notebook/application-extension:monitor-dirty', + description: + 'Send message to Spyder if notebook becomes dirty or non-dirty.', + autoStart: true, + requires: [INotebookShell], + activate: ( + app: JupyterFrontEnd, + notebookShell: INotebookShell + ) => { + const onNotebookModelStateChange = ( + model: INotebookModel, + args: IChangedArgs + ): void => { + if (args.name == 'dirty') { + alert(':SpyderComm:dirty:' + args.newValue) + }; + }; + + const onNotebookShellChange = async () => { + const current = notebookShell.currentWidget; + if (!(current instanceof NotebookPanel)) { + return; + } + + const notebook = current.content; + await current.context.ready; + + notebook.model?.stateChanged.connect(onNotebookModelStateChange); + }; + + notebookShell.currentChanged.connect(onNotebookShellChange); + }, +}; + /** * Export the plugins as default. */ const plugins: JupyterFrontEndPlugin[] = [ menus, opener, - theme + theme, + monitorDirty ]; export default plugins; diff --git a/spyder_notebook/server/yarn.lock b/spyder_notebook/server/yarn.lock index 526846a3..0258f285 100644 --- a/spyder_notebook/server/yarn.lock +++ b/spyder_notebook/server/yarn.lock @@ -3280,6 +3280,7 @@ __metadata: "@jupyterlab/markedparser-extension": ~4.4.9 "@jupyterlab/mathjax-extension": ~4.4.9 "@jupyterlab/metadataform-extension": ~4.4.9 + "@jupyterlab/notebook": ~4.4.9 "@jupyterlab/notebook-extension": ~4.4.9 "@jupyterlab/pdf-extension": ~4.4.9 "@jupyterlab/services-extension": ~4.4.9 @@ -3315,9 +3316,11 @@ __metadata: version: 0.0.0-use.local resolution: "@spyder-notebook/application-extension@workspace:packages/application-extension" dependencies: + "@jupyter-notebook/application": ~7.4.7 "@jupyterlab/application": ~4.4.9 "@jupyterlab/docmanager": ~4.4.9 "@jupyterlab/mainmenu": ~4.4.9 + "@jupyterlab/notebook": ~4.4.9 typescript: ~5.5.4 languageName: unknown linkType: soft diff --git a/spyder_notebook/widgets/client.py b/spyder_notebook/widgets/client.py index 9ea838ca..1ff315ee 100644 --- a/spyder_notebook/widgets/client.py +++ b/spyder_notebook/widgets/client.py @@ -30,6 +30,7 @@ from spyder.utils.image_path_manager import get_image_path from spyder.utils.qthelpers import add_actions from spyder.utils.palette import SpyderPalette +from spyder.widgets.browser import WebPage from spyder.widgets.findreplace import FindReplace # Local imports @@ -90,6 +91,43 @@ def open_in_browser(self, url): self.close() +class NotebookWebPage(WebPage): + """ + Object to view and edit notebooks rendered as web pages. + + Spyder notebooks communicate with Spyder itself with the JavaScript + alert() function where the message starts with a special prefix. + This class raises a signal if such a message is received. + """ + + SPYDER_COMM_PREFIX = ':SpyderComm:' + """ + Prefix for alert() messages used to communicate with Spyder. + """ + + sig_message_received = Signal(str) + """ + This signal is emitted when a Spyder comms message is received. + """ + + def javaScriptAlert(self, securityOrigin, msg: str) -> None: + """ + Called whenever the JavaScript function alert() is called. + + If the message starts with `SPYDER_COMM_PREFIX`, then this is a + message to communicate to Spyder so emit `sig_message_received`. + Otherwise, this is a standard JavaScript alert() to communicate to + the user, so let the base class handle it. + + Overloads the function in QWebEnginePage. + """ + if msg.startswith(self.SPYDER_COMM_PREFIX): + msg = msg.removeprefix(self.SPYDER_COMM_PREFIX) + self.sig_message_received.emit(msg) + else: + super().javaScriptAlert(securityOrigin, msg) + + class NotebookWidget(DOMWidget): """WebView widget for notebooks.""" @@ -103,6 +141,16 @@ class NotebookWidget(DOMWidget): This signal is emitted when the widget loses focus. """ + sig_dirty_changed = Signal(bool) + """ + This signal is emitted when the notebook becomes dirty or non-dirty. + + Parameters + ---------- + new_value : bool + Whether the notebook is now dirty. + """ + def __init__(self, parent, actions=None): """ Constructor. @@ -117,6 +165,12 @@ def __init__(self, parent, actions=None): will be added. """ super().__init__(parent) + + # Use our subclass of QtWebEnginePage to view notebooks + web_page = NotebookWebPage(self) + web_page.sig_message_received.connect(self.on_message_received) + self.setPage(web_page) + self.CONTEXT_NAME = str(id(self)) self.setup() self.actions = actions @@ -176,6 +230,19 @@ def _set_info(self, html): """Set informational html with css from local path.""" self.setHtml(html, QUrl.fromLocalFile(self.css_path)) + def on_message_received(self, msg: str) -> None: + """ + Handle messages from notebooks communicated with alert(). + + The only message implemented at the moment indicates that a notebook + has become dirty or non-dirty. + """ + msg_class, msg_args = msg.split(':', 2) + if msg_class == 'dirty': + self.sig_dirty_changed.emit(msg_args == 'true') + else: + logger.warning(f'Unknown message class from notebook, {msg = }') + def show_blank(self): """Show a blank page.""" blank_template = Template(BLANK) diff --git a/spyder_notebook/widgets/dom.py b/spyder_notebook/widgets/dom.py index 9b706736..db58d096 100644 --- a/spyder_notebook/widgets/dom.py +++ b/spyder_notebook/widgets/dom.py @@ -25,6 +25,27 @@ def __init__(self, parent): else: self.dom = self.page().mainFrame() + def setPage(self, page): + """ + Change the web page of the web view. + + Overrides the function in QWebEngineView in order to update self.dom. + + Parameters + ---------- + page : QWebEnginePage + The new web page. + + Returns + ------- + None. + """ + super().setPage(page) + if WEBENGINE: + self.dom = self.page() + else: + self.dom = self.page().mainFrame() + def evaluate(self, script): """ Evaluate script in page frame. From b5f4cb7ba53bd43f7a32062787e0fc8e0d4e6d33 Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Tue, 4 Nov 2025 21:53:54 +0000 Subject: [PATCH 2/3] Indicate dirty notebooks with * in tab If a notebook is dirty (meaning that it has been changed since the last save), then add an asterix `*` to the file name in the title of the tab. This mirrors the behaviour of the editor. --- spyder_notebook/widgets/client.py | 21 +++++++++++++++++++ spyder_notebook/widgets/notebooktabwidget.py | 22 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/spyder_notebook/widgets/client.py b/spyder_notebook/widgets/client.py index 1ff315ee..87b564a4 100644 --- a/spyder_notebook/widgets/client.py +++ b/spyder_notebook/widgets/client.py @@ -322,6 +322,16 @@ class NotebookClient(QFrame): CONF_SECTION = CONF_SECTION + sig_dirty_changed = Signal(bool) + """ + This signal is emitted when the notebook becomes dirty or non-dirty. + + Parameters + ---------- + new_value : bool + Whether the notebook is now dirty. + """ + def __init__(self, parent, filename, actions=None, ini_message=None): """ Constructor. @@ -349,6 +359,7 @@ def __init__(self, parent, filename, actions=None, ini_message=None): self.file_url = None self.server_url = None self.path = None + self.dirty = False self.notebookwidget = NotebookWidget(self, actions) if ini_message: @@ -358,6 +369,8 @@ def __init__(self, parent, filename, actions=None, ini_message=None): self.notebookwidget.show_blank() self.static = False + self.notebookwidget.sig_dirty_changed.connect( + self._handle_dirty_changed) self.notebookwidget.sig_focus_in_event.connect( lambda: self._apply_stylesheet(focus=True)) self.notebookwidget.sig_focus_out_event.connect( @@ -527,6 +540,14 @@ def _apply_stylesheet(self, focus=False): self.setStyleSheet(css.toString()) + def _handle_dirty_changed(self, new_value: bool) -> None: + """ + Handle signal that a notebook became dirty or not. + + Store the new value and emit the signal again. + """ + self.dirty = new_value + self.sig_dirty_changed.emit(new_value) # ----------------------------------------------------------------------------- # Tests diff --git a/spyder_notebook/widgets/notebooktabwidget.py b/spyder_notebook/widgets/notebooktabwidget.py index 4b6ab1c1..b6e6d30c 100755 --- a/spyder_notebook/widgets/notebooktabwidget.py +++ b/spyder_notebook/widgets/notebooktabwidget.py @@ -207,6 +207,7 @@ def create_new_client(self, filename=None): client = NotebookClient(self, filename, self.actions) self.add_tab(client) + client.sig_dirty_changed.connect(self.handle_dirty_changed) interpreter = self.get_interpreter() server_info = self.server_manager.get_server( filename, interpreter, start=True) @@ -490,6 +491,27 @@ def add_tab(self, widget): self.setCurrentIndex(index) self.setTabToolTip(index, widget.get_filename()) + def handle_dirty_changed(self, new_value: bool) -> None: + """ + Handle signal that a notebook became dirty or not. + + Append a `*` to the filename of the notebook in the tab title if the + notebook is dirty. + + Parameters + ---------- + new_value : bool + Whether the notebook is now dirty. + """ + notebook_client = self.sender() + index = self.indexOf(notebook_client) + if index == -1: + logger.warn('handle_dirty_changed: Client not found!') + else: + suffix = '*' if new_value else '' + self.setTabText(index, notebook_client.get_short_name() + suffix) + self.setTabToolTip(index, notebook_client.get_filename() + suffix) + def handle_server_started(self, process): """ Handle signal that a notebook server has started. From 780f034a4fa06bdae6c6e7f3bb4e4ae1dc96096b Mon Sep 17 00:00:00 2001 From: Jitse Niesen Date: Tue, 4 Nov 2025 22:12:22 +0000 Subject: [PATCH 3/3] Enable Save action only if notebook is dirty Enable the File > Save menu item and the Save button in the Spyder toolbar only if there the current notebook is dirty (i.e., if there is something to save). Enable the Save All action only if any notebook is dirty. If the user triggers this action, then only save notebooks that are dirty. --- spyder_notebook/notebookplugin.py | 27 +++++++++++++++ spyder_notebook/widgets/main_widget.py | 36 +++++++++++++++++++- spyder_notebook/widgets/notebooktabwidget.py | 30 ++++++++++++++-- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/spyder_notebook/notebookplugin.py b/spyder_notebook/notebookplugin.py index 70e1c9f9..4dd5cb5f 100644 --- a/spyder_notebook/notebookplugin.py +++ b/spyder_notebook/notebookplugin.py @@ -84,6 +84,7 @@ def on_application_available(self) -> None: ) widget = self.get_widget() widget.sig_new_recent_file.connect(application.add_recent_file) + widget.sig_enable_save_requested.connect(self._enable_save_actions) @on_plugin_available(plugin=Plugins.Preferences) def on_preferences_available(self): @@ -106,6 +107,7 @@ def on_application_teardown(self) -> None: application = self.get_plugin(Plugins.Application) widget = self.get_widget() widget.sig_new_recent_file.disconnect(application.add_recent_file) + widget.sig_file_action_enabled.connect(self._enable_file_action) @on_plugin_teardown(plugin=Plugins.Preferences) def on_preferences_teardown(self): @@ -261,3 +263,28 @@ def _handle_switcher_selection(self, item, mode, search_text): self.switch_to_plugin() switcher = self.get_plugin(Plugins.Switcher) switcher.hide() + + def _enable_save_actions( + self, + save_enabled: bool, + save_all_enabled: bool + ) -> None: + """ + Enable or disable file action for this plugin. + """ + # Moving this import to the top of the file interferes with the + # async loop in Jupyter + from spyder.plugins.application.api import ApplicationActions + + application = self.get_plugin(Plugins.Application, error=False) + if application: + application.enable_file_action( + ApplicationActions.SaveFile, + save_enabled, + self.NAME + ) + application.enable_file_action( + ApplicationActions.SaveAll, + save_all_enabled, + self.NAME + ) diff --git a/spyder_notebook/widgets/main_widget.py b/spyder_notebook/widgets/main_widget.py index df8366aa..b04899bd 100644 --- a/spyder_notebook/widgets/main_widget.py +++ b/spyder_notebook/widgets/main_widget.py @@ -54,6 +54,19 @@ class NotebookMainWidgetRecentNotebooksMenuSections: class NotebookMainWidget(PluginMainWidget): + sig_enable_save_requested = Signal(bool, bool) + """ + Request to enable or disable the Save and Save All actions. + + Parameters + ---------- + save_enabled: bool + True if the Save action should be enabled, False if it should disabled. + save_all_enabled: bool + True if the Save All action should be enabled, False if it should + disabled. + """ + sig_new_recent_file = Signal(str) """ This signal is emitted when a file is opened or got a new name. @@ -90,6 +103,9 @@ def __init__(self, name, plugin, parent): dark_theme=self.dark_theme ) self.tabwidget.currentChanged.connect(self.refresh_plugin) + self.tabwidget.sig_refresh_save_actions_requested.connect( + self.refresh_save_actions + ) # Widget layout layout = QVBoxLayout() @@ -223,6 +239,7 @@ def open_previous_session(self): self.tabwidget.maybe_create_welcome_client() self.create_new_client() self.tabwidget.setCurrentIndex(0) # bring welcome tab to top + self.refresh_save_actions() def open_notebook(self, filenames=None): """Open a notebook from file.""" @@ -255,7 +272,8 @@ def save_all(self) -> None: """ for client_index in range(self.tabwidget.count()): client = self.tabwidget.widget(client_index) - self.tabwidget.save_notebook(client) + if self.tabwidget.can_save_client(client): + self.tabwidget.save_notebook(client) def save_as(self, close_after_save=True): """ @@ -272,6 +290,22 @@ def save_as(self, close_after_save=True): if old_filename != new_filename: self.sig_new_recent_file.emit(new_filename) + def refresh_save_actions(self): + """ + Enable or disable 'Save' and 'Save All' actions. + + The 'Save' action is enabled if the current notebook can be saved. + The 'Save all' action is enabled if any notebook can be saved. + """ + current_index = self.tabwidget.currentIndex() + current_client = self.tabwidget.widget(current_index) + save_enabled = self.tabwidget.can_save_client(current_client) + save_all_enabled = any( + self.tabwidget.can_save_client(self.tabwidget.widget(index)) + for index in range(self.tabwidget.count()) + ) + self.sig_enable_save_requested.emit(save_enabled, save_all_enabled) + def close_notebook(self) -> None: """ Close current notebook. diff --git a/spyder_notebook/widgets/notebooktabwidget.py b/spyder_notebook/widgets/notebooktabwidget.py index b6e6d30c..367562dd 100755 --- a/spyder_notebook/widgets/notebooktabwidget.py +++ b/spyder_notebook/widgets/notebooktabwidget.py @@ -14,7 +14,7 @@ # Qt imports from qtpy.compat import getopenfilenames, getsavefilename -from qtpy.QtCore import QEventLoop, QTimer +from qtpy.QtCore import QEventLoop, QTimer, Signal from qtpy.QtWidgets import QMessageBox # Third-party imports @@ -103,6 +103,11 @@ class NotebookTabWidget(Tabs, SpyderConfigurationAccessor): most recently closed one listed last. """ + sig_refresh_save_actions_requested = Signal() + """ + This signal is emitted when the save actions should be refreshed. + """ + def __init__(self, parent, server_manager, actions=None, menu=None, corner_widgets=None, dark_theme=False): """ @@ -478,6 +483,25 @@ def is_welcome_client(client): """ return client.get_filename() in [WELCOME, WELCOME_DARK] + @classmethod + def can_save_client(cls, client: NotebookClient) -> bool: + """ + Return whether some client can be saved. + + A client can be saved if it contains a notebook (i.e., it is not a + welcome client) and it is dirty. + + Parameters + ---------- + client : NotebookClient + Client under consideration. + + Returns + ------- + True if `client` can be saved, False otherwise. + """ + return not cls.is_welcome_client(client) and client.dirty + def add_tab(self, widget): """ Add tab containing some notebook widget to the tabbed widget. @@ -496,7 +520,8 @@ def handle_dirty_changed(self, new_value: bool) -> None: Handle signal that a notebook became dirty or not. Append a `*` to the filename of the notebook in the tab title if the - notebook is dirty. + notebook is dirty. Then signal that the save actions should be + refreshed. Parameters ---------- @@ -511,6 +536,7 @@ def handle_dirty_changed(self, new_value: bool) -> None: suffix = '*' if new_value else '' self.setTabText(index, notebook_client.get_short_name() + suffix) self.setTabToolTip(index, notebook_client.get_filename() + suffix) + self.sig_refresh_save_actions_requested.emit() def handle_server_started(self, process): """