Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fork API #410

Merged
merged 5 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/docprovider-extension/src/forkManager.ts
Original file line number Diff line number Diff line change
@@ -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<IForkManager> = {
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;
}
};
4 changes: 3 additions & 1 deletion packages/docprovider-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
statusBarTimeline
} from './filebrowser';
import { notebookCellExecutor } from './executor';
import { forkManagerPlugin } from './forkManager';

/**
* Export the plugins as default.
Expand All @@ -27,7 +28,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
defaultFileBrowser,
logger,
notebookCellExecutor,
statusBarTimeline
statusBarTimeline,
forkManagerPlugin
];

export default plugins;
96 changes: 96 additions & 0 deletions packages/docprovider/src/__tests__/forkManager.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
1 change: 0 additions & 1 deletion packages/docprovider/src/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export const TimelineSliderComponent: React.FC<Props> = ({
setData(data);
setCurrentTimestampIndex(data.timestamps.length - 1);
provider.connectToForkDoc(data.forkRoom, data.sessionId);

sessionRef.current = await requestDocSession(
format,
contentType,
Expand Down
126 changes: 126 additions & 0 deletions packages/docprovider/src/forkManager.ts
Original file line number Diff line number Diff line change
@@ -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<ForkManager, IForkChangedEvent> {
return this._forkAddedSignal;
}
get forkDeleted(): ISignal<ForkManager, IForkChangedEvent> {
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<IForkCreationResponse | undefined> {
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<IForkCreationResponse>(url, init);
return response;
}

async getAllForks(rootId: string) {
const url = URLExt.join(ROOM_FORK_URL, rootId);
const init = { method: 'GET' };
const response = await requestAPI<IAllForksResponse>(url, init);
return response;
}

async deleteFork(options: { forkId: string; merge: boolean }): Promise<void> {
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<ForkManager, IForkChangedEvent>(this);
private _forkDeletedSignal = new Signal<ForkManager, IForkChangedEvent>(this);
}

export namespace ForkManager {
export interface IOptions {
drive: ICollaborativeDrive;
eventManager: Event.IManager;
}
}
2 changes: 2 additions & 0 deletions packages/docprovider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ export * from './requests';
export * from './ydrive';
export * from './yprovider';
export * from './TimelineSlider';
export * from './tokens';
export * from './forkManager';
41 changes: 41 additions & 0 deletions packages/docprovider/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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<T = any>(
endPoint = '',
init: RequestInit = {}
): Promise<T> {
// 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,
Expand Down
Loading
Loading