Skip to content

Commit 9363e5a

Browse files
Add fork API (jupyterlab#410)
* Add forking API (jupyterlab#394) * Add forking API * Add GET forks of root * Add fork Jupyter events * Replace query parameter merge=1 with merge=true * Add fork title and description * Add js api (jupyterlab#395) * Add forking API (jupyterlab#394) * Add forking API * Add GET forks of root * Add fork Jupyter events * Replace query parameter merge=1 with merge=true * Add fork title and description * Add JS APIs --------- Co-authored-by: David Brochart <[email protected]> * Fix test_fork_handler * again * Allow time for update to propagate --------- Co-authored-by: David Brochart <[email protected]>
1 parent 623ecef commit 9363e5a

File tree

16 files changed

+787
-3
lines changed

16 files changed

+787
-3
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
7+
import {
8+
ForkManager,
9+
IForkManager,
10+
IForkManagerToken
11+
} from '@jupyter/docprovider';
12+
13+
import {
14+
JupyterFrontEnd,
15+
JupyterFrontEndPlugin
16+
} from '@jupyterlab/application';
17+
18+
export const forkManagerPlugin: JupyterFrontEndPlugin<IForkManager> = {
19+
id: '@jupyter/docprovider-extension:forkManager',
20+
autoStart: true,
21+
requires: [ICollaborativeDrive],
22+
provides: IForkManagerToken,
23+
activate: (app: JupyterFrontEnd, drive: ICollaborativeDrive) => {
24+
const eventManager = app.serviceManager.events;
25+
const manager = new ForkManager({ drive, eventManager });
26+
return manager;
27+
}
28+
};

packages/docprovider-extension/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
statusBarTimeline
1717
} from './filebrowser';
1818
import { notebookCellExecutor } from './executor';
19+
import { forkManagerPlugin } from './forkManager';
1920

2021
/**
2122
* Export the plugins as default.
@@ -27,7 +28,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
2728
defaultFileBrowser,
2829
logger,
2930
notebookCellExecutor,
30-
statusBarTimeline
31+
statusBarTimeline,
32+
forkManagerPlugin
3133
];
3234

3335
export default plugins;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) Jupyter Development Team.
2+
// Distributed under the terms of the Modified BSD License.
3+
4+
import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
5+
import {
6+
ForkManager,
7+
JUPYTER_COLLABORATION_FORK_EVENTS_URI
8+
} from '../forkManager';
9+
import { Event } from '@jupyterlab/services';
10+
import { Signal } from '@lumino/signaling';
11+
import { requestAPI } from '../requests';
12+
jest.mock('../requests');
13+
14+
const driveMock = {
15+
name: 'rtc',
16+
providers: new Map()
17+
} as ICollaborativeDrive;
18+
const stream = new Signal({});
19+
const eventManagerMock = {
20+
stream: stream as any
21+
} as Event.IManager;
22+
23+
describe('@jupyter/docprovider', () => {
24+
let manager: ForkManager;
25+
beforeEach(() => {
26+
manager = new ForkManager({
27+
drive: driveMock,
28+
eventManager: eventManagerMock
29+
});
30+
});
31+
describe('forkManager', () => {
32+
it('should have a type', () => {
33+
expect(ForkManager).not.toBeUndefined();
34+
});
35+
it('should be able to create instance', () => {
36+
expect(manager).toBeInstanceOf(ForkManager);
37+
});
38+
it('should be able to create new fork', async () => {
39+
await manager.createFork({
40+
rootId: 'root-uuid',
41+
synchronize: true,
42+
title: 'my fork label',
43+
description: 'my fork description'
44+
});
45+
expect(requestAPI).toHaveBeenCalledWith(
46+
'api/collaboration/fork/root-uuid',
47+
{
48+
method: 'PUT',
49+
body: JSON.stringify({
50+
title: 'my fork label',
51+
description: 'my fork description',
52+
synchronize: true
53+
})
54+
}
55+
);
56+
});
57+
it('should be able to get all forks', async () => {
58+
await manager.getAllForks('root-uuid');
59+
expect(requestAPI).toHaveBeenCalledWith(
60+
'api/collaboration/fork/root-uuid',
61+
{
62+
method: 'GET'
63+
}
64+
);
65+
});
66+
it('should be able to get delete forks', async () => {
67+
await manager.deleteFork({ forkId: 'fork-uuid', merge: true });
68+
expect(requestAPI).toHaveBeenCalledWith(
69+
'api/collaboration/fork/fork-uuid?merge=true',
70+
{
71+
method: 'DELETE'
72+
}
73+
);
74+
});
75+
it('should be able to emit fork added signal', async () => {
76+
const listener = jest.fn();
77+
manager.forkAdded.connect(listener);
78+
const data = {
79+
schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI,
80+
action: 'create'
81+
};
82+
stream.emit(data);
83+
expect(listener).toHaveBeenCalledWith(manager, data);
84+
});
85+
it('should be able to emit fork deleted signal', async () => {
86+
const listener = jest.fn();
87+
manager.forkDeleted.connect(listener);
88+
const data = {
89+
schema_id: JUPYTER_COLLABORATION_FORK_EVENTS_URI,
90+
action: 'delete'
91+
};
92+
stream.emit(data);
93+
expect(listener).toHaveBeenCalledWith(manager, data);
94+
});
95+
});
96+
});

packages/docprovider/src/component.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,6 @@ export const TimelineSliderComponent: React.FC<Props> = ({
7676
setData(data);
7777
setCurrentTimestampIndex(data.timestamps.length - 1);
7878
provider.connectToForkDoc(data.forkRoom, data.sessionId);
79-
8079
sessionRef.current = await requestDocSession(
8180
format,
8281
contentType,
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright (c) Jupyter Development Team.
3+
* Distributed under the terms of the Modified BSD License.
4+
*/
5+
6+
import { ICollaborativeDrive } from '@jupyter/collaborative-drive';
7+
import { URLExt } from '@jupyterlab/coreutils';
8+
import { Event } from '@jupyterlab/services';
9+
import { ISignal, Signal } from '@lumino/signaling';
10+
11+
import { requestAPI, ROOM_FORK_URL } from './requests';
12+
import {
13+
IAllForksResponse,
14+
IForkChangedEvent,
15+
IForkCreationResponse,
16+
IForkManager
17+
} from './tokens';
18+
import { IForkProvider } from './ydrive';
19+
20+
export const JUPYTER_COLLABORATION_FORK_EVENTS_URI =
21+
'https://schema.jupyter.org/jupyter_collaboration/fork/v1';
22+
23+
export class ForkManager implements IForkManager {
24+
constructor(options: ForkManager.IOptions) {
25+
const { drive, eventManager } = options;
26+
this._drive = drive;
27+
this._eventManager = eventManager;
28+
this._eventManager.stream.connect(this._handleEvent, this);
29+
}
30+
31+
get isDisposed(): boolean {
32+
return this._disposed;
33+
}
34+
get forkAdded(): ISignal<ForkManager, IForkChangedEvent> {
35+
return this._forkAddedSignal;
36+
}
37+
get forkDeleted(): ISignal<ForkManager, IForkChangedEvent> {
38+
return this._forkDeletedSignal;
39+
}
40+
41+
dispose(): void {
42+
if (this._disposed) {
43+
return;
44+
}
45+
this._eventManager?.stream.disconnect(this._handleEvent);
46+
this._disposed = true;
47+
}
48+
async createFork(options: {
49+
rootId: string;
50+
synchronize: boolean;
51+
title?: string;
52+
description?: string;
53+
}): Promise<IForkCreationResponse | undefined> {
54+
const { rootId, title, description, synchronize } = options;
55+
const init: RequestInit = {
56+
method: 'PUT',
57+
body: JSON.stringify({ title, description, synchronize })
58+
};
59+
const url = URLExt.join(ROOM_FORK_URL, rootId);
60+
const response = await requestAPI<IForkCreationResponse>(url, init);
61+
return response;
62+
}
63+
64+
async getAllForks(rootId: string) {
65+
const url = URLExt.join(ROOM_FORK_URL, rootId);
66+
const init = { method: 'GET' };
67+
const response = await requestAPI<IAllForksResponse>(url, init);
68+
return response;
69+
}
70+
71+
async deleteFork(options: { forkId: string; merge: boolean }): Promise<void> {
72+
const { forkId, merge } = options;
73+
const url = URLExt.join(ROOM_FORK_URL, forkId);
74+
const query = URLExt.objectToQueryString({ merge });
75+
const init = { method: 'DELETE' };
76+
await requestAPI(`${url}${query}`, init);
77+
}
78+
getProvider(options: {
79+
documentPath: string;
80+
format: string;
81+
type: string;
82+
}): IForkProvider | undefined {
83+
const { documentPath, format, type } = options;
84+
const drive = this._drive;
85+
if (drive) {
86+
const driveName = drive.name;
87+
let docPath = documentPath;
88+
if (documentPath.startsWith(driveName)) {
89+
docPath = documentPath.slice(driveName.length + 1);
90+
}
91+
const provider = drive.providers.get(`${format}:${type}:${docPath}`);
92+
return provider as IForkProvider | undefined;
93+
}
94+
return;
95+
}
96+
97+
private _handleEvent(_: Event.IManager, emission: Event.Emission) {
98+
if (emission.schema_id === JUPYTER_COLLABORATION_FORK_EVENTS_URI) {
99+
switch (emission.action) {
100+
case 'create': {
101+
this._forkAddedSignal.emit(emission as any);
102+
break;
103+
}
104+
case 'delete': {
105+
this._forkDeletedSignal.emit(emission as any);
106+
break;
107+
}
108+
default:
109+
break;
110+
}
111+
}
112+
}
113+
114+
private _disposed = false;
115+
private _drive: ICollaborativeDrive | undefined;
116+
private _eventManager: Event.IManager | undefined;
117+
private _forkAddedSignal = new Signal<ForkManager, IForkChangedEvent>(this);
118+
private _forkDeletedSignal = new Signal<ForkManager, IForkChangedEvent>(this);
119+
}
120+
121+
export namespace ForkManager {
122+
export interface IOptions {
123+
drive: ICollaborativeDrive;
124+
eventManager: Event.IManager;
125+
}
126+
}

packages/docprovider/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ export * from './requests';
1313
export * from './ydrive';
1414
export * from './yprovider';
1515
export * from './TimelineSlider';
16+
export * from './tokens';
17+
export * from './forkManager';

packages/docprovider/src/requests.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ const DOC_SESSION_URL = 'api/collaboration/session';
1414
const DOC_FORK_URL = 'api/collaboration/undo_redo';
1515
const TIMELINE_URL = 'api/collaboration/timeline';
1616

17+
export const ROOM_FORK_URL = 'api/collaboration/fork';
18+
1719
/**
1820
* Document session model
1921
*/
@@ -36,6 +38,45 @@ export interface ISessionModel {
3638
sessionId: string;
3739
}
3840

41+
/**
42+
* Call the API extension
43+
*
44+
* @param endPoint API REST end point for the extension
45+
* @param init Initial values for the request
46+
* @returns The response body interpreted as JSON
47+
*/
48+
export async function requestAPI<T = any>(
49+
endPoint = '',
50+
init: RequestInit = {}
51+
): Promise<T> {
52+
// Make request to Jupyter API
53+
const settings = ServerConnection.makeSettings();
54+
const requestUrl = URLExt.join(settings.baseUrl, endPoint);
55+
56+
let response: Response;
57+
try {
58+
response = await ServerConnection.makeRequest(requestUrl, init, settings);
59+
} catch (error) {
60+
throw new ServerConnection.NetworkError(error as any);
61+
}
62+
63+
let data: any = await response.text();
64+
65+
if (data.length > 0) {
66+
try {
67+
data = JSON.parse(data);
68+
} catch (error) {
69+
console.error('Not a JSON response body.', response);
70+
}
71+
}
72+
73+
if (!response.ok) {
74+
throw new ServerConnection.ResponseError(response, data.message || data);
75+
}
76+
77+
return data;
78+
}
79+
3980
export async function requestDocSession(
4081
format: string,
4182
type: string,

0 commit comments

Comments
 (0)