diff --git a/jupyter_server_documents/outputs/handlers.py b/jupyter_server_documents/outputs/handlers.py index 8e87f56..789cdf1 100644 --- a/jupyter_server_documents/outputs/handlers.py +++ b/jupyter_server_documents/outputs/handlers.py @@ -1,10 +1,7 @@ -import json - from tornado import web from jupyter_server.auth.decorator import authorized from jupyter_server.base.handlers import APIHandler -from jupyter_server.utils import url_path_join class OutputsAPIHandler(APIHandler): @@ -18,7 +15,7 @@ def outputs(self): @web.authenticated @authorized - async def get(self, file_id=None, cell_id=None, output_index=None): + async def get(self, file_id, cell_id=None, output_index=None): try: if output_index: output = self.outputs.get_output(file_id, cell_id, output_index) @@ -35,6 +32,19 @@ async def get(self, file_id=None, cell_id=None, output_index=None): self.write(output) self.finish(set_content_type=content_type) + @web.authenticated + @authorized + async def delete(self, file_id, cell_id=None, output_index=None): + # output_index is accepted but ignored as we clear all cell outputs regardless + try: + self.outputs.clear(file_id, cell_id) + except (FileNotFoundError): + self.set_status(404) + self.finish({"error": "Output not found."}) + else: + self.set_status(200) + self.finish() + class StreamAPIHandler(APIHandler): """An outputs service API handler.""" @@ -69,7 +79,7 @@ async def get(self, file_id=None, cell_id=None): _file_id_regex = r"(?P[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})" # In nbformat, cell_ids follow this format, compatible with uuid4 -_cell_id_regex = rf"(?P[a-zA-Z0-9_-]+)" +_cell_id_regex = r"(?P[a-zA-Z0-9_-]+)" # non-negative integers _output_index_regex = r"(?P0|[1-9]\d*)" diff --git a/jupyter_server_documents/rooms/yroom.py b/jupyter_server_documents/rooms/yroom.py index 73ce477..03f3b8d 100644 --- a/jupyter_server_documents/rooms/yroom.py +++ b/jupyter_server_documents/rooms/yroom.py @@ -294,6 +294,9 @@ def _init_awareness(self, ydoc: pycrdt.Doc) -> pycrdt.Awareness: `awareness.unobserve(self._awareness_subscription)`. """ self._awareness = pycrdt.Awareness(ydoc=ydoc) + if self.room_id != "JupyterLab:globalAwareness": + file_format, file_type, file_id = self.room_id.split(":") + self._awareness.set_local_state_field("file_id", file_id) self._awareness_subscription = self._awareness.observe( self._on_awareness_update ) @@ -325,7 +328,6 @@ def _init_jupyter_ydoc(self, ydoc: pycrdt.Doc, awareness: pycrdt.Awareness) -> Y self._jupyter_ydoc.observe(self._on_jupyter_ydoc_update) return self._jupyter_ydoc - @property def clients(self) -> YjsClientGroup: """ diff --git a/src/notebook-factory/notebook-factory.ts b/src/notebook-factory/notebook-factory.ts index eef61cf..a4b15c5 100644 --- a/src/notebook-factory/notebook-factory.ts +++ b/src/notebook-factory/notebook-factory.ts @@ -5,7 +5,7 @@ import { ICodeCellModel } from '@jupyterlab/cells'; import { IChangedArgs } from '@jupyterlab/coreutils'; -import { Notebook, NotebookPanel } from '@jupyterlab/notebook'; +import { Notebook, NotebookPanel, NotebookActions } from '@jupyterlab/notebook'; import { CellChange, createMutex, ISharedCodeCell } from '@jupyter/ydoc'; import { IOutputAreaModel, OutputAreaModel } from '@jupyterlab/outputarea'; import { requestAPI } from '../handler'; @@ -340,3 +340,52 @@ export class RtcNotebookContentFactory return new ResettableNotebook(options); } } + +// Add a handler for the outputCleared signal +NotebookActions.outputCleared.connect((sender, args) => { + const { notebook, cell } = args; + const cellId = cell.model.sharedModel.getId(); + const awareness = notebook.model?.sharedModel.awareness; + const awarenessStates = awareness?.getStates(); + + // FIRST: Clear outputs in YDoc for immediate real-time sync to all clients + try { + const sharedCodeCell = cell.model.sharedModel as ISharedCodeCell; + sharedCodeCell.setOutputs([]); + console.debug(`Cleared outputs in YDoc for cell ${cellId}`); + } catch (error: unknown) { + console.error('Error clearing YDoc outputs:', error); + } + + if (awarenessStates?.size === 0) { + console.log('Could not delete cell output, awareness is not present'); + return; // Early return since we can't get fileId without awareness + } + + let fileId = null; + for (const [_, state] of awarenessStates || []) { + if (state && 'file_id' in state) { + fileId = state['file_id']; + } + } + + if (fileId === null) { + console.error('No fileId found in awareness'); + return; // Early return since we can't make API call without fileId + } + + // SECOND: Send API request to clear outputs from disk storage + try { + requestAPI(`/api/outputs/${fileId}/${cellId}`, { + method: 'DELETE' + }) + .then(() => { + console.debug(`Successfully cleared outputs from disk for cell ${cellId}`); + }) + .catch((error: Error) => { + console.error(`Failed to clear outputs from disk for cell ${cellId}:`, error); + }); + } catch (error: unknown) { + console.error('Error in disk output clearing process:', error); + } +});