@jupyterlab/notebook-extension 4.6.1 → 4.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,8 @@ import type { INotebookTracker } from '@jupyterlab/notebook';
10
10
  import { NotebookTools } from '@jupyterlab/notebook';
11
11
  import type { ISharedText } from '@jupyter/ydoc';
12
12
  import { PanelLayout, Widget } from '@lumino/widgets';
13
- import type { CodeCellModel, ICellModel } from '@jupyterlab/cells';
14
- import { InputPrompt } from '@jupyterlab/cells';
13
+ import type { ICellModel } from '@jupyterlab/cells';
14
+ import { InputPrompt, isCodeCellModel } from '@jupyterlab/cells';
15
15
  import { Debouncer } from '@lumino/polling';
16
16
 
17
17
  /**
@@ -49,12 +49,21 @@ namespace Private {
49
49
  *
50
50
  * ## Note
51
51
  * This field does not work as other metadata form fields, as it does not update metadata.
52
+ *
53
+ * A single instance is meant to be shared by every render of the field. The
54
+ * displayed cell follows the notebook tracker rather than the render calls, so
55
+ * that the field keeps working when the metadata form is not being rebuilt, and
56
+ * so that it does not hold on to a cell model of a closed notebook.
57
+ *
58
+ * One instance owns one node, so the field can only be mounted in one place at
59
+ * a time: `render` moves the node to the most recent mount point and leaves any
60
+ * earlier one empty. Nothing renders this field twice today.
52
61
  */
