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

Support suggestions #292

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
246 changes: 241 additions & 5 deletions packages/collaboration-extension/src/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,43 @@
* @module collaboration-extension
*/

import {
DocumentRegistry
} from '@jupyterlab/docregistry';

import {
NotebookPanel, INotebookModel
} from '@jupyterlab/notebook';

import {
IDisposable, DisposableDelegate
} from '@lumino/disposable';

import { CommandRegistry } from '@lumino/commands';

import {
JupyterFrontEnd,
JupyterFrontEndPlugin
} from '@jupyterlab/application';
import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
import { Dialog, IToolbarWidgetRegistry } from '@jupyterlab/apputils';
import {
EditorExtensionRegistry,
IEditorExtensionRegistry
} from '@jupyterlab/codemirror';
import { WebSocketAwarenessProvider } from '@jupyter/docprovider';
import { SidePanel, usersIcon } from '@jupyterlab/ui-components';
import { requestDocDelete, requestDocMerge, WebSocketAwarenessProvider } from '@jupyter/docprovider';
import {
SidePanel,
usersIcon,
caretDownIcon
} from '@jupyterlab/ui-components';
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import { IStateDB, StateDB } from '@jupyterlab/statedb';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';
import { ITranslator, nullTranslator, TranslationBundle } from '@jupyterlab/translation';

import { Menu, MenuBar } from '@lumino/widgets';

import { IAwareness } from '@jupyter/ydoc';
import { IAwareness, ISharedNotebook, NotebookChange } from '@jupyter/ydoc';

import {
CollaboratorsPanel,
Expand Down Expand Up @@ -189,3 +207,221 @@ export const userEditorCursors: JupyterFrontEndPlugin<void> = {
});
}
};

/**
* A plugin to add editing mode to the notebook page
*/
export const editingMode: JupyterFrontEndPlugin<void> = {
id: '@jupyter/collaboration-extension:editingMode',
description: 'A plugin to add editing mode to the notebook page.',
autoStart: true,
optional: [ITranslator],
activate: (
app: JupyterFrontEnd,
translator: ITranslator | null
) => {
app.docRegistry.addWidgetExtension('Notebook', new EditingModeExtension(translator));
},
};

