Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/collaboration-extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"@jupyterlab/apputils": "^4.7.0-beta.1",
"@jupyterlab/codemirror": "^4.6.0-beta.1",
"@jupyterlab/coreutils": "^6.6.0-beta.1",
"@jupyterlab/fileeditor": "^4.6.0-beta.1",
"@jupyterlab/notebook": "^4.6.0-beta.1",
"@jupyterlab/services": "^7.6.0-beta.1",
"@jupyterlab/statedb": "^4.6.0-beta.1",
"@jupyterlab/translation": "^4.6.0-beta.1",
Expand Down
241 changes: 237 additions & 4 deletions packages/collaboration-extension/src/collaboration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,23 @@ import {
EditorExtensionRegistry,
IEditorExtensionRegistry
} from '@jupyterlab/codemirror';
import { IEditorTracker } from '@jupyterlab/fileeditor';
import { IGlobalAwareness } from '@jupyter/collaborative-drive';
import { IAwarenessProviderFactory } from '@jupyter/docprovider';
import { INotebookTracker } from '@jupyterlab/notebook';
import { SidePanel, usersIcon } from '@jupyterlab/ui-components';
import { IStateDB, StateDB } from '@jupyterlab/statedb';
import { ITranslator, nullTranslator } from '@jupyterlab/translation';

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

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

