diff --git a/packages/docprovider-extension/src/filebrowser.ts b/packages/docprovider-extension/src/filebrowser.ts index 4815659c..3c5a3a7e 100644 --- a/packages/docprovider-extension/src/filebrowser.ts +++ b/packages/docprovider-extension/src/filebrowser.ts @@ -55,12 +55,13 @@ export const rtcContentProvider: JupyterFrontEndPlugin { + globalAwareness: Awareness | null, + settingRegistry: ISettingRegistry | null + ): Promise => { const trans = translator.load('jupyter_collaboration'); const defaultDrive = (app.serviceManager.contents as ContentsManager) .defaultDrive; @@ -75,12 +76,17 @@ export const rtcContentProvider: JupyterFrontEndPlugin { + const encoder = encoding.createEncoder(); + encoding.writeVarUint(encoder, RAW_MESSAGE_TYPE); + encoding.writeVarString(encoder, 'save'); + return encoding.toUint8Array(encoder); +})(); + /** * The url for the default drive service. */ @@ -42,6 +53,7 @@ namespace RtcContentProvider { user: User.IManager; trans: TranslationBundle; globalAwareness: Awareness | null; + docmanagerSettings: ISettingRegistry.ISettings | null; } } @@ -57,6 +69,7 @@ export class RtcContentProvider this._serverSettings = options.serverSettings; this.sharedModelFactory = new SharedModelFactory(this._onCreate); this._providers = new Map(); + this._docmanagerSettings = options.docmanagerSettings; } /** @@ -123,7 +136,7 @@ export class RtcContentProvider const provider = this._providers.get(key); if (provider) { - // Save is done from the backend + provider.wsProvider?.ws?.send(SAVE_MESSAGE); const fetchOptions: Contents.IFetchOptions = { type: options.type, format: options.format, @@ -150,6 +163,19 @@ export class RtcContentProvider if (typeof options.format !== 'string') { return; } + // Set initial autosave value, used to determine backend autosave (default: true) + const autosave = + (this._docmanagerSettings?.composite?.['autosave'] as boolean) ?? true; + + sharedModel.awareness.setLocalStateField('autosave', autosave); + + // Watch for changes in settings + this._docmanagerSettings?.changed.connect(() => { + const newAutosave = + (this._docmanagerSettings?.composite?.['autosave'] as boolean) ?? true; + sharedModel.awareness.setLocalStateField('autosave', newAutosave); + }); + try { const provider = new WebSocketProvider({ url: URLExt.join(this._serverSettings.wsUrl, DOCUMENT_PROVIDER_URL), @@ -235,6 +261,7 @@ export class RtcContentProvider private _providers: Map; private _ydriveFileChanged = new Signal(this); private _serverSettings: ServerConnection.ISettings; + private _docmanagerSettings: ISettingRegistry.ISettings | null; } /** diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py index 803246d6..940370a0 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/handlers.py @@ -9,6 +9,7 @@ from logging import Logger from typing import Any from uuid import uuid4 +from typing import cast from jupyter_server.auth import authorized from jupyter_server.base.handlers import APIHandler, JupyterHandler @@ -32,6 +33,8 @@ room_id_from_encoded_path, ) from .websocketserver import JupyterWebsocketServer, RoomNotFound +from .utils import MessageType +from pycrdt import Decoder YFILE = YDOCS["file"] @@ -291,6 +294,18 @@ async def on_message(self, message): """ On message receive. """ + decoder = Decoder(message) + header = decoder.read_var_uint() + if header == MessageType.RAW: + msg = decoder.read_var_string() + if msg == "save": + try: + room = cast(DocumentRoom, self.room) + room._save_to_disc() + except Exception: + self.log.error("Couldn't save content from room: %s", self._room_id) + return + self._message_queue.put_nowait(message) self._websocket_server.ypatch_nb += 1 diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py index 75f18448..a41d69d7 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py @@ -276,7 +276,7 @@ def _inner( last_modified: datetime | None = None, save_delay: float | None = None, store: SQLiteYStore | None = None, - writable: bool = False, + writable: bool = True, ) -> tuple[FakeContentsManager, FileLoader, DocumentRoom]: paths = {id: path} diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py index f4b701db..851293f1 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/rooms.py @@ -247,6 +247,33 @@ def _on_document_change(self, target: str, event: Any) -> None: document. This tasks are debounced (60 seconds by default) so we need to cancel previous tasks before creating a new one. """ + # Collect autosave values from all clients + autosave_states = [ + state.get("autosave", True) + for state in self.awareness.states.values() + if state # skip empty states + ] + + # If no states exist (e.g., during tests), force autosave to be True + if not autosave_states: + autosave_states = [True] + + # Enable autosave if at least one client has it turned on + autosave = any(autosave_states) + + if not autosave: + return + if self._update_lock.locked(): + return + + self._saving_document = asyncio.create_task( + self._maybe_save_document(self._saving_document) + ) + + def _save_to_disc(self): + """ + Called when manual save is triggered. Helpful when autosave is turned off. + """ if self._update_lock.locked(): return @@ -265,7 +292,6 @@ async def _maybe_save_document(self, saving_document: asyncio.Task | None) -> No """ if self._save_delay is None: return - if saving_document is not None and not saving_document.done(): # the document is being saved, cancel that saving_document.cancel() diff --git a/projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py b/projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py index 22c51d87..6d866799 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/utils.py @@ -19,6 +19,7 @@ class MessageType(IntEnum): SYNC = 0 AWARENESS = 1 + RAW = 2 CHAT = 125 diff --git a/tests/test_rooms.py b/tests/test_rooms.py index 3593094a..f78176c3 100644 --- a/tests/test_rooms.py +++ b/tests/test_rooms.py @@ -51,9 +51,7 @@ async def test_defined_save_delay_should_save_content_after_document_change( rtc_create_mock_document_room, ): content = "test" - cm, _, room = rtc_create_mock_document_room( - "test-id", "test.txt", content, save_delay=0.01, writable=True - ) + cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01) await room.initialize() room._document.source = "Test 2" @@ -79,6 +77,50 @@ async def test_undefined_save_delay_should_not_save_content_after_document_chang assert "save" not in cm.actions +async def test_should_not_save_content_when_all_clients_have_autosave_disabled( + rtc_create_mock_document_room, +): + content = "test" + cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01) + + # Disable autosave for all existing clients + for state in room.awareness._states.values(): + if state is not None: + state["autosave"] = False + + # Inject a dummy client with autosave disabled + room.awareness._states[9999] = {"autosave": False} + + await room.initialize() + room._document.source = "Test 2" + + await asyncio.sleep(0.15) + + assert "save" not in cm.actions + + +async def test_should_save_content_when_at_least_one_client_has_autosave_enabled( + rtc_create_mock_document_room, +): + content = "test" + cm, _, room = rtc_create_mock_document_room("test-id", "test.txt", content, save_delay=0.01) + + # Disable autosave for all existing clients + for state in room.awareness._states.values(): + if state is not None: + state["autosave"] = False + + # Inject a dummy client with autosave enabled + room.awareness._states[10000] = {"autosave": True} + + await room.initialize() + room._document.source = "Test 2" + + await asyncio.sleep(0.15) + + assert "save" in cm.actions + + # The following test should be restored when package versions are fixed. # async def test_document_path(rtc_create_mock_document_room):