export class EditingModeExtension implements DocumentRegistry.IWidgetExtension<NotebookPanel, INotebookModel> {
private _trans: TranslationBundle;

constructor(translator: ITranslator | null) {
this._trans = (translator ?? nullTranslator).load('jupyter_collaboration');
}

createNew(
panel: NotebookPanel,
context: DocumentRegistry.IContext<INotebookModel>
): IDisposable {
const editingMenubar = new MenuBar();
const suggestionMenubar = new MenuBar();
const reviewMenubar = new MenuBar();

const editingCommands = new CommandRegistry();
const suggestionCommands = new CommandRegistry();
const reviewCommands = new CommandRegistry();

const editingMenu = new Menu({ commands: editingCommands });
const suggestionMenu = new Menu({ commands: suggestionCommands });
const reviewMenu = new Menu({ commands: reviewCommands });

const sharedModel = context.model.sharedModel;
const suggestions: {[key: string]: Menu.IItem} = {};
var myForkId = ''; // curently allows only one suggestion per user

editingMenu.title.label = 'Editing';
editingMenu.title.icon = caretDownIcon;

suggestionMenu.title.label = 'Root';
suggestionMenu.title.icon = caretDownIcon;

reviewMenu.title.label = 'Review';
reviewMenu.title.icon = caretDownIcon;

editingCommands.addCommand('editing', {
label: 'Editing',
execute: () => {
editingMenu.title.label = 'Editing';
suggestionMenu.title.label = 'Root';
open_dialog('Editing', this._trans);
}
});
editingCommands.addCommand('suggesting', {
label: 'Suggesting',
execute: () => {
editingMenu.title.label = 'Suggesting';
reviewMenu.clearItems();
if (myForkId === '') {
myForkId = 'pending';
sharedModel.provider.fork().then(newForkId => {
myForkId = newForkId;
sharedModel.provider.connect(newForkId);
suggestionMenu.title.label = newForkId;
});
}
else {
suggestionMenu.title.label = myForkId;
sharedModel.provider.connect(myForkId);
}
open_dialog('Suggesting', this._trans);
}
});

suggestionCommands.addCommand('root', {
label: 'Root',
execute: () => {
// we cannot review the root document
reviewMenu.clearItems();
suggestionMenu.title.label = 'Root';
editingMenu.title.label = 'Editing';
sharedModel.provider.connect(sharedModel.rootRoomId);
open_dialog('Editing', this._trans);
}
});

reviewCommands.addCommand('merge', {
label: 'Merge',
execute: () => {
requestDocMerge(sharedModel.currentRoomId, sharedModel.rootRoomId);
}
});
reviewCommands.addCommand('discard', {
label: 'Discard',
execute: () => {
requestDocDelete(sharedModel.currentRoomId, sharedModel.rootRoomId);
}
});

editingMenu.addItem({type: 'command', command: 'editing'});
editingMenu.addItem({type: 'command', command: 'suggesting'});

suggestionMenu.addItem({type: 'command', command: 'root'});

const _onStateChanged = (sender: ISharedNotebook, changes: NotebookChange) => {
if (changes.stateChange) {
changes.stateChange.forEach(value => {
const forkPrefix = 'fork_';
if (value.name === 'merge' || value.name === 'delete') {
// we are on fork
if (sharedModel.currentRoomId === value.newValue) {
reviewMenu.clearItems();
const merge = value.name === 'merge';
sharedModel.provider.connect(sharedModel.rootRoomId, merge);
open_dialog('Editing', this._trans);
myForkId = '';
}
}
else if (value.name.startsWith(forkPrefix)) {
// we are on root
const forkId = value.name.slice(forkPrefix.length);
if (value.newValue === 'new') {
suggestionCommands.addCommand(forkId, {
label: forkId,
execute: () => {
editingMenu.title.label = 'Suggesting';
reviewMenu.clearItems();
reviewMenu.addItem({type: 'command', command: 'merge'});
reviewMenu.addItem({type: 'command', command: 'discard'});
suggestionMenu.title.label = forkId;
sharedModel.provider.connect(forkId);
open_dialog('Suggesting', this._trans);
}
});
const item = suggestionMenu.addItem({type: 'command', command: forkId});
suggestions[forkId] = item;
if (myForkId !== forkId) {
if (myForkId !== 'pending') {
const dialog = new Dialog({
title: this._trans.__('New suggestion'),
body: this._trans.__('View suggestion?'),
buttons: [
Dialog.okButton({ label: 'View' }),
Dialog.cancelButton({ label: 'Discard' }),
],
});
dialog.launch().then(resp => {
dialog.close();
if (resp.button.label === 'View') {
sharedModel.provider.connect(forkId);
suggestionMenu.title.label = forkId;
editingMenu.title.label = 'Suggesting';
reviewMenu.clearItems();
reviewMenu.addItem({type: 'command', command: 'merge'});
reviewMenu.addItem({type: 'command', command: 'discard'});
}
});
}
else {
reviewMenu.clearItems();
reviewMenu.addItem({type: 'command', command: 'merge'});
reviewMenu.addItem({type: 'command', command: 'discard'});
}
}
}
else if (value.newValue === undefined) {
editingMenu.title.label = 'Editing';
suggestionMenu.title.label = 'Root';
const item: Menu.IItem = suggestions[value.oldValue];
delete suggestions[value.oldValue];
suggestionMenu.removeItem(item);
}
}
});
}
};

sharedModel.changed.connect(_onStateChanged, this);

editingMenubar.addMenu(editingMenu);
suggestionMenubar.addMenu(suggestionMenu);
reviewMenubar.addMenu(reviewMenu);

panel.toolbar.insertItem(997, 'editingMode', editingMenubar);
panel.toolbar.insertItem(998, 'suggestions', suggestionMenubar);
panel.toolbar.insertItem(999, 'review', reviewMenubar);
return new DisposableDelegate(() => {
editingMenubar.dispose();
suggestionMenubar.dispose();
reviewMenubar.dispose();
});
}
}


function open_dialog(title: string, trans: TranslationBundle) {
var body: string;
if (title === 'Editing') {
body = 'You are now directly editing the document.'
}
else {
body = 'Your edits now become suggestions to the document.'
}
const dialog = new Dialog({
title: trans.__(title),
body: trans.__(body),
buttons: [Dialog.okButton({ label: 'OK' })],
});
dialog.launch().then(resp => { dialog.close(); });
}
6 changes: 4 additions & 2 deletions packages/collaboration-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
menuBarPlugin,
rtcGlobalAwarenessPlugin,
rtcPanelPlugin,
userEditorCursors
userEditorCursors,
editingMode
} from './collaboration';
import { sharedLink } from './sharedlink';

Expand All @@ -25,7 +26,8 @@ const plugins: JupyterFrontEndPlugin<any>[] = [
rtcGlobalAwarenessPlugin,
rtcPanelPlugin,
sharedLink,
userEditorCursors
userEditorCursors,
editingMode
];

export default plugins;
1 change: 1 addition & 0 deletions packages/docprovider/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/

export * from './awareness';
export * from './requests';
export * from './ydrive';
export * from './yprovider';
export * from './tokens';