From fbbdf9e3f5b5bcc20488a96474fd85ad510aef9f Mon Sep 17 00:00:00 2001 From: David Brochart Date: Thu, 5 Sep 2024 09:28:20 +0200 Subject: [PATCH] Register RTC content provider --- .../docprovider-extension/src/filebrowser.ts | 174 ++---------------- packages/docprovider-extension/src/index.ts | 10 +- packages/docprovider/src/tokens.ts | 19 +- packages/docprovider/src/ydrive.ts | 122 +++++++++++- 4 files changed, 138 insertions(+), 187 deletions(-) diff --git a/packages/docprovider-extension/src/filebrowser.ts b/packages/docprovider-extension/src/filebrowser.ts index 4a786b72..9830c151 100644 --- a/packages/docprovider-extension/src/filebrowser.ts +++ b/packages/docprovider-extension/src/filebrowser.ts @@ -3,97 +3,50 @@ * Distributed under the terms of the Modified BSD License. */ +import { Drive } from '@jupyterlab/services'; import { - ILabShell, - IRouter, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; import { Dialog, showDialog } from '@jupyterlab/apputils'; import { IDocumentWidget } from '@jupyterlab/docregistry'; -import { - FileBrowser, - IDefaultFileBrowser, - IFileBrowserFactory -} from '@jupyterlab/filebrowser'; import { IEditorTracker } from '@jupyterlab/fileeditor'; import { ILogger, ILoggerRegistry } from '@jupyterlab/logconsole'; import { INotebookTracker } from '@jupyterlab/notebook'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; -import { CommandRegistry } from '@lumino/commands'; - import { YFile, YNotebook } from '@jupyter/ydoc'; import { - ICollaborativeDrive, IGlobalAwareness, - YDrive + RtcContentProvider } from '@jupyter/docprovider'; import { Awareness } from 'y-protocols/awareness'; /** - * The command IDs used by the file browser plugin. - */ -namespace CommandIDs { - export const openPath = 'filebrowser:open-path'; -} - -/** - * The default collaborative drive provider. + * The RTC content provider. */ -export const drive: JupyterFrontEndPlugin = { - id: '@jupyter/docprovider-extension:drive', - description: 'The default collaborative drive provider', - provides: ICollaborativeDrive, +export const rtcContentProvider: JupyterFrontEndPlugin = { + id: '@jupyter/docprovider-extension:content', + description: 'The RTC content provider', + autoStart: true, requires: [ITranslator], - optional: [IGlobalAwareness], + optional: [IGlobalAwareness, ISettingRegistry], activate: ( app: JupyterFrontEnd, translator: ITranslator, - globalAwareness: Awareness | null - ): ICollaborativeDrive => { + globalAwareness: Awareness | null, + settingRegistry: ISettingRegistry | null + ): void => { const trans = translator.load('jupyter_collaboration'); - const drive = new YDrive(app.serviceManager.user, trans, globalAwareness); - app.serviceManager.contents.addDrive(drive); - return drive; - } -}; + const rtcContentProvider = new RtcContentProvider(app.serviceManager.user, trans, globalAwareness); -/** - * Plugin to register the shared model factory for the content type 'file'. - */ -export const yfile: JupyterFrontEndPlugin = { - id: '@jupyter/docprovider-extension:yfile', - description: - "Plugin to register the shared model factory for the content type 'file'", - autoStart: true, - requires: [ICollaborativeDrive], - optional: [], - activate: (app: JupyterFrontEnd, drive: ICollaborativeDrive): void => { const yFileFactory = () => { return new YFile(); }; - drive.sharedModelFactory.registerDocumentFactory('file', yFileFactory); - } -}; + rtcContentProvider.sharedModelFactory.registerDocumentFactory('file', yFileFactory); -/** - * Plugin to register the shared model factory for the content type 'notebook'. - */ -export const ynotebook: JupyterFrontEndPlugin = { - id: '@jupyter/docprovider-extension:ynotebook', - description: - "Plugin to register the shared model factory for the content type 'notebook'", - autoStart: true, - requires: [ICollaborativeDrive], - optional: [ISettingRegistry], - activate: ( - app: JupyterFrontEnd, - drive: ICollaborativeDrive, - settingRegistry: ISettingRegistry | null - ): void => { let disableDocumentWideUndoRedo = true; // Fetch settings if possible. @@ -121,54 +74,12 @@ export const ynotebook: JupyterFrontEndPlugin = { disableDocumentWideUndoRedo }); }; - drive.sharedModelFactory.registerDocumentFactory( + rtcContentProvider.sharedModelFactory.registerDocumentFactory( 'notebook', yNotebookFactory ); - } -}; - -/** - * The default file browser factory provider. - */ -export const defaultFileBrowser: JupyterFrontEndPlugin = { - id: '@jupyter/docprovider-extension:defaultFileBrowser', - description: 'The default file browser factory provider', - provides: IDefaultFileBrowser, - requires: [ICollaborativeDrive, IFileBrowserFactory], - optional: [ - IRouter, - JupyterFrontEnd.ITreeResolver, - ILabShell, - ISettingRegistry - ], - activate: async ( - app: JupyterFrontEnd, - drive: ICollaborativeDrive, - fileBrowserFactory: IFileBrowserFactory, - router: IRouter | null, - tree: JupyterFrontEnd.ITreeResolver | null, - labShell: ILabShell | null - ): Promise => { - const { commands } = app; - - app.serviceManager.contents.addDrive(drive); - - // Manually restore and load the default file browser. - const defaultBrowser = fileBrowserFactory.createFileBrowser('filebrowser', { - auto: false, - restore: false, - driveName: drive.name - }); - void Private.restoreBrowser( - defaultBrowser, - commands, - router, - tree, - labShell - ); - return defaultBrowser; + Drive.getContentProviderRegistry().register(rtcContentProvider); } }; @@ -265,58 +176,3 @@ export const logger: JupyterFrontEndPlugin = { })(); } }; - -namespace Private { - /** - * Restores file browser state and overrides state if tree resolver resolves. - */ - export async function restoreBrowser( - browser: FileBrowser, - commands: CommandRegistry, - router: IRouter | null, - tree: JupyterFrontEnd.ITreeResolver | null, - labShell: ILabShell | null - ): Promise { - const restoring = 'jp-mod-restoring'; - - browser.addClass(restoring); - - if (!router) { - await browser.model.restore(browser.id); - await browser.model.refresh(); - browser.removeClass(restoring); - return; - } - - const listener = async () => { - router.routed.disconnect(listener); - - const paths = await tree?.paths; - if (paths?.file || paths?.browser) { - // Restore the model without populating it. - await browser.model.restore(browser.id, false); - if (paths.file) { - await commands.execute(CommandIDs.openPath, { - path: paths.file, - dontShowBrowser: true - }); - } - if (paths.browser) { - await commands.execute(CommandIDs.openPath, { - path: paths.browser, - dontShowBrowser: true - }); - } - } else { - await browser.model.restore(browser.id); - await browser.model.refresh(); - } - browser.removeClass(restoring); - - if (labShell?.isEmpty('main')) { - void commands.execute('launcher:create'); - } - }; - router.routed.connect(listener); - } -} diff --git a/packages/docprovider-extension/src/index.ts b/packages/docprovider-extension/src/index.ts index 616c50f7..635a47a4 100644 --- a/packages/docprovider-extension/src/index.ts +++ b/packages/docprovider-extension/src/index.ts @@ -8,10 +8,7 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; import { - drive, - yfile, - ynotebook, - defaultFileBrowser, + rtcContentProvider, logger } from './filebrowser'; import { notebookCellExecutor } from './executor'; @@ -20,10 +17,7 @@ import { notebookCellExecutor } from './executor'; * Export the plugins as default. */ const plugins: JupyterFrontEndPlugin[] = [ - drive, - yfile, - ynotebook, - defaultFileBrowser, + rtcContentProvider, logger, notebookCellExecutor ]; diff --git a/packages/docprovider/src/tokens.ts b/packages/docprovider/src/tokens.ts index 8ac32f4a..763dfc8c 100644 --- a/packages/docprovider/src/tokens.ts +++ b/packages/docprovider/src/tokens.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { DocumentChange, IAwareness, YDocument } from '@jupyter/ydoc'; +import { IAwareness } from '@jupyter/ydoc'; import { Contents } from '@jupyterlab/services'; import { Token } from '@lumino/coreutils'; @@ -23,13 +23,6 @@ export const IGlobalAwareness = new Token( '@jupyter/collaboration:IGlobalAwareness' ); -/** - * A document factory for registering shared models - */ -export type SharedDocumentFactory = ( - options: Contents.ISharedFactoryOptions -) => YDocument; - /** * A Collaborative implementation for an `IDrive`, talking to the * server using the Jupyter REST API and a WebSocket connection. @@ -45,16 +38,6 @@ export interface ICollaborativeDrive extends Contents.IDrive { * Yjs sharedModel factory for real-time collaboration. */ export interface ISharedModelFactory extends Contents.ISharedFactory { - /** - * Register a SharedDocumentFactory. - * - * @param type Document type - * @param factory Document factory - */ - registerDocumentFactory( - type: Contents.ContentType, - factory: SharedDocumentFactory - ): void; } /** diff --git a/packages/docprovider/src/ydrive.ts b/packages/docprovider/src/ydrive.ts index 08d137dd..0356bc0b 100644 --- a/packages/docprovider/src/ydrive.ts +++ b/packages/docprovider/src/ydrive.ts @@ -3,7 +3,7 @@ import { PageConfig, URLExt } from '@jupyterlab/coreutils'; import { TranslationBundle } from '@jupyterlab/translation'; -import { Contents, Drive, User } from '@jupyterlab/services'; +import { ContentProviderExtensions, Contents, Drive, IContentProvider, RestContentProvider, SharedDocumentFactory, User } from '@jupyterlab/services'; import { ISignal, Signal } from '@lumino/signaling'; import { DocumentChange, ISharedDocument, YDocument } from '@jupyter/ydoc'; @@ -12,7 +12,6 @@ import { WebSocketProvider } from './yprovider'; import { ICollaborativeDrive, ISharedModelFactory, - SharedDocumentFactory } from './tokens'; import { Awareness } from 'y-protocols/awareness'; @@ -24,6 +23,125 @@ const DISABLE_RTC = */ const DOCUMENT_PROVIDER_URL = 'api/collaboration/room'; +export class RtcContentProvider extends RestContentProvider implements IContentProvider { + + constructor( + user: User.IManager, + translator: TranslationBundle, + globalAwareness: Awareness | null + ) { + super(); + this._user = user; + this._trans = translator; + this._globalAwareness = globalAwareness; + this.sharedModelFactory = new SharedModelFactory(this._onCreate); + this.name = ''; + this._providers = new Map(); + } + + /** + * SharedModel factory for the YDrive. + */ + readonly sharedModelFactory: ISharedModelFactory; + + public get extensions(): ContentProviderExtensions { + return [{re: '.*', score: 2}]; + } + + async get( + localPath: string, + options?: Contents.IFetchOptions + ): Promise { + console.log('RTC get'); + if (options && options.format && options.type) { + const key = `${options.format}:${options.type}:${localPath}`; + const provider = this._providers.get(key); + + if (provider) { + // If the document doesn't exist, `super.get` will reject with an + // error and the provider will never be resolved. + // Use `Promise.all` to reject as soon as possible. The Context will + // show a dialog to the user. + const [model] = await Promise.all([ + super.get(localPath, { ...options, content: false }), + provider.ready + ]); + return model; + } + } + + const model = await super.get(localPath, options); + return model; + } + + private _onCreate = ( + options: Contents.ISharedFactoryOptions, + sharedModel: YDocument + ) => { + console.log('_onCreate'); + if (typeof options.format !== 'string') { + return; + } + try { + const provider = new WebSocketProvider({ + url: URLExt.join(this.serverSettings.wsUrl, DOCUMENT_PROVIDER_URL), + path: options.path, + format: options.format, + contentType: options.contentType, + model: sharedModel, + user: this._user, + translator: this._trans + }); + + // Add the document path in the list of opened ones for this user. + const state = this._globalAwareness?.getLocalState() || {}; + const documents: any[] = state.documents || []; + if (!documents.includes(options.path)) { + documents.push(`${this.name}:${options.path}`); + this._globalAwareness?.setLocalStateField('documents', documents); + } + + const key = `${options.format}:${options.contentType}:${options.path}`; + this._providers.set(key, provider); + + sharedModel.changed.connect(async (_, change) => { + if (!change.stateChange) { + return; + } + }); + + sharedModel.disposed.connect(() => { + const provider = this._providers.get(key); + if (provider) { + provider.dispose(); + this._providers.delete(key); + } + + // Remove the document path from the list of opened ones for this user. + const state = this._globalAwareness?.getLocalState() || {}; + const documents: any[] = state.documents || []; + const index = documents.indexOf(`${this.name}:${options.path}`); + if (index > -1) { + documents.splice(index, 1); + } + this._globalAwareness?.setLocalStateField('documents', documents); + }); + } catch (error) { + // Falling back to the contents API if opening the websocket failed + // This may happen if the shared document is not a YDocument. + console.error( + `Failed to open websocket connection for ${options.path}.\n:${error}` + ); + } + }; + + private _user: User.IManager; + private _trans: TranslationBundle; + private _globalAwareness: Awareness | null; + private _providers: Map; + name: string; +} + /** * A Collaborative implementation for an `IDrive`, talking to the * server using the Jupyter REST API and a WebSocket connection.