From c884cedf6b483fc10f31c5e9c019586b60bc15ed Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Mon, 1 Jun 2026 02:54:10 +0530 Subject: [PATCH 1/2] =?UTF-8?q?Add=20=E2=80=9CScroll=20to=20Collaborator?= =?UTF-8?q?=20Cursor=E2=80=9D=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/collaboration-extension/package.json | 2 + .../src/collaboration.ts | 241 +++++++++++++++++- .../collaboration/src/collaboratorspanel.tsx | 64 ++++- packages/collaboration/src/cursors.ts | 95 +++++++ packages/collaboration/src/tokens.ts | 5 + packages/collaboration/style/sidepanel.css | 19 ++ 6 files changed, 414 insertions(+), 12 deletions(-) diff --git a/packages/collaboration-extension/package.json b/packages/collaboration-extension/package.json index 1a1bdb60..95a9d12a 100644 --- a/packages/collaboration-extension/package.json +++ b/packages/collaboration-extension/package.json @@ -61,6 +61,8 @@ "@jupyterlab/apputils": "^4.5.0", "@jupyterlab/codemirror": "^4.5.0", "@jupyterlab/coreutils": "^6.5.0", + "@jupyterlab/fileeditor": "^4.5.0", + "@jupyterlab/notebook": "^4.5.0", "@jupyterlab/services": "^7.5.0", "@jupyterlab/statedb": "^4.5.0", "@jupyterlab/translation": "^4.5.0", diff --git a/packages/collaboration-extension/src/collaboration.ts b/packages/collaboration-extension/src/collaboration.ts index 061b8745..054f9706 100644 --- a/packages/collaboration-extension/src/collaboration.ts +++ b/packages/collaboration-extension/src/collaboration.ts @@ -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, @@ -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. */ @@ -133,16 +145,223 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin = { 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 }; + revealed?: Promise; + } + | 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(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(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' }); @@ -165,11 +384,25 @@ export const rtcPanelPlugin: JupyterFrontEndPlugin = { 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); diff --git a/packages/collaboration/src/collaboratorspanel.tsx b/packages/collaboration/src/collaboratorspanel.tsx index 75d7c9a8..74811b20 100644 --- a/packages/collaboration/src/collaboratorspanel.tsx +++ b/packages/collaboration/src/collaboratorspanel.tsx @@ -7,7 +7,12 @@ import { DocumentRegistry } from '@jupyterlab/docregistry'; import { User } from '@jupyterlab/services'; -import { LabIcon, caretDownIcon, fileIcon } from '@jupyterlab/ui-components'; +import { + LabIcon, + caretDownIcon, + fileIcon, + searchIcon +} from '@jupyterlab/ui-components'; import { Signal, ISignal } from '@lumino/signaling'; @@ -64,12 +69,19 @@ const COLLABORATOR_FILES_CLASS = 'jp-CollaboratorFiles'; */ const COLLABORATOR_FILE_CLASS = 'jp-CollaboratorFile'; +/** + * The CSS class added to collaborator cursor follow button. + */ +const COLLABORATOR_FOLLOW_CURSOR_CLASS = 'jp-CollaboratorFollowCursor'; + export class CollaboratorsPanel extends Panel { constructor( currentUser: User.IManager, awareness: Awareness, fileopener: (path: string) => void, - docRegistry?: DocumentRegistry + docRegistry?: DocumentRegistry, + followCursor?: (collaborator: ICollaboratorAwareness) => void, + followCursorTitle?: string ) { super({}); @@ -85,6 +97,8 @@ export class CollaboratorsPanel extends Panel { fileopener={fileopener} collaboratorsChanged={this._collaboratorsChanged} docRegistry={docRegistry} + followCursor={followCursor} + followCursorTitle={followCursorTitle} > ) ); @@ -110,8 +124,18 @@ export class CollaboratorsPanel extends Panel { const uniqueKey = `${value.user.username}-${ value.current || 'no-current' }`; - if (!collaboratorsMap.has(uniqueKey)) { - collaboratorsMap.set(uniqueKey, value as ICollaboratorAwareness); + const clientId = + typeof key === 'number' && Number.isInteger(key) ? key : undefined; + const collaborator = { + ...(value as ICollaboratorAwareness), + clientId + }; + const existing = collaboratorsMap.get(uniqueKey); + const existingClientId = existing?.clientId ?? Number.POSITIVE_INFINITY; + const collaboratorClientId = + collaborator.clientId ?? Number.POSITIVE_INFINITY; + if (!existing || existingClientId > collaboratorClientId) { + collaboratorsMap.set(uniqueKey, collaborator); } } }); @@ -129,6 +153,8 @@ export function CollaboratorsBody(props: { collaboratorsChanged: ISignal; fileopener: (path: string) => void; docRegistry?: DocumentRegistry; + followCursor?: (collaborator: ICollaboratorAwareness) => void; + followCursorTitle?: string; }): JSX.Element { const [collaborators, setCollaborators] = useState( [] @@ -150,6 +176,8 @@ export function CollaboratorsBody(props: { collaborator={collaborator} fileopener={props.fileopener} docRegistry={props.docRegistry} + followCursor={props.followCursor} + followCursorTitle={props.followCursorTitle} > ); })} @@ -161,15 +189,19 @@ export function Collaborator(props: { collaborator: ICollaboratorAwareness; fileopener: (path: string) => void; docRegistry?: DocumentRegistry; + followCursor?: (collaborator: ICollaboratorAwareness) => void; + followCursorTitle?: string; }): JSX.Element { const [open, setOpen] = useState(false); - const { collaborator, fileopener } = props; + const { collaborator, fileopener, followCursor, followCursorTitle } = props; let currentMain = ''; if (collaborator.current) { // Discard widget tracker prefix (e.g. `notebook:` or `editor:`) - const path = collaborator.current.split(':'); - currentMain = `${path[1]}`; + const separator = collaborator.current.indexOf(':'); + if (separator !== -1) { + currentMain = collaborator.current.slice(separator + 1); + } } const documents: string[] = collaborator.documents || []; @@ -203,6 +235,12 @@ export function Collaborator(props: { } }; + const onFollowCursor = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + followCursor?.(collaborator); + }; + return (
{collaborator.user.initials}
{collaborator.user.display_name} + {followCursor && collaborator.current ? ( + + ) : null}
{ cursors?: ICursorState[]; } +/** + * Collaborator cursor lookup query. + */ +export interface ICollaboratorCursorQuery { + /** + * Collaborator awareness client id. + */ + clientId?: number; + /** + * Collaborator username. + */ + username?: string; +} + +/** + * Collaborator cursor range. + */ +export interface ICollaboratorCursorRange { + /** + * Collaborator awareness client id. + */ + clientId: number; + /** + * Cursor range start index. + */ + start: number; + /** + * Cursor range end index. + */ + end: number; +} + +/** + * Find collaborator cursor range in a shared text document. + * + * @param awareness Shared awareness state + * @param ytext Shared text source + * @param query Collaborator query + * @returns Collaborator range or `null` + */ +export function getCollaboratorCursorRange( + awareness: Awareness, + ytext: Text, + query: ICollaboratorCursorQuery +): ICollaboratorCursorRange | null { + const { clientId, username } = query; + if (clientId === undefined && !username) { + return null; + } + + const ydoc = ytext.doc; + if (!ydoc) { + return null; + } + + for (const [remoteClientId, state] of awareness.getStates()) { + if (remoteClientId === awareness.doc.clientID) { + continue; + } + if (clientId !== undefined && remoteClientId !== clientId) { + continue; + } + if (username && state.user?.username !== username) { + continue; + } + + const cursor = + state.cursors?.find( + (cursor: ICursorState) => cursor?.primary !== false + ) ?? state.cursors?.[0]; + + if (!cursor?.anchor || !cursor?.head) { + continue; + } + + const anchor = createAbsolutePositionFromRelativePosition( + cursor.anchor, + ydoc + ); + const head = createAbsolutePositionFromRelativePosition(cursor.head, ydoc); + + if (anchor?.type !== ytext || head?.type !== ytext) { + continue; + } + + return { + clientId: remoteClientId, + start: Math.min(anchor.index, head.index), + end: Math.max(anchor.index, head.index) + }; + } + + return null; +} + /** * Facet storing the Yjs document objects */ diff --git a/packages/collaboration/src/tokens.ts b/packages/collaboration/src/tokens.ts index 84a313f1..60cf1d74 100644 --- a/packages/collaboration/src/tokens.ts +++ b/packages/collaboration/src/tokens.ts @@ -72,6 +72,11 @@ export interface IUserMenu { * Global awareness for JupyterLab scopped shared data. */ export interface ICollaboratorAwareness { + /** + * Awareness client id for the collaborator. + */ + clientId?: number; + /** * The User owning theses data. */ diff --git a/packages/collaboration/style/sidepanel.css b/packages/collaboration/style/sidepanel.css index 756dc0c3..e40c8f7a 100644 --- a/packages/collaboration/style/sidepanel.css +++ b/packages/collaboration/style/sidepanel.css @@ -100,6 +100,25 @@ padding-left: 7px; } +.jp-CollaboratorFollowCursor { + margin-left: auto; + border: none; + background: transparent; + color: inherit; + padding: 0 2px; + display: inline-flex; + align-items: center; +} + +.jp-CollaboratorFollowCursor:hover { + cursor: pointer; +} + +.jp-CollaboratorFollowCursor svg { + width: 16px; + height: 16px; +} + .jp-ClickableCollaborator:hover { cursor: pointer; background-color: var(--jp-layout-color2); From 2d958df621451056610d75f9b018db262d066bdd Mon Sep 17 00:00:00 2001 From: "anujkumar.singh" Date: Mon, 1 Jun 2026 02:59:44 +0530 Subject: [PATCH 2/2] update lock file --- yarn.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/yarn.lock b/yarn.lock index 5f15b786..f4152fed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2094,6 +2094,8 @@ __metadata: "@jupyterlab/builder": ^4.4.0 "@jupyterlab/codemirror": ^4.5.0 "@jupyterlab/coreutils": ^6.5.0 + "@jupyterlab/fileeditor": ^4.5.0 + "@jupyterlab/notebook": ^4.5.0 "@jupyterlab/services": ^7.5.0 "@jupyterlab/statedb": ^4.5.0 "@jupyterlab/translation": ^4.5.0