Skip to content

Commit 8c0966d

Browse files
committed
handle OOB edits in notebooks
1 parent 2ff5322 commit 8c0966d

File tree

8 files changed

+183
-48
lines changed

8 files changed

+183
-48
lines changed

src/docprovider/custom_ydocs.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
2-
YFile as DefaultYFile
3-
// YNotebook as DefaultYNotebook
2+
YFile as DefaultYFile,
3+
YNotebook as DefaultYNotebook,
4+
ISharedNotebook
45
} from '@jupyter/ydoc';
56
import * as Y from 'yjs';
67
import { Awareness } from 'y-protocols/awareness';
@@ -34,14 +35,14 @@ export class YFile extends DefaultYFile {
3435
(this as any)._ydoc = new Y.Doc();
3536

3637
// Reset all properties derived from `this._ydoc`
37-
(this as any).ysource = (this as any)._ydoc.getText('source');
38-
(this as any)._ystate = (this as any)._ydoc.getMap('state');
38+
(this as any).ysource = this.ydoc.getText('source');
39+
(this as any)._ystate = this.ydoc.getMap('state');
3940
(this as any)._undoManager = new Y.UndoManager([], {
4041
trackedOrigins: new Set([this]),
4142
doc: (this as any)._ydoc
4243
});
4344
(this as any)._undoManager.addToScope(this.ysource);
44-
(this as any)._awareness = new Awareness((this as any)._ydoc);
45+
(this as any)._awareness = new Awareness(this.ydoc);
4546

4647
// Emit to `this.resetSignal` to inform consumers immediately
4748
this._resetSignal.emit(null);
@@ -64,3 +65,52 @@ export class YFile extends DefaultYFile {
6465

6566
_resetSignal: Signal<this, null>;
6667
}
68+
69+
export class YNotebook extends DefaultYNotebook {
70+
constructor(options?: Omit<ISharedNotebook.IOptions, 'data'>) {
71+
super(options);
72+
this._resetSignal = new Signal(this);
73+
}
74+
75+
/**
76+
* See `YFile.reset()`.
77+
*/
78+
reset() {
79+
// Remove default observers
80+
this._ycells.unobserve((this as any)._onYCellsChanged);
81+
this.ymeta.unobserveDeep((this as any)._onMetaChanged);
82+
(this as any)._ystate.unobserve(this.onStateChanged);
83+
84+
// Reset `this._ydoc` to an empty state
85+
(this as any)._ydoc = new Y.Doc();
86+
87+
// Reset all properties derived from `this._ydoc`
88+
(this as any)._ystate = this.ydoc.getMap('state');
89+
(this as any)._ycells = this.ydoc.getArray('cells');
90+
(this as any).cells = [];
91+
(this as any).ymeta = this.ydoc.getMap('meta');
92+
(this as any)._undoManager = new Y.UndoManager([], {
93+
trackedOrigins: new Set([this]),
94+
doc: (this as any)._ydoc
95+
});
96+
(this as any)._undoManager.addToScope(this._ycells);
97+
(this as any)._awareness = new Awareness(this.ydoc);
98+
99+
// Emit to `this.resetSignal` to inform consumers immediately
100+
this._resetSignal.emit(null);
101+
102+
// Add back default observers
103+
this._ycells.observe((this as any)._onYCellsChanged);
104+
this.ymeta.observeDeep((this as any)._onMetaChanged);
105+
(this as any)._ystate.observe(this.onStateChanged);
106+
}
107+
108+
/**
109+
* See `YFile.resetSignal`.
110+
*/
111+
get resetSignal(): ISignal<this, null> {
112+
return this._resetSignal;
113+
}
114+
115+
_resetSignal: Signal<this, null>;
116+
}

src/docprovider/filebrowser.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ import {
2525
import { ISettingRegistry } from '@jupyterlab/settingregistry';
2626
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
2727

28-
import { YNotebook } from '@jupyter/ydoc';
29-
import { YFile } from './custom_ydocs';
28+
import { YFile, YNotebook } from './custom_ydocs';
3029

3130
import {
3231
ICollaborativeContentProvider,

src/docprovider/yprovider.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { DocumentChange, YDocument } from '@jupyter/ydoc';
1616
import { Awareness } from 'y-protocols/awareness';
1717
import { WebsocketProvider as YWebsocketProvider } from 'y-websocket';
1818
import { requestAPI } from './requests';
19-
import { YFile } from './custom_ydocs';
19+
import { YFile, YNotebook } from './custom_ydocs';
2020

2121
/**
2222
* A class to provide Yjs synchronization over WebSocket.
@@ -146,7 +146,7 @@ export class WebSocketProvider implements IDocumentProvider {
146146
const close_code = event.code;
147147

148148
// 4000 := server close code on out-of-band change
149-
if (close_code === 4000 && this._sharedModel instanceof YFile) {
149+
if (close_code === 4000) {
150150
this._handleOobChange();
151151
return;
152152
}
@@ -172,9 +172,8 @@ export class WebSocketProvider implements IDocumentProvider {
172172
*/
173173
private _handleOobChange() {
174174
// Reset YDoc
175-
// TODO: handle YNotebooks.
176175
// TODO: is it safe to assume that we only need YFile & YNotebook?
177-
const sharedModel = this._sharedModel as YFile;
176+
const sharedModel = this._sharedModel as YFile | YNotebook;
178177
sharedModel.reset();
179178

180179
// Re-connect and display a notification to the user

src/index.ts

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,7 @@ import { KeyboardEvent } from 'react';
2525
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
2626
import { AwarenessExecutionIndicator } from './executionindicator';
2727

28-
import { IEditorServices } from '@jupyterlab/codeeditor';
2928
import { requestAPI } from './handler';
30-
import { RtcNotebookContentFactory } from './notebook';
3129

3230
import { rtcContentProvider, yfile, ynotebook, logger } from './docprovider';
3331

@@ -43,6 +41,7 @@ import { URLExt } from '@jupyterlab/coreutils';
4341
import { AwarenessKernelStatus } from './kernelstatus';
4442

4543
import { codemirrorYjsPlugin } from './codemirror-binding/plugin';
44+
import { notebookFactoryPlugin } from './notebook-factory';
4645

4746
/**
4847
* Initialization data for the @jupyter/server-documents extension.
@@ -62,7 +61,10 @@ export const plugin: JupyterFrontEndPlugin<void> = {
6261
settingRegistry
6362
.load(plugin.id)
6463
.then(settings => {
65-
console.log('@jupyter/server-documents settings loaded:', settings.composite);
64+
console.log(
65+
'@jupyter/server-documents settings loaded:',
66+
settings.composite
67+
);
6668
})
6769
.catch(reason => {
6870
console.error(
@@ -279,21 +281,6 @@ export const kernelStatus: JupyterFrontEndPlugin<IKernelStatusModel> = {
279281
}
280282
};
281283

282-
/**
283-
* The notebook cell factory provider.
284-
*/
285-
const factory: JupyterFrontEndPlugin<NotebookPanel.IContentFactory> = {
286-
id: '@jupyter/server-documents/notebook-extension:factory',
287-
description: 'Provides the notebook cell factory.',
288-
provides: NotebookPanel.IContentFactory,
289-
requires: [IEditorServices],
290-
autoStart: true,
291-
activate: (app: JupyterFrontEnd, editorServices: IEditorServices) => {
292-
const editorFactory = editorServices.factoryService.newInlineEditor;
293-
return new RtcNotebookContentFactory({ editorFactory });
294-
}
295-
};
296-
297284
const plugins: JupyterFrontEndPlugin<unknown>[] = [
298285
rtcContentProvider,
299286
yfile,
@@ -303,7 +290,7 @@ const plugins: JupyterFrontEndPlugin<unknown>[] = [
303290
plugin,
304291
executionIndicator,
305292
kernelStatus,
306-
factory,
293+
notebookFactoryPlugin,
307294
codemirrorYjsPlugin
308295
];
309296

src/notebook-factory/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './notebook-factory';
2+
export * from './plugin';

src/notebook.ts renamed to src/notebook-factory/notebook-factory.ts

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
import { CodeCell, CodeCellModel, ICellModel, ICodeCellModel } from '@jupyterlab/cells';
2-
import { NotebookPanel } from '@jupyterlab/notebook';
1+
import {
2+
CodeCell,
3+
CodeCellModel,
4+
ICellModel,
5+
ICodeCellModel
6+
} from '@jupyterlab/cells';
7+
import { IChangedArgs } from '@jupyterlab/coreutils';
8+
import { Notebook, NotebookPanel } from '@jupyterlab/notebook';
39
import { CellChange, createMutex, ISharedCodeCell } from '@jupyter/ydoc';
410
import { IOutputAreaModel, OutputAreaModel } from '@jupyterlab/outputarea';
5-
import { IChangedArgs } from '@jupyterlab/coreutils';
6-
import { requestAPI } from './handler';
11+
import { requestAPI } from '../handler';
12+
import { ResettableNotebook } from './notebook';
713

814
const globalModelDBMutex = createMutex();
915

@@ -12,8 +18,6 @@ const globalModelDBMutex = createMutex();
1218
*/
1319
const DIRTY_CLASS = 'jp-mod-dirty';
1420

15-
16-
1721
(CodeCellModel.prototype as any)._onSharedModelChanged = function (
1822
slot: ISharedCodeCell,
1923
change: CellChange
@@ -142,26 +146,25 @@ class RtcOutputAreaModel extends OutputAreaModel implements IOutputAreaModel {
142146
}
143147
}
144148

145-
/**
146-
* NOTE: We should upstream this fix. This is a bug in JupyterLab.
147-
*
149+
/**
150+
* NOTE: We should upstream this fix. This is a bug in JupyterLab.
151+
*
148152
* The execution count comes back from the kernel immediately
149153
* when the execute request is made by the client, even thought
150154
* cell might still be running. JupyterLab holds this value in
151-
* memory with a Promise to set it later, once the execution
152-
* state goes back to Idle.
153-
*
155+
* memory with a Promise to set it later, once the execution
156+
* state goes back to Idle.
157+
*
154158
* In CRDT world, we don't need to do this gymnastics, holding
155-
* the state in a Promise. Instead, we can just watch the
159+
* the state in a Promise. Instead, we can just watch the
156160
* executionState and executionCount in the CRDT being maintained
157161
* by the server-side model.
158-
*
162+
*
159163
* This is a big win! It means user can close and re-open a
160-
* notebook while a list of executed cells are queued.
164+
* notebook while a list of executed cells are queued.
161165
*/
162166
(CodeCell.prototype as any).onStateChanged = function (
163-
164-
model: ICellModel,
167+
model: ICellModel,
165168
args: IChangedArgs<any>
166169
): void {
167170
switch (args.name) {
@@ -188,7 +191,7 @@ class RtcOutputAreaModel extends OutputAreaModel implements IOutputAreaModel {
188191
default:
189192
break;
190193
}
191-
}
194+
};
192195

193196
CodeCellModel.ContentFactory.prototype.createOutputArea = function (
194197
options: IOutputAreaModel.IOptions
@@ -203,4 +206,8 @@ export class RtcNotebookContentFactory
203206
createCodeCell(options: CodeCell.IOptions): CodeCell {
204207
return new CodeCell(options).initializeState();
205208
}
209+
210+
createNotebook(options: Notebook.IOptions): Notebook {
211+
return new ResettableNotebook(options);
212+
}
206213
}

src/notebook-factory/notebook.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { INotebookModel, Notebook, NotebookModel } from '@jupyterlab/notebook';
2+
import { YNotebook } from '../docprovider/custom_ydocs';
3+
4+
/**
5+
* A custom implementation of `Notebook` that resets the notebook to an empty
6+
* state when `YNotebook.resetSignal` is emitted to.
7+
*
8+
* This requires the custom `YNotebook` class defined by this labextension.
9+
*/
10+
export class ResettableNotebook extends Notebook {
11+
constructor(options: Notebook.IOptions) {
12+
super(options);
13+
this._resetSignalSlot = () => this._onReset();
14+
}
15+
16+
get model(): INotebookModel | null {
17+
return super.model;
18+
}
19+
20+
set model(newValue: INotebookModel | null) {
21+
// if current model exists, remove the `resetSignal` observer
22+
if (this.model) {
23+
const ynotebook = this.model.sharedModel as YNotebook;
24+
ynotebook.resetSignal.disconnect(this._resetSignalSlot);
25+
}
26+
27+
// call parent property setter
28+
super.model = newValue;
29+
30+
// return early if `newValue === null`
31+
if (!newValue) {
32+
return;
33+
}
34+
35+
// otherwise, listen to `YNotebook.resetSignal`.
36+
const ynotebook = newValue.sharedModel as YNotebook;
37+
ynotebook.resetSignal.connect(this._resetSignalSlot);
38+
}
39+
40+
/**
41+
* Function called when the YDoc has been reset. This recreates the notebook
42+
* model using this model's options.
43+
*
44+
* TODO (?): we may want to use NotebookModelFactory, but that factory only
45+
* seems to set some configuration options. The NotebookModel constructor
46+
* does not require any arguments so this is OK for now.
47+
*/
48+
_onReset() {
49+
if (!this.model) {
50+
console.warn(
51+
'The notebook was reset without a model. This should never happen.'
52+
);
53+
return;
54+
}
55+
56+
this.model = new NotebookModel({
57+
collaborationEnabled: this.model.collaborative,
58+
sharedModel: this.model.sharedModel
59+
// other options in `NotebookModel.IOptions` are either unused or
60+
// forwarded to `YNotebook`, which is preserved here
61+
});
62+
}
63+
64+
_resetSignalSlot: () => void;
65+
}

src/notebook-factory/plugin.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type {
2+
JupyterFrontEnd,
3+
JupyterFrontEndPlugin
4+
} from '@jupyterlab/application';
5+
import { NotebookPanel } from '@jupyterlab/notebook';
6+
import { IEditorServices } from '@jupyterlab/codeeditor';
7+
8+
import { RtcNotebookContentFactory } from './notebook-factory';
9+
10+
type NotebookFactoryPlugin =
11+
JupyterFrontEndPlugin<NotebookPanel.IContentFactory>;
12+
13+
/**
14+
* Custom `Notebook` factory plugin.
15+
*/
16+
export const notebookFactoryPlugin: NotebookFactoryPlugin = {
17+
id: '@jupyter/server-documents/notebook-extension:factory',
18+
description: 'Provides the notebook cell factory.',
19+
provides: NotebookPanel.IContentFactory,
20+
requires: [IEditorServices],
21+
autoStart: true,
22+
activate: (app: JupyterFrontEnd, editorServices: IEditorServices) => {
23+
const editorFactory = editorServices.factoryService.newInlineEditor;
24+
return new RtcNotebookContentFactory({ editorFactory });
25+
}
26+
};

0 commit comments

Comments
 (0)