diff --git a/devenv-jcollab-backend.yml b/devenv-jcollab-backend.yml deleted file mode 100644 index 4288f7e..0000000 --- a/devenv-jcollab-backend.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: serverdocs-jcollab-backend -channels: - - conda-forge -dependencies: - - python - - nodejs=22 - - uv - - jupyterlab - - pip: - - "jupyter_server_ydoc>=2.0.2,<3" - - jupyter_collaboration_ui>=2.0.2,<3 - - "jupyterlab>=4.4.0,<5.0.0" \ No newline at end of file diff --git a/devenv-jcollab-frontend.yml b/devenv-jcollab-frontend.yml deleted file mode 100644 index 2fc614e..0000000 --- a/devenv-jcollab-frontend.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: serverdocs-jcollab-frontend -channels: - - conda-forge -dependencies: - - python - - nodejs=22 - - uv - - jupyterlab - - pip: - - jupyter_docprovider>=2.0.2,<3 - - jupyter_collaboration_ui>=2.0.2,<3 diff --git a/devenv-jcollab.yml b/devenv-jcollab.yml new file mode 100644 index 0000000..0c9cd1d --- /dev/null +++ b/devenv-jcollab.yml @@ -0,0 +1,11 @@ +name: serverdocs-jcollab +channels: + - conda-forge +dependencies: + - python + - nodejs=22 + - uv + - jupyterlab + - pip: + - jupyterlab>=4.4.0,<5.0.0 + - jupyter_collaboration~=4.0 diff --git a/jupyter-config/server-config/jupyter_server_documents.json b/jupyter-config/server-config/jupyter_server_documents.json index 419bf96..026985e 100644 --- a/jupyter-config/server-config/jupyter_server_documents.json +++ b/jupyter-config/server-config/jupyter_server_documents.json @@ -1,7 +1,8 @@ { "ServerApp": { "jpserver_extensions": { - "jupyter_server_documents": true + "jupyter_server_documents": true, + "jupyter_server_ydoc": false } } } diff --git a/jupyter_server_documents/__init__.py b/jupyter_server_documents/__init__.py index dd9a0e3..e609f8f 100644 --- a/jupyter_server_documents/__init__.py +++ b/jupyter_server_documents/__init__.py @@ -10,6 +10,7 @@ from .app import ServerDocsApp +from .events import JSD_AWARENESS_EVENT_URI, JSD_ROOM_EVENT_URI def _jupyter_labextension_paths(): diff --git a/jupyter_server_documents/app.py b/jupyter_server_documents/app.py index 7df66fc..6b2b631 100644 --- a/jupyter_server_documents/app.py +++ b/jupyter_server_documents/app.py @@ -7,6 +7,8 @@ from .websockets import YRoomWebsocket from .rooms.yroom_manager import YRoomManager from .outputs import OutputsManager, outputs_handlers +from .events import JSD_AWARENESS_EVENT_SCHEMA, JSD_ROOM_EVENT_SCHEMA +from .jcollab_api import JCollabAPI class ServerDocsApp(ExtensionApp): name = "jupyter_server_documents" @@ -51,6 +53,10 @@ def initialize(self): super().initialize() def initialize_settings(self): + # Register event schemas + self.serverapp.event_logger.register_event_schema(JSD_ROOM_EVENT_SCHEMA) + self.serverapp.event_logger.register_event_schema(JSD_AWARENESS_EVENT_SCHEMA) + # Get YRoomManager arguments from server extension context. # We cannot access the 'file_id_manager' key immediately because server # extensions initialize in alphabetical order. 'jupyter_server_documents' < @@ -65,13 +71,22 @@ def get_fileid_manager(): self.settings["yroom_manager"] = YRoomManager( get_fileid_manager=get_fileid_manager, contents_manager=contents_manager, + event_logger=self.serverapp.event_logger, loop=loop, log=log ) - + # Initialize OutputsManager self.outputs_manager = self.outputs_manager_class(config=self.config) self.settings["outputs_manager"] = self.outputs_manager + + # Serve Jupyter Collaboration API on + # `self.settings["jupyter_server_ydoc"]` for compatibility with + # extensions depending on Jupyter Collaboration + self.settings["jupyter_server_ydoc"] = JCollabAPI( + get_fileid_manager=get_fileid_manager, + yroom_manager=self.settings["yroom_manager"] + ) def _link_jupyter_server_extension(self, server_app): """Setup custom config needed by this extension.""" diff --git a/jupyter_server_documents/events/__init__.py b/jupyter_server_documents/events/__init__.py new file mode 100644 index 0000000..c60b35b --- /dev/null +++ b/jupyter_server_documents/events/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + +EVENTS_DIR = Path(__file__).parent + +# Use the same schema ID as `jupyter_collaboration` for compatibility +JSD_ROOM_EVENT_URI = "https://schema.jupyter.org/jupyter_collaboration/session/v1" +JSD_AWARENESS_EVENT_URI = "https://schema.jupyter.org/jupyter_collaboration/awareness/v1" + +JSD_ROOM_EVENT_SCHEMA = EVENTS_DIR / "room.yaml" +JSD_AWARENESS_EVENT_SCHEMA = EVENTS_DIR / "awareness.yaml" diff --git a/jupyter_server_documents/events/awareness.yaml b/jupyter_server_documents/events/awareness.yaml new file mode 100644 index 0000000..eccf2d7 --- /dev/null +++ b/jupyter_server_documents/events/awareness.yaml @@ -0,0 +1,33 @@ +"$id": https://schema.jupyter.org/jupyter_collaboration/awareness/v1 +"$schema": "http://json-schema.org/draft-07/schema" +version: "1" +title: Collaborative awareness events +personal-data: true +description: | + Awareness events emitted from server-side during a collaborative session. +type: object +required: + - roomid + - username + - action +properties: + roomid: + type: string + description: | + Room ID. Usually composed by the file type, format and ID. + username: + type: string + description: | + The name of the user who joined or left room. + action: + enum: + - join + - leave + description: | + Possible values: + 1. join + 2. leave + msg: + type: string + description: | + Optional event message. \ No newline at end of file diff --git a/jupyter_server_documents/events/room.yaml b/jupyter_server_documents/events/room.yaml new file mode 100644 index 0000000..d54e0d8 --- /dev/null +++ b/jupyter_server_documents/events/room.yaml @@ -0,0 +1,60 @@ +"$id": https://schema.jupyter.org/jupyter_collaboration/session/v1 +"$schema": "http://json-schema.org/draft-07/schema" +version: "1" +title: Room events +personal-data: true +description: | + An event emitted by a collaboration room. +type: object +required: + - level + - room + - path +properties: + level: + enum: + - INFO + - DEBUG + - WARNING + - ERROR + - CRITICAL + description: | + Message type. + room: + type: string + description: | + Room ID. This is a composite ID that takes the format `{file_format}:{file_type}:{file_id}`. + path: + type: string + description: | + Path of the file. + store: + type: string + description: | + The store used to track the document history. + action: + enum: + - initialize + - load + - save + - overwrite + - clean + description: | + Action performed in a room during a collaborative session. + Possible values: + 1. initialize + Initialize a room by loading the content from the contents manager or a store. + 2. load + Load the content from the contents manager. + 3. save + Save the content with the contents manager. + 4. overwrite + Overwrite the content in a room with content from the contents manager. + This can happen when multiple rooms access the same file or when a user + modifies the file outside Jupyter Server (e.g. using a different app). + 5. clean + Clean the room once is empty (aka there is no more users connected to it). + msg: + type: string + description: | + Event message. \ No newline at end of file diff --git a/jupyter_server_documents/jcollab_api/__init__.py b/jupyter_server_documents/jcollab_api/__init__.py new file mode 100644 index 0000000..6cc6fac --- /dev/null +++ b/jupyter_server_documents/jcollab_api/__init__.py @@ -0,0 +1 @@ +from .jcollab_api import JCollabAPI \ No newline at end of file diff --git a/jupyter_server_documents/jcollab_api/jcollab_api.py b/jupyter_server_documents/jcollab_api/jcollab_api.py new file mode 100644 index 0000000..312340a --- /dev/null +++ b/jupyter_server_documents/jcollab_api/jcollab_api.py @@ -0,0 +1,64 @@ +from __future__ import annotations +from jupyter_ydoc.ybasedoc import YBaseDoc +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable, Literal + from jupyter_server_fileid.manager import BaseFileIdManager + from ..rooms import YRoomManager + + +class JCollabAPI: + """ + Provides the Python API provided by `jupyter_collaboration~=4.0` under + `self.settings["jupyter_server_ydoc"]`. + """ + fileid_manager: BaseFileIdManager + yroom_manager: YRoomManager + + def __init__(self, get_fileid_manager: Callable[[], BaseFileIdManager], yroom_manager: YRoomManager): + self._get_fileid_manager = get_fileid_manager + self.yroom_manager = yroom_manager + + @property + def fileid_manager(self) -> BaseFileIdManager: + return self._get_fileid_manager() + + async def get_document( + self, + *, + path: str | None = None, + content_type: str | None = None, + file_format: Literal["json", "text"] | None = None, + room_id: str | None = None, + copy: bool = True, + ) -> YBaseDoc: + """ + Returns the Jupyter YDoc for a collaborative room. + + You need to provide either a ``room_id`` or the ``path``, + the ``content_type`` and the ``file_format``. + + The `copy` argument is ignored by `jupyter_server_documents`. + """ + + # Raise exception if required arguments are not given + if room_id is None and (path is None or content_type is None or file_format is None): + raise ValueError( + "You need to provide either a ``room_id`` or the ``path``, the ``content_type`` and the ``file_format``." + ) + + # Compute room_id if not given + if room_id is None: + file_id = self.fileid_manager.index(path) + room_id = f"{file_format}:{content_type}:{file_id}" + + # Get or create room using `room_id` + room = self.yroom_manager.get_room(room_id) + if not room: + raise ValueError( + f"Could not get room using room ID '{room_id}'." + ) + + # Return the Jupyter YDoc once ready + return await room.get_jupyter_ydoc() diff --git a/jupyter_server_documents/rooms/yroom.py b/jupyter_server_documents/rooms/yroom.py index a085c8b..4a07062 100644 --- a/jupyter_server_documents/rooms/yroom.py +++ b/jupyter_server_documents/rooms/yroom.py @@ -8,8 +8,10 @@ from pycrdt import YMessageType, YSyncMessageType as YSyncMessageSubtype from jupyter_server_documents.ydocs import ydocs as jupyter_ydoc_classes from jupyter_ydoc.ybasedoc import YBaseDoc +from jupyter_events import EventLogger from tornado.websocket import WebSocketHandler from .yroom_file_api import YRoomFileAPI +from .yroom_events_api import YRoomEventsAPI if TYPE_CHECKING: from typing import Literal, Tuple, Any @@ -27,12 +29,12 @@ class YRoom: """ The ID of the room. This is a composite ID following the format: - room_id := "{file_type}:{file_format}:{file_id}" + room_id := "{file_format}:{file_type}:{file_id}" """ file_api: YRoomFileAPI | None """ - The `YRoomFileAPI` instance for this room. This is set to `None` if & only + The `YRoomFileAPI` instance for this room. This is set to `None` only if `self.room_id == "JupyterLab:globalAwareness"`. The file API provides `load_ydoc_content()` for loading the YDoc content @@ -41,8 +43,16 @@ class YRoom: out-of-band changes. """ + events_api: YRoomEventsAPI | None + """ + A `YRoomEventsAPI` instance for this room that provides methods for emitting + events through the `jupyter_events.EventLogger` singleton. This is set to + `None` only if `self.room_id == "JupyterLab:globalAwareness"`. + """ + _jupyter_ydoc: YBaseDoc | None """JupyterYDoc""" + _ydoc: pycrdt.Doc """Ydoc""" _awareness: pycrdt.Awareness @@ -83,7 +93,8 @@ def __init__( loop: asyncio.AbstractEventLoop, fileid_manager: BaseFileIdManager, contents_manager: AsyncContentsManager | ContentsManager, - on_stop: callable[[], Any] | None = None + on_stop: callable[[], Any] | None = None, + event_logger: EventLogger ): # Bind instance attributes self.room_id = room_id @@ -98,14 +109,18 @@ def __init__( self._ydoc = pycrdt.Doc() self._awareness = pycrdt.Awareness(ydoc=self._ydoc) - # If this room is providing global awareness, set `file_api` and - # `_jupyter_ydoc` to `None` as the YDoc is unused. + # If this room is providing global awareness, set unused optional + # attributes to `None`. if self.room_id == "JupyterLab:globalAwareness": self.file_api = None self._jupyter_ydoc = None + self.events_api = None else: - # Otherwise, initialize `_jupyter_ydoc` and `file_api`. + # Otherwise, initialize optional attributes for document rooms + # Initialize JupyterYDoc self._jupyter_ydoc = self._init_jupyter_ydoc() + + # Initialize YRoomFileAPI, start loading content self.file_api = YRoomFileAPI( room_id=self.room_id, jupyter_ydoc=self._jupyter_ydoc, @@ -117,12 +132,18 @@ def __init__( on_outofband_move=self.handle_outofband_move, on_inband_deletion=self.handle_inband_deletion ) - - # Load the YDoc content after initializing self.file_api.load_ydoc_content() # Attach Jupyter YDoc observer to automatically save on change self._jupyter_ydoc.observe(self._on_jupyter_ydoc_update) + + # Initialize YRoomEventsAPI + self.events_api = YRoomEventsAPI( + event_logger=event_logger, + fileid_manager=fileid_manager, + room_id=self.room_id, + log=self.log, + ) # Start observers on `self.ydoc` and `self.awareness` to ensure new # updates are always broadcast to all clients. @@ -140,6 +161,18 @@ def __init__( # Log notification that room is ready self.log.info(f"Room '{self.room_id}' initialized.") + + # Emit events if defined + if self.events_api: + # Emit 'initialize' event + self.events_api.emit_room_event("initialize") + + # Emit 'load' event once content is loaded + assert self.file_api + async def emit_load_event(): + await self.file_api.ydoc_content_loaded.wait() + self.events_api.emit_room_event("load") + self._loop.create_task(emit_load_event()) def _init_jupyter_ydoc(self) -> YBaseDoc: @@ -163,7 +196,7 @@ def _init_jupyter_ydoc(self) -> YBaseDoc: jupyter_ydoc_classes.get(file_type, jupyter_ydoc_classes["file"]) ) - # Initialize Jupyter YDoc, add an observer to save it on change, return + # Initialize Jupyter YDoc and return it jupyter_ydoc = JupyterYDocClass(ydoc=self._ydoc, awareness=self._awareness) return jupyter_ydoc @@ -613,6 +646,10 @@ def reload_ydoc(self) -> None: ) self._jupyter_ydoc.observe(self._on_jupyter_ydoc_update) + # Emit 'overwrite' event as the YDoc content has been overwritten + if self.events_api: + self.events_api.emit_room_event("overwrite") + def handle_outofband_move(self) -> None: """ diff --git a/jupyter_server_documents/rooms/yroom_events_api.py b/jupyter_server_documents/rooms/yroom_events_api.py new file mode 100644 index 0000000..1b9fe6b --- /dev/null +++ b/jupyter_server_documents/rooms/yroom_events_api.py @@ -0,0 +1,79 @@ +from jupyter_events import EventLogger +from ..events import JSD_ROOM_EVENT_URI +from typing import Optional +from jupyter_server_fileid.manager import BaseFileIdManager +from logging import Logger +from typing import Literal + + +class YRoomEventsAPI: + """ + Class that provides an API to emit events to the + `jupyter_events.EventLogger` singleton in `jupyter_server`. + + JSD room and awareness events have the same structure as + `jupyter_collaboration` v4 session and awareness events and emit on the same + schema IDs. Fork events are not emitted. + + The event schemas must be registered via + `event_logger.register_event_schema()` in advance. This should be done when + the server extension initializes. + """ + + _event_logger: EventLogger + _fileid_manager: BaseFileIdManager + room_id: str + log: Logger + + def __init__(self, event_logger: EventLogger, fileid_manager: BaseFileIdManager, room_id: str, log: Logger): + self._event_logger = event_logger + self._fileid_manager = fileid_manager + self.room_id = room_id + self.log = log + + def emit_room_event( + self, + action: Literal["initialize", "load", "save", "overwrite", "clean"], + level: Optional[Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]] = "INFO" + ): + """ + Emits a room event. This method is guaranteed to log any caught + exceptions and never raise them to the `YRoom`. + """ + try: + path = self._get_path() + event_data = { + "level": level, + "room": self.room_id, + "path": path, + "action": action + } + + # TODO: Jupyter AI requires the `msg` field to be set to 'Room + # initialized' on 'initialize' room events. Remove this when the + # Jupyter AI issue is fixed. + if action == "initialize": + event_data["msg"] = "Room initialized" + self._event_logger.emit(schema_id=JSD_ROOM_EVENT_URI, data=event_data) + except: + self.log.exception("Exception occurred when emitting a room event.") + + def emit_awareness_event(self): + """ + TODO + """ + pass + + + def _get_path(self) -> str: + """ + Returns the relative path to the file by querying the FileIdManager. The + path is relative to the `ServerApp.root_dir` configurable trait. + """ + # Query for the path from the file ID in the room ID + file_id = self.room_id.split(":")[-1] + rel_path = self._fileid_manager.get_path(file_id) + + # Raise exception if the path could not be found, then return + assert rel_path is not None + return rel_path \ No newline at end of file diff --git a/jupyter_server_documents/rooms/yroom_manager.py b/jupyter_server_documents/rooms/yroom_manager.py index 27fb876..e6d7576 100644 --- a/jupyter_server_documents/rooms/yroom_manager.py +++ b/jupyter_server_documents/rooms/yroom_manager.py @@ -8,6 +8,7 @@ import logging from jupyter_server_fileid.manager import BaseFileIdManager from jupyter_server.services.contents.manager import AsyncContentsManager, ContentsManager + from jupyter_events import EventLogger class YRoomManager(): _rooms_by_id: dict[str, YRoom] @@ -17,12 +18,14 @@ def __init__( *, get_fileid_manager: callable[[], BaseFileIdManager], contents_manager: AsyncContentsManager | ContentsManager, + event_logger: EventLogger, loop: asyncio.AbstractEventLoop, log: logging.Logger, ): # Bind instance attributes self._get_fileid_manager = get_fileid_manager self.contents_manager = contents_manager + self.event_logger = event_logger self.loop = loop self.log = log @@ -55,6 +58,7 @@ def get_room(self, room_id: str) -> YRoom | None: fileid_manager=self.fileid_manager, contents_manager=self.contents_manager, on_stop=lambda: self._handle_yroom_stop(room_id), + event_logger=self.event_logger, ) self._rooms_by_id[room_id] = yroom return yroom diff --git a/package.json b/package.json index cac128c..1c85446 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "watch:labextension": "jupyter labextension watch ." }, "dependencies": { - "@jupyter/collaborative-drive": "^4.0.2", + "@jupyter/collaborative-drive": "^4", "@jupyter/ydoc": "^2.1.3 || ^3.0.0", "@jupyterlab/application": "^4.4.0", "@jupyterlab/apputils": "^4.4.0", @@ -132,7 +132,8 @@ "outputDir": "jupyter_server_documents/labextension", "schemaDir": "schema", "disabledExtensions": [ - "@jupyterlab/codemirror-extension:binding" + "@jupyterlab/codemirror-extension:binding", + "@jupyter/docprovider-extension" ] }, "eslintIgnore": [ diff --git a/src/docprovider/filebrowser.ts b/src/docprovider/filebrowser.ts index cf2cb8a..b29f0ca 100644 --- a/src/docprovider/filebrowser.ts +++ b/src/docprovider/filebrowser.ts @@ -41,7 +41,7 @@ const TWO_SESSIONS_WARNING = export const rtcContentProvider: JupyterFrontEndPlugin = { - id: '@jupyter/server-documents/docprovider-extension:content-provider', + id: '@jupyter/server-documents:rtc-content-provider', description: 'The RTC content provider', provides: ICollaborativeContentProvider, requires: [ITranslator], @@ -82,7 +82,7 @@ export const rtcContentProvider: JupyterFrontEndPlugin = { - id: '@jupyter/server-documents/docprovider-extension:yfile', + id: '@jupyter/server-documents:yfile', description: "Plugin to register the shared model factory for the content type 'file'", autoStart: true, @@ -107,7 +107,7 @@ export const yfile: JupyterFrontEndPlugin = { * Plugin to register the shared model factory for the content type 'notebook'. */ export const ynotebook: JupyterFrontEndPlugin = { - id: '@jupyter/server-documents/docprovider-extension:ynotebook', + id: '@jupyter/server-documents:ynotebook', description: "Plugin to register the shared model factory for the content type 'notebook'", autoStart: true, @@ -158,7 +158,7 @@ export const ynotebook: JupyterFrontEndPlugin = { * The default collaborative drive provider. */ export const logger: JupyterFrontEndPlugin = { - id: '@jupyter/server-documents/docprovider-extension:logger', + id: '@jupyter/server-documents:rtc-drive-logger', description: 'A logging plugin for debugging purposes.', autoStart: true, optional: [ILoggerRegistry, IEditorTracker, INotebookTracker, ITranslator], diff --git a/src/index.ts b/src/index.ts index 4fbd404..9830d84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { } from '@jupyterlab/apputils'; import { KeyboardEvent } from 'react'; import { IToolbarWidgetRegistry } from '@jupyterlab/apputils'; +import { INotebookCellExecutor, runCell } from '@jupyterlab/notebook'; import { AwarenessExecutionIndicator } from './executionindicator'; import { requestAPI } from './handler'; @@ -90,7 +91,7 @@ export const plugin: JupyterFrontEndPlugin = { * Jupyter plugin creating a global awareness for RTC. */ export const rtcGlobalAwarenessPlugin: JupyterFrontEndPlugin = { - id: '@jupyter/server-documents/collaboration-extension:rtcGlobalAwareness', + id: '@jupyter/server-documents:rtc-global-awareness', description: 'Add global awareness to share working document of users.', requires: [IStateDB], provides: IGlobalAwareness, @@ -172,7 +173,7 @@ export const executionIndicator: JupyterFrontEndPlugin = { * A plugin that provides a kernel status item to the status bar. */ export const kernelStatus: JupyterFrontEndPlugin = { - id: '@jupyterlab/apputils-extension:awareness-kernel-status', + id: '@jupyter/server-documents:awareness-kernel-status', description: 'Provides the kernel status indicator model.', autoStart: true, requires: [IStatusBar], @@ -281,6 +282,28 @@ export const kernelStatus: JupyterFrontEndPlugin = { } }; +/** + * Notebook cell executor plugin, provided by JupyterLab by default. Re-provided + * to ensure compatibility with `jupyter_collaboration`. + * + * The `@jupyter/docprovider-extension` disables this plugin to override it, but + * we disable that labextension, leaving `INotebookCellExecutor` un-implemented. + * This plugin fixes that issue by re-providing this plugin with `autoStart: + * false`, which specifies that this plugin only gets activated if no other + * implementation exists, e.g. only when `jupyter_collaboration` is installed. + */ +export const backupCellExecutorPlugin: JupyterFrontEndPlugin = + { + id: '@jupyter/server-documents:backup-cell-executor', + description: + 'Provides a backup default implementation of the notebook cell executor.', + autoStart: false, + provides: INotebookCellExecutor, + activate: (): INotebookCellExecutor => { + return Object.freeze({ runCell }); + } + }; + const plugins: JupyterFrontEndPlugin[] = [ rtcContentProvider, yfile, @@ -291,7 +314,8 @@ const plugins: JupyterFrontEndPlugin[] = [ executionIndicator, kernelStatus, notebookFactoryPlugin, - codemirrorYjsPlugin + codemirrorYjsPlugin, + backupCellExecutorPlugin ]; export default plugins; diff --git a/src/notebook-factory/plugin.ts b/src/notebook-factory/plugin.ts index d759c1d..5867953 100644 --- a/src/notebook-factory/plugin.ts +++ b/src/notebook-factory/plugin.ts @@ -14,7 +14,7 @@ type NotebookFactoryPlugin = * Custom `Notebook` factory plugin. */ export const notebookFactoryPlugin: NotebookFactoryPlugin = { - id: '@jupyter/server-documents/notebook-extension:factory', + id: '@jupyter/server-documents:notebook-factory', description: 'Provides the notebook cell factory.', provides: NotebookPanel.IContentFactory, requires: [IEditorServices], diff --git a/yarn.lock b/yarn.lock index 38ba302..114d864 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2012,7 +2012,7 @@ __metadata: languageName: node linkType: hard -"@jupyter/collaborative-drive@npm:^4.0.2": +"@jupyter/collaborative-drive@npm:^4": version: 4.0.2 resolution: "@jupyter/collaborative-drive@npm:4.0.2" dependencies: @@ -2038,7 +2038,7 @@ __metadata: version: 0.0.0-use.local resolution: "@jupyter/server-documents@workspace:." dependencies: - "@jupyter/collaborative-drive": ^4.0.2 + "@jupyter/collaborative-drive": ^4 "@jupyter/ydoc": ^2.1.3 || ^3.0.0 "@jupyterlab/application": ^4.4.0 "@jupyterlab/apputils": ^4.4.0