diff --git a/packages/collaboration-extension/src/collaboration.ts b/packages/collaboration-extension/src/collaboration.ts index e2502bca..783684f1 100644 --- a/packages/collaboration-extension/src/collaboration.ts +++ b/packages/collaboration-extension/src/collaboration.ts @@ -5,25 +5,43 @@ * @module collaboration-extension */ +import { + DocumentRegistry +} from '@jupyterlab/docregistry'; + +import { + NotebookPanel, INotebookModel +} from '@jupyterlab/notebook'; + +import { + IDisposable, DisposableDelegate +} from '@lumino/disposable'; + +import { CommandRegistry } from '@lumino/commands'; + import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { IToolbarWidgetRegistry } from '@jupyterlab/apputils'; +import { Dialog, IToolbarWidgetRegistry } from '@jupyterlab/apputils'; import { EditorExtensionRegistry, IEditorExtensionRegistry } from '@jupyterlab/codemirror'; -import { WebSocketAwarenessProvider } from '@jupyter/docprovider'; -import { SidePanel, usersIcon } from '@jupyterlab/ui-components'; +import { requestDocDelete, requestDocMerge, WebSocketAwarenessProvider } from '@jupyter/docprovider'; +import { + SidePanel, + usersIcon, + caretDownIcon +} from '@jupyterlab/ui-components'; import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { IStateDB, StateDB } from '@jupyterlab/statedb'; -import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { ITranslator, nullTranslator, TranslationBundle } from '@jupyterlab/translation'; import { Menu, MenuBar } from '@lumino/widgets'; -import { IAwareness } from '@jupyter/ydoc'; +import { IAwareness, ISharedNotebook, NotebookChange } from '@jupyter/ydoc'; import { CollaboratorsPanel, @@ -189,3 +207,221 @@ export const userEditorCursors: JupyterFrontEndPlugin = { }); } }; + +/** + * A plugin to add editing mode to the notebook page + */ +export const editingMode: JupyterFrontEndPlugin = { + id: '@jupyter/collaboration-extension:editingMode', + description: 'A plugin to add editing mode to the notebook page.', + autoStart: true, + optional: [ITranslator], + activate: ( + app: JupyterFrontEnd, + translator: ITranslator | null + ) => { + app.docRegistry.addWidgetExtension('Notebook', new EditingModeExtension(translator)); + }, +}; + +export class EditingModeExtension implements DocumentRegistry.IWidgetExtension { + private _trans: TranslationBundle; + + constructor(translator: ITranslator | null) { + this._trans = (translator ?? nullTranslator).load('jupyter_collaboration'); + } + + createNew( + panel: NotebookPanel, + context: DocumentRegistry.IContext + ): IDisposable { + const editingMenubar = new MenuBar(); + const suggestionMenubar = new MenuBar(); + const reviewMenubar = new MenuBar(); + + const editingCommands = new CommandRegistry(); + const suggestionCommands = new CommandRegistry(); + const reviewCommands = new CommandRegistry(); + + const editingMenu = new Menu({ commands: editingCommands }); + const suggestionMenu = new Menu({ commands: suggestionCommands }); + const reviewMenu = new Menu({ commands: reviewCommands }); + + const sharedModel = context.model.sharedModel; + const suggestions: {[key: string]: Menu.IItem} = {}; + var myForkId = ''; // curently allows only one suggestion per user + + editingMenu.title.label = 'Editing'; + editingMenu.title.icon = caretDownIcon; + + suggestionMenu.title.label = 'Root'; + suggestionMenu.title.icon = caretDownIcon; + + reviewMenu.title.label = 'Review'; + reviewMenu.title.icon = caretDownIcon; + + editingCommands.addCommand('editing', { + label: 'Editing', + execute: () => { + editingMenu.title.label = 'Editing'; + suggestionMenu.title.label = 'Root'; + open_dialog('Editing', this._trans); + } + }); + editingCommands.addCommand('suggesting', { + label: 'Suggesting', + execute: () => { + editingMenu.title.label = 'Suggesting'; + reviewMenu.clearItems(); + if (myForkId === '') { + myForkId = 'pending'; + sharedModel.provider.fork().then(newForkId => { + myForkId = newForkId; + sharedModel.provider.connect(newForkId); + suggestionMenu.title.label = newForkId; + }); + } + else { + suggestionMenu.title.label = myForkId; + sharedModel.provider.connect(myForkId); + } + open_dialog('Suggesting', this._trans); + } + }); + + suggestionCommands.addCommand('root', { + label: 'Root', + execute: () => { + // we cannot review the root document + reviewMenu.clearItems(); + suggestionMenu.title.label = 'Root'; + editingMenu.title.label = 'Editing'; + sharedModel.provider.connect(sharedModel.rootRoomId); + open_dialog('Editing', this._trans); + } + }); + + reviewCommands.addCommand('merge', { + label: 'Merge', + execute: () => { + requestDocMerge(sharedModel.currentRoomId, sharedModel.rootRoomId); + } + }); + reviewCommands.addCommand('discard', { + label: 'Discard', + execute: () => { + requestDocDelete(sharedModel.currentRoomId, sharedModel.rootRoomId); + } + }); + + editingMenu.addItem({type: 'command', command: 'editing'}); + editingMenu.addItem({type: 'command', command: 'suggesting'}); + + suggestionMenu.addItem({type: 'command', command: 'root'}); + + const _onStateChanged = (sender: ISharedNotebook, changes: NotebookChange) => { + if (changes.stateChange) { + changes.stateChange.forEach(value => { + const forkPrefix = 'fork_'; + if (value.name === 'merge' || value.name === 'delete') { + // we are on fork + if (sharedModel.currentRoomId === value.newValue) { + reviewMenu.clearItems(); + const merge = value.name === 'merge'; + sharedModel.provider.connect(sharedModel.rootRoomId, merge); + open_dialog('Editing', this._trans); + myForkId = ''; + } + } + else if (value.name.startsWith(forkPrefix)) { + // we are on root + const forkId = value.name.slice(forkPrefix.length); + if (value.newValue === 'new') { + suggestionCommands.addCommand(forkId, { + label: forkId, + execute: () => { + editingMenu.title.label = 'Suggesting'; + reviewMenu.clearItems(); + reviewMenu.addItem({type: 'command', command: 'merge'}); + reviewMenu.addItem({type: 'command', command: 'discard'}); + suggestionMenu.title.label = forkId; + sharedModel.provider.connect(forkId); + open_dialog('Suggesting', this._trans); + } + }); + const item = suggestionMenu.addItem({type: 'command', command: forkId}); + suggestions[forkId] = item; + if (myForkId !== forkId) { + if (myForkId !== 'pending') { + const dialog = new Dialog({ + title: this._trans.__('New suggestion'), + body: this._trans.__('View suggestion?'), + buttons: [ + Dialog.okButton({ label: 'View' }), + Dialog.cancelButton({ label: 'Discard' }), + ], + }); + dialog.launch().then(resp => { + dialog.close(); + if (resp.button.label === 'View') { + sharedModel.provider.connect(forkId); + suggestionMenu.title.label = forkId; + editingMenu.title.label = 'Suggesting'; + reviewMenu.clearItems(); + reviewMenu.addItem({type: 'command', command: 'merge'}); + reviewMenu.addItem({type: 'command', command: 'discard'}); + } + }); + } + else { + reviewMenu.clearItems(); + reviewMenu.addItem({type: 'command', command: 'merge'}); + reviewMenu.addItem({type: 'command', command: 'discard'}); + } + } + } + else if (value.newValue === undefined) { + editingMenu.title.label = 'Editing'; + suggestionMenu.title.label = 'Root'; + const item: Menu.IItem = suggestions[value.oldValue]; + delete suggestions[value.oldValue]; + suggestionMenu.removeItem(item); + } + } + }); + } + }; + + sharedModel.changed.connect(_onStateChanged, this); + + editingMenubar.addMenu(editingMenu); + suggestionMenubar.addMenu(suggestionMenu); + reviewMenubar.addMenu(reviewMenu); + + panel.toolbar.insertItem(997, 'editingMode', editingMenubar); + panel.toolbar.insertItem(998, 'suggestions', suggestionMenubar); + panel.toolbar.insertItem(999, 'review', reviewMenubar); + return new DisposableDelegate(() => { + editingMenubar.dispose(); + suggestionMenubar.dispose(); + reviewMenubar.dispose(); + }); + } +} + + +function open_dialog(title: string, trans: TranslationBundle) { + var body: string; + if (title === 'Editing') { + body = 'You are now directly editing the document.' + } + else { + body = 'Your edits now become suggestions to the document.' + } + const dialog = new Dialog({ + title: trans.__(title), + body: trans.__(body), + buttons: [Dialog.okButton({ label: 'OK' })], + }); + dialog.launch().then(resp => { dialog.close(); }); +} diff --git a/packages/collaboration-extension/src/index.ts b/packages/collaboration-extension/src/index.ts index 196b75c0..0a34643e 100644 --- a/packages/collaboration-extension/src/index.ts +++ b/packages/collaboration-extension/src/index.ts @@ -12,7 +12,8 @@ import { menuBarPlugin, rtcGlobalAwarenessPlugin, rtcPanelPlugin, - userEditorCursors + userEditorCursors, + editingMode } from './collaboration'; import { sharedLink } from './sharedlink'; @@ -25,7 +26,8 @@ const plugins: JupyterFrontEndPlugin[] = [ rtcGlobalAwarenessPlugin, rtcPanelPlugin, sharedLink, - userEditorCursors + userEditorCursors, + editingMode ]; export default plugins; diff --git a/packages/docprovider/src/index.ts b/packages/docprovider/src/index.ts index 62edebda..abfd4873 100644 --- a/packages/docprovider/src/index.ts +++ b/packages/docprovider/src/index.ts @@ -8,6 +8,7 @@ */ export * from './awareness'; +export * from './requests'; export * from './ydrive'; export * from './yprovider'; export * from './tokens'; diff --git a/packages/docprovider/src/requests.ts b/packages/docprovider/src/requests.ts index 54e7c364..699f2a65 100644 --- a/packages/docprovider/src/requests.ts +++ b/packages/docprovider/src/requests.ts @@ -11,6 +11,9 @@ import { ServerConnection, Contents } from '@jupyterlab/services'; * See https://github.com/jupyterlab/jupyter_collaboration */ const DOC_SESSION_URL = 'api/collaboration/session'; +const DOC_FORK_URL = 'api/collaboration/fork_room'; +const DOC_MERGE_URL = 'api/collaboration/merge_room'; +const DOC_DELETE_URL = 'api/collaboration/delete_room'; /** * Document session model @@ -73,3 +76,117 @@ export async function requestDocSession( return data; } + + +export async function requestDocFork( + roomid: string, +): Promise { + const settings = ServerConnection.makeSettings(); + const url = URLExt.join( + settings.baseUrl, + DOC_FORK_URL, + encodeURIComponent(roomid) + ); + const body = {method: 'PUT'}; + + let response: Response; + try { + response = await ServerConnection.makeRequest(url, body, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as Error); + } + + let data: any = await response.text(); + + if (data.length > 0) { + try { + data = JSON.parse(data); + } catch (error) { + console.log('Not a JSON response body.', response); + } + } + + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + + return data; +} + + +export async function requestDocMerge( + forkRoomid: string, + rootRoomid: string +): Promise { + const settings = ServerConnection.makeSettings(); + const url = URLExt.join( + settings.baseUrl, + DOC_MERGE_URL + ); + const body = { + method: 'PUT', + body: JSON.stringify({ fork_roomid: forkRoomid, root_roomid: rootRoomid }) + }; + + let response: Response; + try { + response = await ServerConnection.makeRequest(url, body, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as Error); + } + + let data: any = await response.text(); + + if (data.length > 0) { + try { + data = JSON.parse(data); + } catch (error) { + console.log('Not a JSON response body.', response); + } + } + + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + + return data; +} + + +export async function requestDocDelete( + forkRoomid: string, + rootRoomid: string, +): Promise { + const settings = ServerConnection.makeSettings(); + const url = URLExt.join( + settings.baseUrl, + DOC_DELETE_URL, + ); + const body = { + method: 'DELETE', + body: JSON.stringify({ fork_roomid: forkRoomid, root_roomid: rootRoomid }) + }; + + let response: Response; + try { + response = await ServerConnection.makeRequest(url, body, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as Error); + } + + let data: any = await response.text(); + + if (data.length > 0) { + try { + data = JSON.parse(data); + } catch (error) { + console.log('Not a JSON response body.', response); + } + } + + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + + return data; +} diff --git a/packages/docprovider/src/ydrive.ts b/packages/docprovider/src/ydrive.ts index b8ec4d60..aab5b43c 100644 --- a/packages/docprovider/src/ydrive.ts +++ b/packages/docprovider/src/ydrive.ts @@ -147,6 +147,7 @@ export class YDrive extends Drive implements ICollaborativeDrive { const key = `${options.format}:${options.contentType}:${options.path}`; this._providers.set(key, provider); + sharedModel.provider = provider; sharedModel.disposed.connect(() => { const provider = this._providers.get(key); if (provider) { diff --git a/packages/docprovider/src/yprovider.ts b/packages/docprovider/src/yprovider.ts index 4061ebeb..44d943c1 100644 --- a/packages/docprovider/src/yprovider.ts +++ b/packages/docprovider/src/yprovider.ts @@ -8,25 +8,14 @@ import { User } from '@jupyterlab/services'; import { TranslationBundle } from '@jupyterlab/translation'; import { PromiseDelegate } from '@lumino/coreutils'; -import { IDisposable } from '@lumino/disposable'; import { Signal } from '@lumino/signaling'; -import { DocumentChange, YDocument } from '@jupyter/ydoc'; +import { DocumentChange, IDocumentProvider, YDocument } from '@jupyter/ydoc'; import { Awareness } from 'y-protocols/awareness'; import { WebsocketProvider as YWebsocketProvider } from 'y-websocket'; -import { requestDocSession } from './requests'; - -/** - * An interface for a document provider. - */ -export interface IDocumentProvider extends IDisposable { - /** - * Returns a Promise that resolves when the document provider is ready. - */ - readonly ready: Promise; -} +import { requestDocFork, requestDocSession } from './requests'; /** * A class to provide Yjs synchronization over WebSocket. @@ -42,6 +31,7 @@ export class WebSocketProvider implements IDocumentProvider { */ constructor(options: WebSocketProvider.IOptions) { this._isDisposed = false; + this._sessionId = options.sessionId ?? ''; this._path = options.path; this._contentType = options.contentType; this._format = options.format; @@ -50,15 +40,14 @@ export class WebSocketProvider implements IDocumentProvider { this._awareness = options.model.awareness; this._yWebsocketProvider = null; this._trans = options.translator; + this._user = options.user; - const user = options.user; - - user.ready + this._user.ready .then(() => { - this._onUserChanged(user); + this._onUserChanged(this._user); }) .catch(e => console.error(e)); - user.userChanged.connect(this._onUserChanged, this); + this._user.userChanged.connect(this._onUserChanged, this); this._connect().catch(e => console.warn(e)); } @@ -91,20 +80,71 @@ export class WebSocketProvider implements IDocumentProvider { Signal.clearData(this); } - private async _connect(): Promise { + async fork(): Promise { const session = await requestDocSession( this._format, this._contentType, this._path ); + const response = await requestDocFork(`${session.format}:${session.type}:${session.fileId}`); + const forkId = response.roomId; + this._sharedModel.currentRoomId = forkId; + this._sharedModel.addFork(forkId); + + return forkId; + } + + connect(roomId: string, merge?: boolean) { + this._sharedModel.currentRoomId = roomId; + this._yWebsocketProvider?.disconnect(); + if (roomId === this._sharedModel.rootRoomId) { + // connecting to the root + // don't bring our changes there if not merging + if (merge !== true) { + while (this._sharedModel.undoManager.canUndo()) { + this._sharedModel.undoManager.undo(); + } + } + this._sharedModel.undoManager.clear(); + } + else { + // connecting to a fork + // keep track of changes so that we can undo them when connecting back to root + this._sharedModel.undoManager.clear(); + } + this._yWebsocketProvider = new YWebsocketProvider( this._serverUrl, - `${session.format}:${session.type}:${session.fileId}`, + roomId, this._sharedModel.ydoc, { disableBc: true, - params: { sessionId: session.sessionId }, + params: { sessionId: this._sessionId }, + awareness: this._awareness + } + ); + } + + private async _connect(): Promise { + if (this._sharedModel.rootRoomId === '') { + const session = await requestDocSession( + this._format, + this._contentType, + this._path + ); + this._sharedModel.rootRoomId = `${session.format}:${session.type}:${session.fileId}`; + this._sharedModel.currentRoomId = this._sharedModel.rootRoomId; + this._sessionId = session.sessionId; + } + + this._yWebsocketProvider = new YWebsocketProvider( + this._serverUrl, + this._sharedModel.rootRoomId, + this._sharedModel.ydoc, + { + disableBc: true, + params: { sessionId: this._sessionId }, awareness: this._awareness } ); @@ -142,12 +182,14 @@ export class WebSocketProvider implements IDocumentProvider { private _contentType: string; private _format: string; private _isDisposed: boolean; + private _sessionId: string; private _path: string; private _ready = new PromiseDelegate(); private _serverUrl: string; private _sharedModel: YDocument; private _yWebsocketProvider: YWebsocketProvider | null; private _trans: TranslationBundle; + private _user: User.IManager; } /** @@ -192,5 +234,10 @@ export namespace WebSocketProvider { * The jupyterlab translator */ translator: TranslationBundle; + + /** + * The document session ID, if the document is a fork + */ + sessionId?: string; } } diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py index 8ee23ee3..287acb06 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/app.py @@ -12,7 +12,7 @@ from pycrdt_websocket.ystore import BaseYStore from traitlets import Bool, Float, Type -from .handlers import DocSessionHandler, YDocWebSocketHandler +from .handlers import DocForkHandler, DocDeleteHandler, DocMergeHandler, DocSessionHandler, YDocWebSocketHandler from .loaders import FileLoaderMapping from .rooms import DocumentRoom from .stores import SQLiteYStore @@ -131,6 +131,27 @@ def initialize_handlers(self): }, ), (r"/api/collaboration/session/(.*)", DocSessionHandler), + ( + r"/api/collaboration/fork_room/(.*)", + DocForkHandler, + { + "ywebsocket_server": self.ywebsocket_server, + } + ), + ( + r"/api/collaboration/merge_room", + DocMergeHandler, + { + "ywebsocket_server": self.ywebsocket_server, + } + ), + ( + r"/api/collaboration/delete_room", + DocDeleteHandler, + { + "ywebsocket_server": self.ywebsocket_server, + } + ), ] ) diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py index 59849058..1e6e81df 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py @@ -6,13 +6,14 @@ import asyncio import json import time -import uuid +from uuid import uuid4 from typing import Any from jupyter_server.auth import authorized from jupyter_server.base.handlers import APIHandler, JupyterHandler from jupyter_ydoc import ydocs as YDOCS -from pycrdt_websocket.websocket_server import YRoom +from pycrdt import Doc, Map +from pycrdt_websocket.yroom import YRoom from pycrdt_websocket.ystore import BaseYStore from pycrdt_websocket.yutils import YMessageType, write_var_uint from tornado import web @@ -32,7 +33,7 @@ YFILE = YDOCS["file"] -SERVER_SESSION = str(uuid.uuid4()) +SERVER_SESSION = str(uuid4()) class YDocWebSocketHandler(WebSocketHandler, JupyterHandler): @@ -405,3 +406,119 @@ async def put(self, path): ) self.set_status(201) return self.finish(data) + + +class DocForkHandler(APIHandler): + """ + Jupyter Server's handler to fork a document. + """ + + auth_resource = "contents" + + def initialize( + self, + ywebsocket_server: JupyterWebsocketServer, + ) -> None: + self._websocket_server = ywebsocket_server + + @web.authenticated + @authorized + async def put(self, room_id): + """ + Creates a fork of a root document and returns its ID. + """ + idx = uuid4().hex + + root_room = await self._websocket_server.get_room(room_id) + update = root_room.ydoc.get_update() + fork_ydoc = Doc() + fork_ydoc.apply_update(update) + fork_room = YRoom(ydoc=fork_ydoc) + self._websocket_server.add_room(idx, fork_room) + root_room.fork_ydocs.add(fork_ydoc) + data = json.dumps({ + "sessionId": SERVER_SESSION, + "roomId": idx, + }) + self.set_status(201) + return self.finish(data) + + +class DocMergeHandler(APIHandler): + """ + Jupyter Server's handler to merge a document. + """ + + auth_resource = "contents" + + def initialize( + self, + ywebsocket_server: JupyterWebsocketServer, + ) -> None: + self._websocket_server = ywebsocket_server + + @web.authenticated + @authorized + async def put(self): + """ + Merges back a fork into a root document. + """ + model = self.get_json_body() + fork_roomid = model["fork_roomid"] + root_room = await self._websocket_server.get_room(model["root_roomid"]) + root_ydoc = root_room.ydoc + idx = f"fork_{fork_roomid}" + root_state = root_ydoc.get("state", type=Map) + if idx in root_state: + del root_state[idx] + else: + self.set_status(404) + raise RuntimeError(f"Could not find root document fork with ID: {fork_roomid}") + fork_room = await self._websocket_server.get_room(fork_roomid) + fork_ydoc = fork_room.ydoc + fork_update = fork_ydoc.get_update() + root_ydoc.apply_update(fork_update) + root_room.fork_ydocs.remove(fork_ydoc) + fork_state = fork_ydoc.get("state", type=Map) + fork_state["merge"] = fork_roomid + #self._websocket_server.delete_room(name=fork_roomid) + self.set_status(200) + + +class DocDeleteHandler(APIHandler): + """ + Jupyter Server's handler to delete a document. + """ + + auth_resource = "contents" + + def initialize( + self, + ywebsocket_server: JupyterWebsocketServer, + ) -> None: + self._websocket_server = ywebsocket_server + + @web.authenticated + @authorized + async def delete(self): + """ + Deletes a forked document. + """ + model = self.get_json_body() + fork_roomid = model["fork_roomid"] + root_room = await self._websocket_server.get_room(model["root_roomid"]) + root_ydoc = root_room.ydoc + idx = f"fork_{fork_roomid}" + root_state = root_ydoc.get("state", type=Map) + if idx in root_state: + del root_state[idx] + else: + self.set_status(404) + raise RuntimeError(f"Could not find root document fork with ID: {fork_roomid}") + fork_room = await self._websocket_server.get_room(fork_roomid) + fork_ydoc = fork_room.ydoc + root_room.fork_ydocs.remove(fork_ydoc) + fork_state = fork_ydoc.get("state", type=Map) + fork_state["delete"] = fork_roomid + #self._websocket_server.delete_room(name=fork_roomid) + self.set_status(200)