53
62
  export class ActiveCellTool extends NotebookTools.Tool {
54
63
  constructor(options: Private.IOptions) {
55
64
  super();
56
- const { languages } = options;
57
65
  this._tracker = options.tracker;
66
+ this._languages = options.languages;
58
67
 
59
68
  this.addClass(ACTIVE_CELL_TOOL_CLASS);
60
69
  this.layout = new PanelLayout();
@@ -71,51 +80,191 @@ export class ActiveCellTool extends NotebookTools.Tool {
71
80
  this._editorEl = editor;
72
81
  (this.layout as PanelLayout).addWidget(new Widget({ node }));
73
82
 
74
- const update = async () => {
75
- this._editorEl.innerHTML = '';
76
- if (this._cellModel?.type === 'code') {
77
- this._inputPrompt.executionCount = `${
78
- (this._cellModel as CodeCellModel).executionCount ?? ''
79
- }`;
80
- this._inputPrompt.show();
81
- } else {
82
- this._inputPrompt.executionCount = null;
83
- this._inputPrompt.hide();
84
- }
85
-
86
- if (this._cellModel) {
87
- await languages.highlight(
88
- this._cellModel.sharedModel.getSource().split('\n')[0],
89
- languages.findByMIME(this._cellModel.mimeType),
90
- this._editorEl
91
- );
92
- }
93
- };
94
-
95
- this._refreshDebouncer = new Debouncer(update, 150);
83
+ // Only edits to the current cell are rate-limited; switching cells updates
84
+ // the display immediately, see `_onActiveCellChanged`.
85
+ this._previewDebouncer = new Debouncer<void, void, null[]>(
86
+ () => this._update(),
87
+ 150
88
+ );
89
+
90
+ this._tracker.activeCellChanged.connect(this._onActiveCellChanged, this);
91
+ // `activeCellChanged` is not emitted when the last notebook is closed:
92
+ // NotebookTracker.onCurrentChanged returns early on a null widget. Without
93
+ // this second connection the field would keep the cell model of a closed
94
+ // notebook, and its shared model, alive for the rest of the session.
95
+ this._tracker.currentChanged.connect(this._onActiveCellChanged, this);
96
+ this._onActiveCellChanged();
97
+ }
98
+
99
+ dispose(): void {
100
+ if (this.isDisposed) {
101
+ return;
102
+ }
103
+ this._tracker.activeCellChanged.disconnect(this._onActiveCellChanged, this);
104
+ this._tracker.currentChanged.disconnect(this._onActiveCellChanged, this);
105
+ this._disconnectCellModel();
106
+ this._previewDebouncer.dispose();
107
+ super.dispose();
96
108
  }
97
109
 
98
110
  render(props: FieldProps): JSX.Element {
99
- const activeCell = this._tracker.activeCell;
100
- if (activeCell) this._cellModel = activeCell?.model || null;
101
- (this._cellModel?.sharedModel as ISharedText).changed.connect(
102
- this.refresh,
111
+ // The content is driven by the tracker; React only supplies the mount
112
+ // point, which is a different element on every rebuild of the form.
113
+ return (
114
+ <div
115
+ ref={ref => {
116
+ if (!ref || this.node.parentElement === ref) {
117
+ return;
118
+ }
119
+ ref.appendChild(this.node);
120
+ if (this._pendingUpdate) {
121
+ this._update().catch(console.warn);
122
+ }
123
+ }}
124
+ ></div>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Follow the active cell of the tracker, which is null once the last
130
+ * notebook is closed.
131
+ */
132
+ private _onActiveCellChanged(): void {
133
+ const cellModel = this._tracker.activeCell?.model ?? null;
134
+ if (cellModel === this._cellModel) {
135
+ return;
136
+ }
137
+ this._disconnectCellModel();
138
+ this._cellModel = cellModel;
139
+ if (cellModel) {
140
+ (cellModel.sharedModel as ISharedText).changed.connect(
141
+ this._onCellContentChanged,
142
+ this
143
+ );
144
+ cellModel.mimeTypeChanged.connect(this._onCellContentChanged, this);
145
+ }
146
+ // The prompt now belongs to a cell whose source line is not on screen yet,
147
+ // so leave it to `_update` to write both together.
148
+ this._promptOutdated = true;
149
+ this._update().catch(console.warn);
150
+ }
151
+
152
+ /**
153
+ * Handle a change to the current cell source, mime type or execution count.
154
+ */
155
+ private _onCellContentChanged(): void {
156
+ if (!this.node.isConnected) {
157
+ this._pendingUpdate = true;
158
+ this._promptOutdated = true;
159
+ return;
160
+ }
161
+ if (!this._promptOutdated) {
162
+ // The cell is unchanged and no switch is in flight, so writing the prompt
163
+ // now cannot pair it with the source line of a different cell.
164
+ this._updatePrompt();
165
+ }
166
+ this._previewDebouncer.invoke().catch(console.warn);
167
+ }
168
+
169
+ /**
170
+ * Stop listening to the cell model the tool is currently displaying.
171
+ */
172
+ private _disconnectCellModel(): void {
173
+ const cellModel = this._cellModel;
174
+ if (!cellModel) {
175
+ return;
176
+ }
177
+ (cellModel.sharedModel as ISharedText).changed.disconnect(
178
+ this._onCellContentChanged,
103
179
  this
104
180
  );
105
- this._cellModel?.mimeTypeChanged.connect(this.refresh, this);
106
- this.refresh()
107
- .then(() => undefined)
108
- .catch(console.warn);
109
- return <div ref={ref => ref?.appendChild(this.node)}></div>;
181
+ cellModel.mimeTypeChanged.disconnect(this._onCellContentChanged, this);
182
+ this._cellModel = null;
183
+ }
184
+
185
+ /**
186
+ * Reflect the execution count of the current cell.
187
+ */
188
+ private _updatePrompt(): void {
189
+ const cellModel = this._cellModel;
190
+ if (cellModel && isCodeCellModel(cellModel)) {
191
+ this._inputPrompt.executionCount = `${cellModel.executionCount ?? ''}`;
192
+ this._inputPrompt.show();
193
+ } else {
194
+ this._inputPrompt.executionCount = null;
195
+ this._inputPrompt.hide();
196
+ }
110
197
  }
111
198
 
112
- private async refresh(): Promise<void> {
113
- await this._refreshDebouncer.invoke();
199
+ /**
200
+ * Refresh the preview, writing an outdated prompt along with it.
201
+ *
202
+ * The prompt is written in the same task as the preview it belongs with, so
203
+ * that the field never shows the prompt of one cell above the source line of
204
+ * another while a language mode is being loaded.
205
+ */
206
+ private async _update(): Promise<void> {
207
+ if (!this.node.isConnected) {
208
+ // Nothing is on screen; catch up when the field is mounted again.
209
+ this._pendingUpdate = true;
210
+ return;
211
+ }
212
+ this._pendingUpdate = false;
213
+
214
+ const cellModel = this._cellModel;
215
+ const pending = ++this._updateId;
216
+
217
+ if (!cellModel) {
218
+ this._promptOutdated = false;
219
+ this._updatePrompt();
220
+ this._editorEl.replaceChildren();
221
+ return;
222
+ }
223
+
224
+ const source = cellModel.sharedModel.getSource();
225
+ const lineEnd = source.indexOf('\n');
226
+ const firstLine = lineEnd === -1 ? source : source.slice(0, lineEnd);
227
+
228
+ // Highlight into a detached node, so that the preview is never cleared
229
+ // while waiting for a language mode to load.
230
+ const staging = document.createElement('pre');
231
+ try {
232
+ await this._languages.highlight(
233
+ firstLine,
234
+ this._languages.findByMIME(cellModel.mimeType),
235
+ staging
236
+ );
237
+ } catch (error) {
238
+ // Fall back to unhighlighted source rather than keeping the previous
239
+ // cell's line on screen.
240
+ console.warn(error);
241
+ staging.replaceChildren(document.createTextNode(firstLine));
242
+ }
243
+
244
+ if (pending !== this._updateId) {
245
+ // A newer update started while this one was highlighting; it still owes
246
+ // the prompt write, so `_promptOutdated` is deliberately left set.
247
+ return;
248
+ }
249
+
250
+ if (this._promptOutdated) {
251
+ this._updatePrompt();
252
+ this._promptOutdated = false;
253
+ }
254
+ const fragment = document.createDocumentFragment();
255
+ while (staging.firstChild) {
256
+ fragment.appendChild(staging.firstChild);
257
+ }
258
+ this._editorEl.replaceChildren(fragment);
114
259
  }
115
260
 
116
261
  private _tracker: INotebookTracker;
117
- private _cellModel: ICellModel | null;
118
- private _refreshDebouncer: Debouncer<void, void, null[]>;
262
+ private _languages: IEditorLanguageRegistry;
263
+ private _cellModel: ICellModel | null = null;
264
+ private _previewDebouncer: Debouncer<void, void, null[]>;
265
+ private _updateId = 0;
266
+ private _pendingUpdate = false;
267
+ private _promptOutdated = false;
119
268
  private _editorEl: HTMLPreElement;
120
269
  private _inputPrompt: InputPrompt;
121
270
  }