import {
CollaboratorsPanel,
ICollaboratorCursorQuery,
ICollaboratorAwareness,
getCollaboratorCursorRange,
IUserMenu,
remoteUserCursors,
RendererUserMenu,
Expand All @@ -36,6 +41,13 @@ import {
import * as Y from 'yjs';
import { Awareness } from 'y-protocols/awareness';

/**
* The command IDs used by the plugin.
*/
namespace CommandIDs {
export const scrollToCursor = 'collaboration:scroll-to-cursor';
}

/**
* Jupyter plugin providing the IUserMenu.
*/
Expand Down Expand Up @@ -133,16 +145,223 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin<void> = {
description: 'Add side panel to display all currently connected users.',
autoStart: true,
requires: [IGlobalAwareness],
optional: [ITranslator],
optional: [ITranslator, IEditorTracker, INotebookTracker],
activate: (
app: JupyterFrontEnd,
awareness: Awareness,
translator: ITranslator | null
translator: ITranslator | null,
editorTracker: IEditorTracker | null,
notebookTracker: INotebookTracker | null
): void => {
const { user } = app.serviceManager;
const { commands } = app;

const trans = (translator ?? nullTranslator).load('jupyter_collaboration');

const getPathFromCurrent = (
current: string | null | undefined
): string | null => {
if (!current) {
return null;
}
const separator = current.indexOf(':');
if (separator === -1 || separator === current.length - 1) {
return null;
}
return current.slice(separator + 1);
};

const revealInEditor = (
sharedModel: IYText,
query: ICollaboratorCursorQuery,
editor: {
getPositionAt: (offset: number) => any;
revealSelection: (selection: any) => void;
focus: () => void;
}
): boolean => {
if (!sharedModel.awareness) {
return false;
}
const cursor = getCollaboratorCursorRange(
sharedModel.awareness,
sharedModel.ysource,
query
);
if (!cursor) {
return false;
}

const start = editor.getPositionAt(cursor.start);
const end = editor.getPositionAt(cursor.end);
if (!start || !end) {
return false;
}

editor.revealSelection({ start, end });
editor.focus();
return true;
};

commands.addCommand(CommandIDs.scrollToCursor, {
label: trans.__('Scroll to Collaborator Cursor'),
execute: async args => {
const username =
typeof args['username'] === 'string' ? args['username'] : null;
const rawClientId = args['clientId'];
const clientId =
typeof rawClientId === 'number' && Number.isInteger(rawClientId)
? rawClientId
: typeof rawClientId === 'string' &&
rawClientId.length > 0 &&
Number.isInteger(Number(rawClientId))
? Number(rawClientId)
: null;
let path = typeof args['path'] === 'string' ? args['path'] : null;

if (!username && clientId === null) {
return false;
}

const collaboratorQuery: ICollaboratorCursorQuery = {};
if (username) {
collaboratorQuery.username = username;
}
if (clientId !== null) {
collaboratorQuery.clientId = clientId;
}

if (!path) {
for (const [remoteClientId, state] of awareness.getStates()) {
if (
collaboratorQuery.clientId !== undefined &&
remoteClientId !== collaboratorQuery.clientId
) {
continue;
}
if (
collaboratorQuery.username &&
state.user?.username !== collaboratorQuery.username
) {
continue;
}
path = getPathFromCurrent(state.current as string | null);
if (path) {
break;
}
}
}

if (!path) {
return false;
}

const openedWidget = (await commands.execute('docmanager:open', {
path
})) as
| {
context?: { ready?: Promise<void> };
revealed?: Promise<void>;
}
| undefined;
await openedWidget?.context?.ready;
await openedWidget?.revealed;

let editorWidget = editorTracker?.find(
widget => widget.context.path === path
);
if (!editorWidget && editorTracker) {
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise<void>(resolve => setTimeout(resolve, 20));
editorWidget = editorTracker.find(
widget => widget.context.path === path
);
if (editorWidget) {
break;
}
}
}
if (editorWidget) {
const sharedModel = editorWidget.content.model.sharedModel as IYText;
if (
revealInEditor(
sharedModel,
collaboratorQuery,
editorWidget.content.editor
)
) {
return true;
}
}

let notebookWidget = notebookTracker?.find(
widget => widget.context.path === path
);
if (!notebookWidget && notebookTracker) {
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise<void>(resolve => setTimeout(resolve, 20));
notebookWidget = notebookTracker.find(
widget => widget.context.path === path
);
if (notebookWidget) {
break;
}
}
}
if (!notebookWidget?.content.model) {
return false;
}

let cursorCell: { index: number; start: number; end: number } | null =
null;
for (let i = 0; i < notebookWidget.content.widgets.length; i++) {
const sharedModel = notebookWidget.content.widgets[i].model
.sharedModel as unknown as IYText;
if (!sharedModel.ysource || !sharedModel.awareness) {
continue;
}
const cursor = getCollaboratorCursorRange(
sharedModel.awareness,
sharedModel.ysource,
collaboratorQuery
);
if (!cursor) {
continue;
}
cursorCell = {
index: i,
start: cursor.start,
end: cursor.end
};
break;
}

if (!cursorCell) {
return false;
}

notebookWidget.content.activeCellIndex = cursorCell.index;
await notebookWidget.content.scrollToItem(cursorCell.index, 'smart');

const cell = notebookWidget.content.widgets[cursorCell.index];
await cell.ready;
const cellEditor = cell.editor;
if (!cellEditor) {
return false;
}

const start = cellEditor.getPositionAt(cursorCell.start);
const end = cellEditor.getPositionAt(cursorCell.end);
if (!start || !end) {
return false;
}

cellEditor.revealSelection({ start, end });
cellEditor.focus();
return true;
}
});

const userPanel = new SidePanel({
alignment: 'justify'
});
Expand All @@ -165,11 +384,25 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin<void> = {
void app.commands.execute('docmanager:open', { path });
};

const followCursor = (collaborator: ICollaboratorAwareness) => {
const path = getPathFromCurrent(collaborator.current);
if (!path) {
return;
}
void commands.execute(CommandIDs.scrollToCursor, {
username: collaborator.user.username,
clientId: collaborator.clientId,
path
});
};

const collaboratorsPanel = new CollaboratorsPanel(
user,
awareness,
fileopener,
app.docRegistry
app.docRegistry,
followCursor,
trans.__('Scroll to cursor')
);
collaboratorsPanel.title.label = trans.__('Online Collaborators');
userPanel.addWidget(collaboratorsPanel);
Expand Down
Loading
Loading