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/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..87b564a4 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) @@ -255,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. @@ -282,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: @@ -291,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( @@ -460,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/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. 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 4b6ab1c1..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): """ @@ -207,6 +212,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) @@ -477,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. @@ -490,6 +515,29 @@ 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. Then signal that the save actions should be + refreshed. + + 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) + self.sig_refresh_save_actions_requested.emit() + def handle_server_started(self, process): """ Handle signal that a notebook server has started.