diff --git a/packages/docprovider-extension/src/forkManager.ts b/packages/docprovider-extension/src/forkManager.ts new file mode 100644 index 00000000..5c2cf916 --- /dev/null +++ b/packages/docprovider-extension/src/forkManager.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { ICollaborativeDrive } from '@jupyter/collaborative-drive'; +import { + ForkManager, + IForkManager, + IForkManagerToken +} from '@jupyter/docprovider'; + +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; + +export const forkManagerPlugin: JupyterFrontEndPlugin = { + id: '@jupyter/docprovider-extension:forkManager', + autoStart: true, + requires: [ICollaborativeDrive], + provides: IForkManagerToken, + activate: (app: JupyterFrontEnd, drive: ICollaborativeDrive) => { + const eventManager = app.serviceManager.events; + const manager = new ForkManager({ drive, eventManager }); + return manager; + } +}; diff --git a/packages/docprovider-extension/src/index.ts b/packages/docprovider-extension/src/index.ts index 1869c5b7..556f7470 100644 --- a/packages/docprovider-extension/src/index.ts +++ b/packages/docprovider-extension/src/index.ts @@ -16,6 +16,7 @@ import { statusBarTimeline } from './filebrowser'; import { notebookCellExecutor } from './executor'; +import { forkManagerPlugin } from './forkManager'; /** * Export the plugins as default. @@ -27,7 +28,8 @@ const plugins: JupyterFrontEndPlugin[] = [ defaultFileBrowser, logger, notebookCellExecutor, - statusBarTimeline + statusBarTimeline, + forkManagerPlugin ]; export default plugins; diff --git a/packages/docprovider/src/__tests__/forkManager.spec.ts b/packages/docprovider/src/__tests__/forkManager.spec.ts new file mode 100644 index 00000000..c664a36d --- /dev/null +++ b/packages/docprovider/src/__tests__/forkManager.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { ICollaborativeDrive } from '@jupyter/collaborative-drive'; +import { + ForkManager, + JUPYTER_COLLABORATION_FORK_EVENTS_URI +} from '../forkManager'; +import { Event } from '@jupyterlab/services'; +import { Signal } from '@lumino/signaling'; +import { requestAPI } from '../requests'; +jest.mock('../requests'); + +const driveMock = { + name: 'rtc', + providers: new Map() +} as ICollaborativeDrive; +const stream = new Signal({}); +const eventManagerMock = { + stream: stream as any +} as Event.IManager; + +describe('@jupyter/docprovider', () => { + let manager: ForkManager; + beforeEach(() => { + manager = new ForkManager({ + drive: driveMock, + eventManager: eventManagerMock + }); + }); + describe('forkManager', () => { + it('should have a type', () => { + expect(ForkManager).not.toBeUndefined(); + }); + it('should be able to create instance', () => { + expect(manager).toBeInstanceOf(ForkManager); + }); + it('should be able to create new fork', async () => { + await manager.createFork({ + rootId: 'root-uuid', + synchronize: true, + title: 'my fork label', + description: 'my fork description' + }); + expect(requestAPI).toHaveBeenCalledWith( + 'api/collaboration/fork/root-uuid', + { + method: 'PUT', + body: JSON.stringify({ + title: 'my fork label', + description: 'my fork description', + synchronize: true + }) + } + ); + }); + it('should be able to get all forks', async () => { + await manager.getAllForks('root-uuid'); + expect(requestAPI).toHaveBeenCalledWith( + 'api/collaboration/fork/root-uuid', + { + method: 'GET' + } + ); + }); + it('should be able to get delete forks', async () => { + await manager.deleteFork({ forkId: 'fork-uuid', merge: true }); + expect(requestAPI).toHaveBeenCalledWith( + 'api/collaboration/fork/fork-uuid?merge=true', + { + method: 'DELETE' + } + ); + }); + it('should be able to emit fork added signal', async () => { + const listener = jest.fn(); + manager.forkAdded.connect(listener); + const data = { + schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI, + action: 'create' + }; + stream.emit(data); + expect(listener).toHaveBeenCalledWith(manager, data); + }); + it('should be able to emit fork deleted signal', async () => { + const listener = jest.fn(); + manager.forkDeleted.connect(listener); + const data = { + schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI, + action: 'delete' + }; + stream.emit(data); + expect(listener).toHaveBeenCalledWith(manager, data); + }); + }); +}); diff --git a/packages/docprovider/src/component.tsx b/packages/docprovider/src/component.tsx index 13351aa3..5a6d4475 100644 --- a/packages/docprovider/src/component.tsx +++ b/packages/docprovider/src/component.tsx @@ -76,7 +76,6 @@ export const TimelineSliderComponent: React.FC = ({ setData(data); setCurrentTimestampIndex(data.timestamps.length - 1); provider.connectToForkDoc(data.forkRoom, data.sessionId); - sessionRef.current = await requestDocSession( format, contentType, diff --git a/packages/docprovider/src/forkManager.ts b/packages/docprovider/src/forkManager.ts new file mode 100644 index 00000000..f781e802 --- /dev/null +++ b/packages/docprovider/src/forkManager.ts @@ -0,0 +1,126 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { ICollaborativeDrive } from '@jupyter/collaborative-drive'; +import { URLExt } from '@jupyterlab/coreutils'; +import { Event } from '@jupyterlab/services'; +import { ISignal, Signal } from '@lumino/signaling'; + +import { requestAPI, ROOM_FORK_URL } from './requests'; +import { + IAllForksResponse, + IForkChangedEvent, + IForkCreationResponse, + IForkManager +} from './tokens'; +import { IForkProvider } from './ydrive'; + +export const JUPYTER_COLLABORATION_FORK_EVENTS_URI = + 'https://schema.jupyter.org/jupyter_collaboration/fork/v1'; + +export class ForkManager implements IForkManager { + constructor(options: ForkManager.IOptions) { + const { drive, eventManager } = options; + this._drive = drive; + this._eventManager = eventManager; + this._eventManager.stream.connect(this._handleEvent, this); + } + + get isDisposed(): boolean { + return this._disposed; + } + get forkAdded(): ISignal { + return this._forkAddedSignal; + } + get forkDeleted(): ISignal { + return this._forkDeletedSignal; + } + + dispose(): void { + if (this._disposed) { + return; + } + this._eventManager?.stream.disconnect(this._handleEvent); + this._disposed = true; + } + async createFork(options: { + rootId: string; + synchronize: boolean; + title?: string; + description?: string; + }): Promise { + const { rootId, title, description, synchronize } = options; + const init: RequestInit = { + method: 'PUT', + body: JSON.stringify({ title, description, synchronize }) + }; + const url = URLExt.join(ROOM_FORK_URL, rootId); + const response = await requestAPI(url, init); + return response; + } + + async getAllForks(rootId: string) { + const url = URLExt.join(ROOM_FORK_URL, rootId); + const init = { method: 'GET' }; + const response = await requestAPI(url, init); + return response; + } + + async deleteFork(options: { forkId: string; merge: boolean }): Promise { + const { forkId, merge } = options; + const url = URLExt.join(ROOM_FORK_URL, forkId); + const query = URLExt.objectToQueryString({ merge }); + const init = { method: 'DELETE' }; + await requestAPI(`${url}${query}`, init); + } + getProvider(options: { + documentPath: string; + format: string; + type: string; + }): IForkProvider | undefined { + const { documentPath, format, type } = options; + const drive = this._drive; + if (drive) { + const driveName = drive.name; + let docPath = documentPath; + if (documentPath.startsWith(driveName)) { + docPath = documentPath.slice(driveName.length + 1); + } + const provider = drive.providers.get(`${format}:${type}:${docPath}`); + return provider as IForkProvider | undefined; + } + return; + } + + private _handleEvent(_: Event.IManager, emission: Event.Emission) { + if (emission.schema_id === JUPYTER_COLLABORATION_FORK_EVENTS_URI) { + switch (emission.action) { + case 'create': { + this._forkAddedSignal.emit(emission as any); + break; + } + case 'delete': { + this._forkDeletedSignal.emit(emission as any); + break; + } + default: + break; + } + } + } + + private _disposed = false; + private _drive: ICollaborativeDrive | undefined; + private _eventManager: Event.IManager | undefined; + private _forkAddedSignal = new Signal(this); + private _forkDeletedSignal = new Signal(this); +} + +export namespace ForkManager { + export interface IOptions { + drive: ICollaborativeDrive; + eventManager: Event.IManager; + } +} diff --git a/packages/docprovider/src/index.ts b/packages/docprovider/src/index.ts index b3cd6572..178b7984 100644 --- a/packages/docprovider/src/index.ts +++ b/packages/docprovider/src/index.ts @@ -13,3 +13,5 @@ export * from './requests'; export * from './ydrive'; export * from './yprovider'; export * from './TimelineSlider'; +export * from './tokens'; +export * from './forkManager'; diff --git a/packages/docprovider/src/requests.ts b/packages/docprovider/src/requests.ts index 51a6ece3..0e374721 100644 --- a/packages/docprovider/src/requests.ts +++ b/packages/docprovider/src/requests.ts @@ -14,6 +14,8 @@ const DOC_SESSION_URL = 'api/collaboration/session'; const DOC_FORK_URL = 'api/collaboration/undo_redo'; const TIMELINE_URL = 'api/collaboration/timeline'; +export const ROOM_FORK_URL = 'api/collaboration/fork'; + /** * Document session model */ @@ -36,6 +38,45 @@ export interface ISessionModel { sessionId: string; } +/** + * Call the API extension + * + * @param endPoint API REST end point for the extension + * @param init Initial values for the request + * @returns The response body interpreted as JSON + */ +export async function requestAPI( + endPoint = '', + init: RequestInit = {} +): Promise { + // Make request to Jupyter API + const settings = ServerConnection.makeSettings(); + const requestUrl = URLExt.join(settings.baseUrl, endPoint); + + let response: Response; + try { + response = await ServerConnection.makeRequest(requestUrl, init, settings); + } catch (error) { + throw new ServerConnection.NetworkError(error as any); + } + + let data: any = await response.text(); + + if (data.length > 0) { + try { + data = JSON.parse(data); + } catch (error) { + console.error('Not a JSON response body.', response); + } + } + + if (!response.ok) { + throw new ServerConnection.ResponseError(response, data.message || data); + } + + return data; +} + export async function requestDocSession( format: string, type: string, diff --git a/packages/docprovider/src/tokens.ts b/packages/docprovider/src/tokens.ts new file mode 100644 index 00000000..c76b29cb --- /dev/null +++ b/packages/docprovider/src/tokens.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Token } from '@lumino/coreutils'; +import { IDisposable } from '@lumino/disposable'; +import { ISignal } from '@lumino/signaling'; +import { IForkProvider } from './ydrive'; +export interface IForkInfo { + description?: string; + root_roomid: string; + synchronize: boolean; + title?: string; +} + +export interface IForkCreationResponse { + fork_info: IForkInfo; + fork_roomid: string; + sessionId: string; +} + +export interface IAllForksResponse { + [forkId: string]: IForkInfo; +} + +export interface IForkChangedEvent { + fork_info: IForkInfo; + fork_roomid: string; + username?: string; +} + +/** + * Interface representing a Fork Manager that manages forked documents and + * provides signals for fork-related events. + * + * @interface IForkManager + * @extends IDisposable + */ +export interface IForkManager extends IDisposable { + /** + * Get the fork provider of a given document. + * + * @param options.documentPath - The document path including the + * drive prefix. + * @param options.format - Format of the document. + * @param options.type - Content type of the document. + * @returns The fork provider of the document. + */ + getProvider(options: { + documentPath: string; + format: string; + type: string; + }): IForkProvider | undefined; + + /** + * Creates a new fork for a given document. + * + * @param options.rootId - The ID of the root document to fork. + * @param options.synchronize - A flag indicating whether the fork should be kept + * synchronized with the root document. + * @param options.title - An optional label for the fork. + * @param options.description - An optional description for the fork. + * + * @returns A promise that resolves to an `IForkCreationResponse` if the fork + * is created successfully, or `undefined` if the creation fails. + */ + createFork(options: { + rootId: string; + synchronize: boolean; + title?: string; + description?: string; + }): Promise; + + /** + * Retrieves all forks associated with a specific document. + * + * @param documentId - The ID of the document for which forks are to be retrieved. + * + * @returns A promise that resolves to an `IAllForksResponse` containing information about all forks. + */ + getAllForks(documentId: string): Promise; + + /** + * Deletes a specified fork and optionally merges its changes. + * + * @param options - Options for deleting the fork. + * @param options.forkId - The ID of the fork to be deleted. + * @param options.merge - A flag indicating whether changes from the fork should be merged back into the root document. + * + * @returns A promise that resolves when the fork is successfully deleted. + */ + deleteFork(options: { forkId: string; merge: boolean }): Promise; + + /** + * Signal emitted when a new fork is added. + * + * @event forkAdded + * @type ISignal + */ + forkAdded: ISignal; + + /** + * Signal emitted when a fork is deleted. + * + * @event forkDeleted + * @type ISignal + */ + forkDeleted: ISignal; +} + +/** + * Token providing a fork manager instance. + */ +export const IForkManagerToken = new Token( + '@jupyter/docprovider:IForkManagerToken' +); diff --git a/packages/docprovider/src/ydrive.ts b/packages/docprovider/src/ydrive.ts index 96a9106c..68a95723 100644 --- a/packages/docprovider/src/ydrive.ts +++ b/packages/docprovider/src/ydrive.ts @@ -308,7 +308,6 @@ class SharedModelFactory implements ISharedModelFactory { // the `sharedModel` will be the default one. return; } - if (this.documentFactories.has(options.contentType)) { const factory = this.documentFactories.get(options.contentType)!; const sharedModel = factory(options); 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 7ac9b18f..a916c975 100644 --- a/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py +++ b/projects/jupyter-server-ydoc/jupyter_server_ydoc/pytest_plugin.py @@ -157,7 +157,7 @@ async def _inner(format: str, type: str, path: str) -> Any: @pytest.fixture def rtc_connect_fork_client(jp_http_port, jp_base_url, rtc_fetch_session): async def _inner(room_id: str) -> Any: - return connect( + return aconnect_ws( f"ws://127.0.0.1:{jp_http_port}{jp_base_url}api/collaboration/room/{room_id}" )