Skip to content

Commit d7793c3

Browse files
committed
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.
1 parent a313a43 commit d7793c3

File tree

4 files changed

+122
-3
lines changed

4 files changed

+122
-3
lines changed

spyder_notebook/server/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
"@jupyterlab/markedparser-extension": "~4.4.9",
128128
"@jupyterlab/mathjax-extension": "~4.4.9",
129129
"@jupyterlab/metadataform-extension": "~4.4.9",
130+
"@jupyterlab/notebook": "~4.4.9"
130131
"@jupyterlab/notebook-extension": "~4.4.9",
131132
"@jupyterlab/pdf-extension": "~4.4.9",
132133
"@jupyterlab/services-extension": "~4.4.9",

spyder_notebook/server/packages/application-extension/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
"build": "tsc -b"
1717
},
1818
"dependencies": {
19+
"@jupyter-notebook/application": "~7.4.7",
1920
"@jupyterlab/application": "~4.4.9",
2021
"@jupyterlab/docmanager": "~4.4.9",
21-
"@jupyterlab/mainmenu": "~4.4.9"
22+
"@jupyterlab/mainmenu": "~4.4.9",
23+
"@jupyterlab/notebook": "~4.4.9"
2224
},
2325
"devDependencies": {
2426
"typescript": "~5.5.4"

spyder_notebook/server/packages/application-extension/src/index.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,22 @@ import {
1111

1212
import { IThemeManager } from '@jupyterlab/apputils';
1313

14-
import { PageConfig } from '@jupyterlab/coreutils';
14+
import {
15+
IChangedArgs,
16+
PageConfig
17+
} from '@jupyterlab/coreutils';
1518

1619
import { IDocumentManager } from '@jupyterlab/docmanager';
1720

1821
import { IMainMenu } from '@jupyterlab/mainmenu';
1922

23+
import {
24+
INotebookModel,
25+
NotebookPanel
26+
} from '@jupyterlab/notebook';
27+
28+
import { INotebookShell } from '@jupyter-notebook/application';
29+
2030
/**
2131
* A regular expression to match path to notebooks and documents
2232
*
@@ -104,13 +114,52 @@ const theme: JupyterFrontEndPlugin<void> = {
104114
}
105115
};
106116

117+
/**
118+
* Send message to Spyder if notebook becomes dirty or non-dirty
119+
*/
120+
const monitorDirty: JupyterFrontEndPlugin<void> = {
121+
id: '@spyder-notebook/application-extension:monitor-dirty',
122+
description:
123+
'Send message to Spyder if notebook becomes dirty or non-dirty.',
124+
autoStart: true,
125+
requires: [INotebookShell],
126+
activate: (
127+
app: JupyterFrontEnd,
128+
notebookShell: INotebookShell
129+
) => {
130+
const onNotebookModelStateChange = (
131+
model: INotebookModel,
132+
args: IChangedArgs<any>
133+
): void => {
134+
if (args.name == 'dirty') {
135+
alert(':SpyderComm:dirty:' + args.newValue)
136+
};
137+
};
138+
139+
const onNotebookShellChange = async () => {
140+
const current = notebookShell.currentWidget;
141+
if (!(current instanceof NotebookPanel)) {
142+
return;
143+
}
144+
145+
const notebook = current.content;
146+
await current.context.ready;
147+
148+
notebook.model?.stateChanged.connect(onNotebookModelStateChange);
149+
};
150+
151+
notebookShell.currentChanged.connect(onNotebookShellChange);
152+
},
153+
};
154+
107155
/**
108156
* Export the plugins as default.
109157
*/
110158
const plugins: JupyterFrontEndPlugin<any>[] = [
111159
menus,
112160
opener,
113-
theme
161+
theme,
162+
monitorDirty
114163
];
115164

116165
export default plugins;

spyder_notebook/widgets/client.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from spyder.utils.image_path_manager import get_image_path
3131
from spyder.utils.qthelpers import add_actions
3232
from spyder.utils.palette import SpyderPalette
33+
from spyder.widgets.browser import WebPage
3334
from spyder.widgets.findreplace import FindReplace
3435

3536
# Local imports
@@ -90,6 +91,43 @@ def open_in_browser(self, url):
9091
self.close()
9192

9293

94+
class NotebookWebPage(WebPage):
95+
"""
96+
Object to view and edit notebooks rendered as web pages.
97+
98+
Spyder notebooks communicate with Spyder itself with the JavaScript
99+
alert() function where the message starts with a special prefix.
100+
This class raises a signal if such a message is received.
101+
"""
102+
103+
SPYDER_COMM_PREFIX = ':SpyderComm:'
104+
"""
105+
Prefix for alert() messages used to communicate with Spyder.
106+
"""
107+
108+
sig_message_received = Signal(str)
109+
"""
110+
This signal is emitted when a Spyder comms message is received.
111+
"""
112+
113+
def javaScriptAlert(self, securityOrigin, msg: str) -> None:
114+
"""
115+
Called whenever the JavaScript function alert() is called.
116+
117+
If the message starts with `SPYDER_COMM_PREFIX`, then this is a
118+
message to communicate to Spyder so emit `sig_message_received`.
119+
Otherwise, this is a standard JavaScript alert() to communicate to
120+
the user, so let the base class handle it.
121+
122+
Overloads the function in QWebEnginePage.
123+
"""
124+
if msg.startswith(self.SPYDER_COMM_PREFIX):
125+
msg = msg.removeprefix(self.SPYDER_COMM_PREFIX)
126+
self.sig_message_received.emit(msg)
127+
else:
128+
super().javaScriptAlert(securityOrigin, msg)
129+
130+
93131
class NotebookWidget(DOMWidget):
94132
"""WebView widget for notebooks."""
95133

@@ -103,6 +141,16 @@ class NotebookWidget(DOMWidget):
103141
This signal is emitted when the widget loses focus.
104142
"""
105143

144+
sig_dirty_changed = Signal(bool)
145+
"""
146+
This signal is emitted when the notebook becomes dirty or non-dirty.
147+
148+
Parameters
149+
----------
150+
new_value : bool
151+
Whether the notebook is now dirty.
152+
"""
153+
106154
def __init__(self, parent, actions=None):
107155
"""
108156
Constructor.
@@ -117,6 +165,12 @@ def __init__(self, parent, actions=None):
117165
will be added.
118166
"""
119167
super().__init__(parent)
168+
169+
# Use our subclass of QtWebEnginePage to view notebooks
170+
web_page = NotebookWebPage(self)
171+
web_page.sig_message_received.connect(self.on_message_received)
172+
self.setPage(web_page)
173+
120174
self.CONTEXT_NAME = str(id(self))
121175
self.setup()
122176
self.actions = actions
@@ -176,6 +230,19 @@ def _set_info(self, html):
176230
"""Set informational html with css from local path."""
177231
self.setHtml(html, QUrl.fromLocalFile(self.css_path))
178232

233+
def on_message_received(self, msg: str) -> None:
234+
"""
235+
Handle messages from notebooks communicated with alert().
236+
237+
The only message implemented at the moment indicates that a notebook
238+
has become dirty or non-dirty.
239+
"""
240+
msg_class, msg_args = msg.split(':', 2)
241+
if msg_class == 'dirty':
242+
self.sig_dirty_changed.emit(msg_args == 'true')
243+
else:
244+
logger.warning(f'Unknown message class from notebook, {msg = }')
245+
179246
def show_blank(self):
180247
"""Show a blank page."""
181248
blank_template = Template(BLANK)

0 commit comments

Comments
 (0)