diff --git a/CLAUDE.md b/CLAUDE.md index 67c72afb0..959f83579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,66 +1,66 @@ -# Jupyter UI - AI Assistant Guide +# Jupyter UI - Quick Reference -## Quick Overview +## Overview -React component library for Jupyter notebooks. Monorepo with 4 packages managed by Lerna. +React component library for Jupyter notebooks. Monorepo with 4 main packages. -## Core Packages - -- `@datalayer/jupyter-react` - React components for notebooks, cells, terminals -- `@datalayer/jupyter-lexical` - Rich text editor integration -- `@datalayer/jupyter-docusaurus-plugin` - Docusaurus plugin -- `datalayer-jupyter-vscode` - VS Code extension - -## Essential Commands +## Commands ```bash -npm install # Install dependencies -npm run build # Build all packages -npm run jupyter:server # Start Jupyter server (port 8686) -npm run storybook # Start Storybook (port 6006) -npm run lint # Check errors only (--quiet) -npm run lint:fix # Auto-fix issues -npm run format # Format code -npm run type-check # TypeScript checking -npm run check # Run all checks (format, lint, type) -npm run check:fix # Auto-fix and check all -npm test # Run tests +# Setup +npm install +npm run build + +# Development +npm run jupyter:server # Start Jupyter (port 8686) +npm run storybook # Start Storybook (port 6006) + +# React package dev (cd packages/react) +npm run start # Remote server config +npm run start-local # Local server (webpack + jupyter) +npm run start-local:webpack # Only webpack + +# Code quality +npm run check # Format, lint, type-check +npm run check:fix # Auto-fix and check ``` -## Requirements +## Key Info -- Node.js >= 20.0.0 (use .nvmrc) -- npm (not yarn) -- Server token: `60c1661cc408f978c309d04157af55c9588ff9557c9380e4fb50785750703da6` +- **Node.js**: >= 20.0.0 (use .nvmrc) +- **Server token**: `60c1661cc408f978c309d04157af55c9588ff9557c9380e4fb50785750703da6` +- **Webpack entry**: Edit `packages/react/webpack.config.js` → `ENTRY` variable +- **Jupyter config**: `dev/config/jupyter_server_config.py` -## Key Files +## Collaboration Setup -- `eslint.config.js` - ESLint v9 flat config -- `.prettierrc.json` - Formatter config -- `.prettierignore` - Excludes MDX files -- `patches/` - Third-party fixes (auto-applied) -- `packages/react/webpack.config.js` - Build config +1. Install: `pip install jupyter-collaboration jupyterlab` +2. Enable: Set `c.LabApp.collaborative = True` in jupyter config +3. Test: Open http://localhost:3208/ in multiple windows -## Recent Fixes (2024) +## Collaboration Providers -- MDX comments: `{/_` → `{/** **/}` in 13 files -- Node requirement: 18 → 20+ -- Webpack warnings: 7 → 2 (source-map exclusions) -- @jupyterlite patch for missing logos -- ESLint v9 flat config migration -- React 18 deprecations fixed -- Storybook CI: Added wait-on and --url for test reliability -- Terminal component: Fixed BoxPanel initialization issue +```tsx +// Basic usage +const provider = new JupyterCollaborationProvider(); +; + +// With config +const provider = new JupyterCollaborationProvider({ + path: 'notebook.ipynb', + serverSettings: mySettings, +}); +``` -## Common Issues +## Troubleshooting -1. **Storybook errors**: Check MDX syntax, run `npx patch-package` -2. **Node version**: Use Node 20+ (`nvm use`) -3. **Lint errors**: Run `npm run lint:fix` -4. **Build fails**: Run `npm run type-check` +- **Build fails**: Run `npm run type-check` +- **Lint errors**: Run `npm run lint:fix` +- **Node version**: Use Node 20+ (`nvm use`) +- **Collaboration issues**: Check WebSocket connections and jupyter-collaboration installation -## AI Assistant Notes +## Development Tips -- Always use npm, not yarn +- Use npm, not yarn - Prefer editing over creating files -- Run lint/type checks before committing +- Run checks after changes: `npm run check:fix` diff --git a/README.md b/README.md index 61df031ec..67aef516b 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ Jupyter UI is a set of [React.js](https://react.dev) components that allow a fro ## 📦 Packages -| Package | Version | Description | -| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| [@datalayer/jupyter-react](./packages/react) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-react)](https://www.npmjs.com/package/@datalayer/jupyter-react) | Core React components for Jupyter integration | -| [@datalayer/jupyter-lexical](./packages/lexical) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-lexical)](https://www.npmjs.com/package/@datalayer/jupyter-lexical) | Rich text editor with Lexical framework | -| [@datalayer/jupyter-docusaurus-plugin](./packages/docusaurus-plugin) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-docusaurus-plugin)](https://www.npmjs.com/package/@datalayer/jupyter-docusaurus-plugin) | Docusaurus plugin for Jupyter notebooks | -| [datalayer-jupyter-vscode](./packages/vscode) | [![marketplace](https://img.shields.io/visual-studio-marketplace/v/datalayer.datalayer-jupyter-vscode)](https://marketplace.visualstudio.com/items?itemName=datalayer.datalayer-jupyter-vscode) | VS Code extension | +| Package | Version | Description | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | +| [@datalayer/jupyter-react](./packages/react) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-react)](https://www.npmjs.com/package/@datalayer/jupyter-react) | Generic React components for Jupyter | +| [@datalayer/jupyter-lexical](./packages/lexical) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-lexical)](https://www.npmjs.com/package/@datalayer/jupyter-lexical) | Rich text editor with Lexical framework | +| [@datalayer/jupyter-docusaurus-plugin](./packages/docusaurus-plugin) | [![npm](https://img.shields.io/npm/v/@datalayer/jupyter-docusaurus-plugin)](https://www.npmjs.com/package/@datalayer/jupyter-docusaurus-plugin) | Docusaurus plugin for Jupyter notebooks | +| [datalayer-jupyter-vscode](./packages/vscode) | [![marketplace](https://img.shields.io/visual-studio-marketplace/v/datalayer.datalayer-jupyter-vscode)](https://marketplace.visualstudio.com/items?itemName=datalayer.datalayer-jupyter-vscode) | VS Code extension | ## 🚀 Quick Start @@ -38,21 +38,65 @@ npm install @datalayer/jupyter-react ### Basic Usage ```tsx -import { Jupyter, Notebook } from '@datalayer/jupyter-react'; +import { JupyterReactTheme, Notebook } from '@datalayer/jupyter-react'; function App() { return ( - - - + + + ); } ``` +### Collaborative Editing + +Jupyter UI supports real-time collaboration through a pluggable provider system: + +```tsx +import { + Notebook, + JupyterCollaborationProvider, +} from '@datalayer/jupyter-react'; + +function CollaborativeNotebook() { + const collaborationProvider = new JupyterCollaborationProvider(); + + return ( + + ); +} +``` + +#### Creating Custom Collaboration Providers + +You can create your own collaboration provider by extending `CollaborationProviderBase`: + +```tsx +import { CollaborationProviderBase } from '@datalayer/jupyter-react'; + +class MyCustomProvider extends CollaborationProviderBase { + constructor(config) { + super('my-provider-type'); + // Initialize your provider + } + + async connect(sharedModel, documentId, options) { + // Implement your connection logic + // Set up WebSocket, authenticate, etc. + } +} + +// Use it with any Notebook component +const provider = new MyCustomProvider({ + /* config */ +}); +; +``` + ### Development Setup As a developer start with the [setup of your environment](https://jupyter-ui.datalayer.tech/docs/develop/setup) and try [one of the examples](https://jupyter-ui.datalayer.tech/docs/category/examples). We have [documentation](https://jupyter-ui.datalayer.tech) for more details. @@ -100,12 +144,21 @@ We host a Storybook on ✨ https://jupyter-ui-storybook.datalayer.tech that show ### Advanced Features - **🔌 IPyWidgets Support** - Full support for interactive widgets -- **👥 Collaborative Editing** - Real-time collaboration using Y.js +- **👥 Collaborative Editing** - Pluggable provider system supporting: + - Jupyter collaboration (WebSocket-based with Y.js) + - Custom providers via `ICollaborationProvider` interface - **🎨 Theming** - JupyterLab theme support with dark/light modes - **🔧 Extensible** - Plugin system for custom functionality - **🚀 Performance** - Virtual scrolling, lazy loading, and optimizations - **🔒 Security** - Token authentication, CORS, XSS protection +### Architecture Highlights + +- **🏗️ Clean Architecture** - Modular, composable components with clear interfaces +- **🔄 Composition Pattern** - Components compose rather than inherit for maximum flexibility +- **🔌 Provider System** - Pluggable collaboration providers for different backends +- **📦 One-way Dependencies** - Core depends on jupyter-react, not vice versa +
Jupyter UI Gallery
@@ -151,7 +204,7 @@ We maintain a plugin for [Docusaurus](https://docusaurus.io) in the [docusaurus- ## 📋 Requirements -- **Node.js** >= 18.0.0 +- **Node.js** >= 20.0.0 - **npm** >= 8.0.0 - **Python** >= 3.8 (for Jupyter server) - **JupyterLab** >= 4.0.0 diff --git a/SUMMARY.md b/SUMMARY.md index 1a806ae3f..d0a0a0222 100644 --- a/SUMMARY.md +++ b/SUMMARY.md @@ -4,6 +4,15 @@ Jupyter UI is an open-source React.js component library that bridges the gap between the Jupyter ecosystem and modern web development frameworks. It provides React components that are 100% compatible with Jupyter, allowing developers to build custom data products without being constrained by the traditional JupyterLab interface. +## Recent Updates (January 2025) + +### Collaboration Provider System + +- Implemented plugin-based architecture for extensible collaboration +- Built-in providers: `JupyterCollaborationProvider`, `NoOpCollaborationProvider` +- Direct instantiation pattern for simplicity +- Added collaboration support to `Notebook2` component + ### Core Problem Solved Traditional JupyterLab uses the Lumino widget toolkit, an imperative UI framework that isn't compatible with modern declarative frameworks like React. This forces developers to either: @@ -23,7 +32,7 @@ The project uses Lerna to manage a monorepo structure with the following organiz ``` jupyter-ui/ ├── packages/ # Core library packages -│ ├── react/ # Main React component library +│ ├── react/ # Main React component library (generic) │ ├── lexical/ # Rich text editor integration │ ├── docusaurus-plugin/ # Docusaurus integration │ └── vscode/ # VS Code extension @@ -60,12 +69,15 @@ The main package providing React components for Jupyter functionality. - Provides React context providers for state management - Supports both local and remote Jupyter servers - Implements WebSocket communication for real-time updates +- Plugin-based collaboration provider system +- Extensible without platform-specific code **Key Files:** - `src/jupyter/JupyterContext.tsx` - Core context provider -- `src/components/notebook/Notebook.tsx` - Main notebook component +- `src/components/notebook/Notebook.tsx` - Main notebook component (accepts collaborationProvider) - `src/providers/ServiceManagerProvider.tsx` - Service management +- `src/jupyter/collaboration/ICollaborationProvider.ts` - Provider interface ### 2. @datalayer/jupyter-lexical (v1.0.3) @@ -454,11 +466,11 @@ The repository includes several example implementations: ## Community & Ecosystem -- Active development by Datalayer, Inc. - MIT licensed - Integration with major React frameworks - Storybook for component documentation - Comprehensive documentation site +- Active development by Datalayer, Inc. ## Future Roadmap (Based on Code Structure) diff --git a/dev/config/jupyter_server_config.py b/dev/config/jupyter_server_config.py index e09dbf647..a26383b94 100755 --- a/dev/config/jupyter_server_config.py +++ b/dev/config/jupyter_server_config.py @@ -106,4 +106,4 @@ # JupyterLab ################# -c.LabApp.collaborative = False +c.LabApp.collaborative = True diff --git a/dev/notebooks/collaboration.ipynb b/dev/notebooks/collaboration.ipynb new file mode 100644 index 000000000..21405e3d3 --- /dev/null +++ b/dev/notebooks/collaboration.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Collaboration Example\n", + "This notebook is for testing real-time collaboration." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T06:03:49.869760Z", + "iopub.status.busy": "2025-08-18T06:03:49.869446Z", + "iopub.status.idle": "2025-08-18T06:03:49.875095Z", + "shell.execute_reply": "2025-08-18T06:03:49.874611Z", + "shell.execute_reply.started": "2025-08-18T06:03:49.869729Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Hello from collaboration notebook!\n" + ] + } + ], + "source": [ + "print('Hello from collaboration notebook!')" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "execution": { + "iopub.execute_input": "2025-08-18T06:03:51.258535Z", + "iopub.status.busy": "2025-08-18T06:03:51.258136Z", + "iopub.status.idle": "2025-08-18T06:03:51.262464Z", + "shell.execute_reply": "2025-08-18T06:03:51.261483Z", + "shell.execute_reply.started": "2025-08-18T06:03:51.258506Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x + y = 30\n" + ] + } + ], + "source": [ + "# Test collaboration by editing this cell\n", + "x = 10\n", + "y = 20\n", + "print(f'x + y = {x + y}')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.7" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/packages/react/package.json b/packages/react/package.json index 55db5d7e4..8f7a0bb44 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -54,7 +54,7 @@ "start-noconfig": "cross-env NO_CONFIG=true webpack serve", "start-local": "run-p -c 'start-local:*'", "start-local:webpack": "cross-env LOCAL_JUPYTER_SERVER=true webpack serve", - "start-local:jupyter-server": "cd ./../.. && make start-jupyter-server", + "start-local:jupyter-server": "cd ./../.. && npm run jupyter:server", "stylelint": "npm stylelint:check --fix", "stylelint:check": "stylelint --cache \"style/**/*.css\"", "test": "jest --coverage", diff --git a/packages/react/public/index-local.html b/packages/react/public/index-local.html index 7388c2d8d..17dd3d02c 100755 --- a/packages/react/public/index-local.html +++ b/packages/react/public/index-local.html @@ -28,7 +28,7 @@ "appUrl": "/lab", "themesUrl": "/lab/api/themes", "disableRTC": false, - "terminalsAvailable": "false", + "terminalsAvailable": "true", "mathjaxUrl": "https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/MathJax.js", "mathjaxConfig": "TeX-AMS_CHTML-full,Safe" } diff --git a/packages/react/src/components/codemirror/CodeMirrorDatalayerEditor.tsx b/packages/react/src/components/codemirror/CodeMirrorEditor.tsx similarity index 94% rename from packages/react/src/components/codemirror/CodeMirrorDatalayerEditor.tsx rename to packages/react/src/components/codemirror/CodeMirrorEditor.tsx index 0b4cd9ee4..4bc6b2220 100644 --- a/packages/react/src/components/codemirror/CodeMirrorDatalayerEditor.tsx +++ b/packages/react/src/components/codemirror/CodeMirrorEditor.tsx @@ -16,7 +16,7 @@ import useOutputsStore from '../output/OutputState'; import codeMirrorTheme from './CodeMirrorTheme'; import CodeMirrorOutputToolbar from './CodeMirrorOutputToolbar'; -export const CodeMirrorDatalayerEditor = (props: { +export const CodeMirrorEditor = (props: { code: string; codePre?: string; outputAdapter: OutputAdapter; @@ -147,4 +147,7 @@ export const CodeMirrorDatalayerEditor = (props: { ); }; -export default CodeMirrorDatalayerEditor; +// Deprecated: Use CodeMirrorEditor instead of CodeMirrorDatalayerEditor +export const CodeMirrorDatalayerEditor = CodeMirrorEditor; + +export default CodeMirrorEditor; diff --git a/packages/react/src/components/codemirror/index.ts b/packages/react/src/components/codemirror/index.ts index cac9c1e19..cffda6317 100644 --- a/packages/react/src/components/codemirror/index.ts +++ b/packages/react/src/components/codemirror/index.ts @@ -4,6 +4,7 @@ * MIT License */ -export * from './CodeMirrorDatalayerEditor'; +export * from './CodeMirrorEditor'; +export { CodeMirrorDatalayerEditor } from './CodeMirrorEditor'; export * from './CodeMirrorOutputToolbar'; export * from './CodeMirrorTheme'; diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts index a2668aea5..e33347c41 100644 --- a/packages/react/src/components/index.ts +++ b/packages/react/src/components/index.ts @@ -6,7 +6,7 @@ export * from './button'; export * from './cell'; -// export * from './codemirror'; +export * from './codemirror'; export * from './commands'; export * from './console'; export * from './dialog'; diff --git a/packages/react/src/components/lumino/Lumino.tsx b/packages/react/src/components/lumino/Lumino.tsx index 2421cdfb6..4ef3f8828 100644 --- a/packages/react/src/components/lumino/Lumino.tsx +++ b/packages/react/src/components/lumino/Lumino.tsx @@ -17,21 +17,56 @@ export const Lumino = (props: LuminoProps) => { const ref = useRef(null); const { children, id, height } = props; useEffect(() => { - if (ref && ref.current) { + console.log( + 'Lumino useEffect - ref.current:', + ref.current, + 'children:', + children, + 'children.isAttached:', + children?.isAttached + ); + if (ref && ref.current && children) { try { - Widget.attach(children, ref.current); + // Only attach if not already attached + if (!children.isAttached) { + console.log('Attaching widget to DOM'); + Widget.attach(children, ref.current); + console.log('Widget attached successfully'); + console.log('Widget node:', children.node); + console.log('Widget node parent:', children.node.parentElement); + console.log( + 'Container children after attach:', + ref.current.children.length + ); + console.log('Widget is visible:', children.isVisible); + if (!children.isVisible) { + console.log('Making widget visible'); + children.show(); + } + // Force the widget to update + children.update(); + } else { + console.log('Widget already attached'); + console.log('Widget node:', children.node); + console.log('Widget node parent:', children.node.parentElement); + // Ensure widget is in the DOM + if (!ref.current.contains(children.node)) { + console.log('Widget node not in container, re-attaching'); + Widget.attach(children, ref.current); + } + } } catch (e) { console.warn('Exception while attaching Lumino widget.', e); } return () => { + console.log('Lumino cleanup - detaching widget'); try { - if (children.isAttached || children.node.isConnected) { - children.dispose(); + if (children && (children.isAttached || children.node.isConnected)) { + console.log('Detaching widget from DOM'); Widget.detach(children); } } catch (e) { - // no-op. - // console.debug('Exception while detaching Lumino widget.', e); + console.warn('Exception while detaching Lumino widget.', e); } }; } diff --git a/packages/react/src/components/notebook/Notebook.tsx b/packages/react/src/components/notebook/Notebook.tsx index c432a7243..86d114b41 100644 --- a/packages/react/src/components/notebook/Notebook.tsx +++ b/packages/react/src/components/notebook/Notebook.tsx @@ -6,31 +6,22 @@ import { YNotebook } from '@jupyter/ydoc'; import { Cell, ICellModel } from '@jupyterlab/cells'; -import { URLExt } from '@jupyterlab/coreutils'; import { createGlobalStyle } from 'styled-components'; import { INotebookContent } from '@jupyterlab/nbformat'; import { NotebookModel } from '@jupyterlab/notebook'; import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; import { Kernel as JupyterKernel, ServiceManager } from '@jupyterlab/services'; -import { PromiseDelegate } from '@lumino/coreutils'; import { Box } from '@primer/react'; import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; -import { WebsocketProvider as YWebsocketProvider } from 'y-websocket'; -import { - jupyterReactStore, - KernelTransfer, - OnSessionConnection, -} from '../../state'; -import { newUuid, sleep } from '../../utils'; +import { KernelTransfer, OnSessionConnection } from '../../state'; +import { newUuid } from '../../utils'; import { asObservable, Lumino } from '../lumino'; import { - COLLABORATION_ROOM_URL_PATH, - requestDatalayerollaborationSessionId, ICollaborationProvider, + CollaborationStatus, Kernel, Lite, - requestJupyterCollaborationSession, useJupyter, } from './../../jupyter'; import { CellMetadataEditor } from './cell/metadata'; @@ -40,7 +31,7 @@ import { INotebookToolbarProps } from './toolbar'; import { Loader } from '../utils'; import './Notebook.css'; -import { DatalayerNotebookExtension } from './NotebookExtensions'; +import { NotebookExtension } from './NotebookExtensions'; export type ExternalIPyWidgets = { name: string; @@ -64,8 +55,8 @@ export type INotebookProps = { Toolbar?: (props: INotebookToolbarProps) => JSX.Element; cellMetadataPanel?: boolean; cellSidebarMargin?: number; - collaborative?: ICollaborationProvider; - extensions?: DatalayerNotebookExtension[]; + collaborationProvider?: ICollaborationProvider; + extensions?: NotebookExtension[]; height?: string; id: string; kernel?: Kernel; @@ -111,7 +102,7 @@ export const Notebook = (props: INotebookProps) => { }); const { Toolbar, - collaborative, + collaborationProvider: collaborationProviderProp, extensions, height, maxHeight, @@ -123,6 +114,7 @@ export const Notebook = (props: INotebookProps) => { } = props; const [id, _] = useState(props.id || newUuid()); const [adapter, setAdapter] = useState(); + const [isLoading, setIsLoading] = useState(false); const [extensionComponents, setExtensionComponents] = useState( new Array() ); @@ -130,7 +122,14 @@ export const Notebook = (props: INotebookProps) => { const notebookStore = useNotebookStore(); const portals = notebookStore.selectNotebookPortals(id); - const [isLoading, setIsLoading] = useState(false); + console.log( + 'Notebook render - adapter:', + adapter, + 'isLoading:', + isLoading, + 'adapter.panel:', + adapter?.panel + ); // Bootstrap the Notebook Adapter. const bootstrapAdapter = async ( @@ -138,6 +137,7 @@ export const Notebook = (props: INotebookProps) => { serviceManager?: ServiceManager.IManager, kernel?: Kernel ) => { + console.log('bootstrapAdapter called with kernel:', kernel); const adapter = new NotebookAdapter({ ...props, id, @@ -145,6 +145,7 @@ export const Notebook = (props: INotebookProps) => { kernel, serviceManager, }); + console.log('Created adapter:', adapter); // Update the local state. setAdapter(adapter); extensions!.forEach(extension => { @@ -242,151 +243,129 @@ export const Notebook = (props: INotebookProps) => { }, [serviceManager, kernel]); useEffect(() => { - // As the server has the content source of truth, we - // must ensure that the shared model is pristine before - // to connect to the server. More over we should ensure, - // the connection is disposed in case the server document is - // reset for any reason while the client is still alive. - let provider: YWebsocketProvider | null = null; - let ready = new PromiseDelegate(); + // Set up collaboration using the new provider system + let collaborationProvider: ICollaborationProvider | null = null; let isMounted = true; let sharedModel: YNotebook | null = null; - const onConnectionClose = (event: any) => { - if (event.code > 1000) { - console.error( - 'Connection with the document has been closed unexpectedly.', - event - ); - - provider?.disconnect(); + const connect = async () => { + if (!adapter?.notebookPanel || !isMounted) { + return; + } - // If sessionId has expired - reset the client model - if (event.code === 4002) { - provider?.destroy(); - ready.reject('Connection closed.'); - ready = new PromiseDelegate(); - if (isMounted) { - setIsLoading(true); - Promise.all([connect(), ready.promise, sleep(500)]) - .catch(error => { - console.error( - 'Failed to setup collaboration connection.', - error - ); - }) - .finally(() => { - if (isMounted) { - setIsLoading(false); - } - }); - } - } - // FIXME inform the user. + // Use the provided collaboration provider + if (collaborationProviderProp) { + collaborationProvider = collaborationProviderProp; } - }; - const onSync = (isSynced: boolean) => { - if (isSynced) { - provider?.off('sync', onSync); - ready.resolve(void 0); + if (!collaborationProvider) { + return; } - }; - const connect = async () => { - if (adapter?.notebookPanel && isMounted) { + try { sharedModel = new YNotebook(); - const { ydoc, awareness } = sharedModel; - // Setup Collaboration. - if (collaborative == 'jupyter') { - const token = - jupyterReactStore.getState().jupyterConfig?.jupyterServerToken; - const session = await requestJupyterCollaborationSession( - 'json', - 'notebook', - path! - ); - const wsUrl = serviceManager?.serverSettings.wsUrl; - if (!wsUrl) { - throw new Error('WebSocket URL is not available'); + + // Set up event handlers + const handleStatusChange = ( + _: ICollaborationProvider, + status: CollaborationStatus + ) => { + if ( + status === CollaborationStatus.Connected && + adapter?.notebookPanel + ) { + // Create a new model using the synchronized shared model + const model = new NotebookModel({ + collaborationEnabled: true, + disableDocumentWideUndoRedo: true, + sharedModel: sharedModel!, + }); + + // Store the old model for disposal + const oldModel = adapter.notebookPanel.content.model; + + // Safely update the model + try { + // Update the model without triggering widget reattachment + adapter.notebookPanel.content.model = model; + + // Update the notebook store with the new model + notebookStore.changeModel({ id, notebookModel: model }); + + // Force the notebook panel to update its content + adapter.notebookPanel.update(); + + // Dispose the old model after successful update + if (oldModel && oldModel !== model) { + oldModel.dispose(); + } + + console.log( + 'Notebook model updated with collaboration. Cell count:', + model.cells?.length + ); + } catch (error) { + console.error('Error updating notebook model:', error); + // Restore the old model if update fails + if (oldModel && !oldModel.isDisposed) { + adapter.notebookPanel.content.model = oldModel; + } + } } - const documentURL = URLExt.join(wsUrl, COLLABORATION_ROOM_URL_PATH); - const documentName = `${session.format}:${session.type}:${session.fileId}`; - provider = new YWebsocketProvider(documentURL, documentName, ydoc, { - disableBc: true, - params: { - sessionId: session.sessionId, - token: token!, - }, - awareness, - }); - } else if (collaborative == 'datalayer') { - const { runUrl, token } = - jupyterReactStore.getState().datalayerConfig ?? {}; - const documentName = id; - const documentURL = URLExt.join(runUrl!, `/api/spacer/v1/documents`); - const sessionId = await requestDatalayerollaborationSessionId({ - url: URLExt.join(documentURL, documentName), - token, - }); - provider = new YWebsocketProvider( - documentURL.replace(/^http/, 'ws'), - documentName, - ydoc, - { - disableBc: true, - params: { - sessionId, - token: token!, - }, - awareness, + }; + + const handleError = (_: ICollaborationProvider, error: Error) => { + console.error('Collaboration error:', error); + // Handle collaboration errors + if (error.message.includes('session expired')) { + // Attempt to reconnect + if (isMounted && collaborationProvider && sharedModel) { + collaborationProvider + .connect(sharedModel, id) + .catch(console.error); } - ); - } - if (provider) { - provider.on('sync', onSync); - provider.on('connection-close', onConnectionClose); - console.log('Collaboration is setup with websocket provider.'); - // Create a new model using the one synchronize with the collaboration document - const model = new NotebookModel({ - collaborationEnabled: true, - disableDocumentWideUndoRedo: true, - sharedModel, - }); - const oldModel = adapter.notebookPanel.content.model; - adapter.notebookPanel.content.model = model; - // We must dispose the old model after setting the new one. - oldModel?.dispose(); - } + } + }; + + collaborationProvider.events.statusChanged.connect(handleStatusChange); + collaborationProvider.events.errorOccurred.connect(handleError); + + // Connect to collaboration service + await collaborationProvider.connect(sharedModel, id, { + serviceManager, + path: props.path, // Pass the notebook's path to the collaboration provider + }); + + console.log( + 'Collaboration is setup with provider:', + collaborationProvider.type + ); + } catch (error) { + console.error('Failed to setup collaboration:', error); + setIsLoading(false); } }; - if (collaborative) { - setIsLoading(true); - Promise.all([connect(), ready.promise, sleep(500)]) + if (collaborationProviderProp) { + // Don't set isLoading to true here as it causes the Lumino widget to unmount + // setIsLoading(true); + connect() .catch(error => { console.error('Failed to setup collaboration connection.', error); }) .finally(() => { if (isMounted) { - setIsLoading(false); + // setIsLoading(false); } }); } return () => { isMounted = false; - if (provider) { - (provider.synced ? Promise.resolve() : ready.promise).finally(() => { - provider?.off('sync', onSync); - provider?.off('connection-close', onConnectionClose); - provider?.disconnect(); - provider?.destroy(); - }); - } + collaborationProvider?.dispose(); sharedModel?.dispose(); }; - }, [adapter?.notebookPanel, collaborative]); + }, [adapter?.notebookPanel, collaborationProviderProp]); useEffect(() => { if (adapter && adapter.kernel !== kernel) { @@ -495,7 +474,13 @@ export const Notebook = (props: INotebookProps) => { {isLoading ? ( ) : ( - {adapter && {adapter.panel}} + + {adapter ? ( + {adapter.panel} + ) : ( +
No adapter available
+ )} +
)} diff --git a/packages/react/src/components/notebook/Notebook2.tsx b/packages/react/src/components/notebook/Notebook2.tsx index 49807daef..0b8402027 100644 --- a/packages/react/src/components/notebook/Notebook2.tsx +++ b/packages/react/src/components/notebook/Notebook2.tsx @@ -15,11 +15,11 @@ import { Box } from '@primer/react'; import type { OnSessionConnection } from '../../state'; import { Loader } from '../utils'; import { useKernelId, useNotebookModel, Notebook2Base } from './Notebook2Base'; -import type { DatalayerNotebookExtension } from './NotebookExtensions'; +import type { NotebookExtension } from './NotebookExtensions'; import type { INotebookToolbarProps } from './toolbar'; import './Notebook.css'; -import { ICollaborationServer } from '../../jupyter'; +import { ICollaborationProvider } from '../../jupyter'; const GlobalStyle = createGlobalStyle` .dla-Box-Notebook .jp-Cell .dla-CellSidebar-Container { @@ -35,9 +35,9 @@ const GlobalStyle = createGlobalStyle` */ export interface INotebook2Props { /** - * Collaboration server providing the document documents. + * Collaboration provider instance. */ - collaborationServer?: ICollaborationServer; + collaborationProvider?: ICollaborationProvider; /** * Custom command registry. * @@ -48,7 +48,7 @@ export interface INotebook2Props { /** * Notebook extensions. */ - extensions?: DatalayerNotebookExtension[]; + extensions?: NotebookExtension[]; /** * Notebook ID. */ @@ -127,7 +127,7 @@ export function Notebook2( Toolbar, children, cellSidebarMargin = 120, - collaborationServer, + collaborationProvider, commands, extensions, height = '100vh', @@ -153,10 +153,13 @@ export function Notebook2( }); const model = useNotebookModel({ - collaborationServer, + collaborationProvider, nbformat, readonly, url, + path, + serviceManager, + id, }); useEffect(() => { @@ -169,7 +172,7 @@ export function Notebook2( useEffect(() => { // Set user identity if collaborating using Jupyter collaboration const setUserIdentity = () => { - if (collaborationServer?.type === 'jupyter' && model) { + if (collaborationProvider && model) { // Yjs details are hidden from the interface (model.sharedModel as any).awareness.setLocalStateField( 'user', @@ -182,7 +185,7 @@ export function Notebook2( return () => { serviceManager.user.userChanged.disconnect(setUserIdentity); }; - }, [collaborationServer, model, serviceManager]); + }, [collaborationProvider, model, serviceManager]); return isLoading ? ( diff --git a/packages/react/src/components/notebook/Notebook2Base.tsx b/packages/react/src/components/notebook/Notebook2Base.tsx index 6c0f1937e..1521401ea 100644 --- a/packages/react/src/components/notebook/Notebook2Base.tsx +++ b/packages/react/src/components/notebook/Notebook2Base.tsx @@ -23,7 +23,7 @@ import { KernelCompleterProvider, ProviderReconciliator, } from '@jupyterlab/completer'; -import { PathExt, URLExt, type IChangedArgs } from '@jupyterlab/coreutils'; +import { PathExt, type IChangedArgs } from '@jupyterlab/coreutils'; import { Context, type DocumentRegistry } from '@jupyterlab/docregistry'; import { rendererFactory as javascriptRendererFactory } from '@jupyterlab/javascript-extension'; import { rendererFactory as jsonRendererFactory } from '@jupyterlab/json-extension'; @@ -56,19 +56,14 @@ import type { ISessionConnection } from '@jupyterlab/services/lib/session/sessio import { YNotebook, type ISharedNotebook, type IYText } from '@jupyter/ydoc'; import { find } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; -import { PromiseDelegate } from '@lumino/coreutils'; import { DisposableSet } from '@lumino/disposable'; import { Signal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import { Box } from '@primer/react'; import { Banner } from '@primer/react/experimental'; import { EditorView } from 'codemirror'; -import { WebsocketProvider } from 'y-websocket'; import { - COLLABORATION_ROOM_URL_PATH, - ICollaborationServer, - requestDatalayerollaborationSessionId, - requestJupyterCollaborationSession, + ICollaborationProvider, WIDGET_MIMETYPE, WidgetLabRenderer, WidgetManager, @@ -77,12 +72,12 @@ import type { OnSessionConnection } from '../../state'; import { newUuid, remoteUserCursors } from '../../utils'; import { Lumino } from '../lumino'; import { Loader } from '../utils'; -import type { DatalayerNotebookExtension } from './NotebookExtensions'; +import type { NotebookExtension } from './NotebookExtensions'; import { addNotebookCommands } from './NotebookCommands'; const COMPLETER_TIMEOUT_MILLISECONDS = 1000; -const DEFAULT_EXTENSIONS = new Array(); +const DEFAULT_EXTENSIONS = new Array(); const FALLBACK_NOTEBOOK_PATH = '.datalayer/ping.ipynb'; @@ -104,7 +99,7 @@ export interface INotebook2BaseProps { /** * Notebook extensions */ - extensions?: DatalayerNotebookExtension[]; + extensions?: NotebookExtension[]; /** * Kernel ID to connect to */ @@ -596,9 +591,9 @@ export function useKernelId( type IOptions = { /** - * Collaboration server providing the documents + * Collaboration provider for the notebook. */ - collaborationServer?: ICollaborationServer; + collaborationProvider?: ICollaborationProvider; /** * Notebook content. */ @@ -613,6 +608,18 @@ type IOptions = { * URL to fetch the notebook content from. */ url?: string; + /** + * Path to the notebook file. + */ + path?: string; + /** + * Service manager. + */ + serviceManager?: ServiceManager.IManager; + /** + * Notebook ID. + */ + id?: string; }; /** @@ -621,10 +628,18 @@ type IOptions = { * The notebook content may come from 3 sources: * - {@link nbformat}: The notebook content * - {@link url}: A URL to fetch the notebook content from - * - {@link collaborationServer}: Parameters to connect to a collaboration server + * - {@link collaborationProvider}: A collaboration provider for real-time editing */ export function useNotebookModel(options: IOptions): NotebookModel | null { - const { collaborationServer, nbformat, readonly = false, url } = options; + const { + collaborationProvider, + nbformat, + readonly = false, + url, + path, + serviceManager, + id, + } = options; // Generate the notebook model // There are three posibilities (by priority order): @@ -637,114 +652,19 @@ export function useNotebookModel(options: IOptions): NotebookModel | null { let isMounted = true; const disposable = new DisposableSet(); - if (collaborationServer) { - // As the server has the content source of thruth, we - // must ensure that the shared model is pristine before - // to connect to the server. More over we should ensure, - // the connection is disposed in case the server document is - // reset for any reason while the client is still alive. - let provider: WebsocketProvider | null = null; - let ready = new PromiseDelegate(); - const isMounted = true; - let sharedModel: YNotebook | null = null; - - const onConnectionClose = (event: any) => { - if (event.code > 1000) { - console.error( - 'Connection with the document has been closed unexpectedly.', - event - ); - - provider?.disconnect(); - - // If sessionId has expired - reset the client model - if (event.code === 4002) { - disposable.clear(); - provider?.destroy(); - if (isMounted) { - connect().catch(error => { - console.error( - 'Failed to setup collaboration connection.', - error - ); - }); - } - } - // FIXME inform the user. - } - }; - - const onSync = (isSynced: boolean) => { - if (isSynced) { - provider?.off('sync', onSync); - ready.resolve(void 0); - } - }; + // Handle new collaboration provider + if (collaborationProvider && serviceManager && id) { + const setupCollaboration = async () => { + try { + const sharedModel = new YNotebook(); - const connect = async () => { - ready.reject('Connection closed.'); - ready = new PromiseDelegate(); - - sharedModel = new YNotebook(); - const { ydoc, awareness } = sharedModel; - let documentURL = ''; - let documentName = ''; - const params: Record = {}; - - // Setup Collaboration. - if (collaborationServer.type === 'jupyter') { - const { path, serverSettings } = collaborationServer; - const session = await requestJupyterCollaborationSession( - 'json', - 'notebook', + // Connect to the collaboration provider + await collaborationProvider.connect(sharedModel, id, { + serviceManager, path, - serverSettings - ); - documentURL = URLExt.join( - serverSettings.wsUrl, - COLLABORATION_ROOM_URL_PATH - ); - documentName = `${session.format}:${session.type}:${session.fileId}`; - params.sessionId = session.sessionId; - if (serverSettings.token) { - params.token = serverSettings.token; - } - } else if (collaborationServer.type === 'datalayer') { - const { - baseURL, - documentName: documentName_, - token, - } = collaborationServer; - documentName = documentName_; // Set non local variable. - const serverURL = URLExt.join(baseURL, '/api/spacer/v1/documents'); - documentURL = serverURL.replace(/^http/, 'ws'); - params.sessionId = await requestDatalayerollaborationSessionId({ - url: URLExt.join(serverURL, documentName), - token, }); - params.token = token; - } - - if (params.sessionId) { - provider = new WebsocketProvider(documentURL, documentName, ydoc, { - disableBc: true, - params, - awareness, - }); - provider.on('sync', onSync); - provider.on('connection-close', onConnectionClose); - console.log('Collaboration is setup with websocket provider.'); - - await ready.promise; - const dispose = () => { - provider?.off('sync', onSync); - provider?.off('connection-close', onConnectionClose); - provider?.disconnect(); - provider?.destroy(); - }; if (isMounted) { - // Create a new model using the one synchronized with the collaboration document. const model = new NotebookModel({ collaborationEnabled: true, disableDocumentWideUndoRedo: true, @@ -753,16 +673,21 @@ export function useNotebookModel(options: IOptions): NotebookModel | null { model.readOnly = readonly; setModel(model); - disposable.add(Object.freeze({ dispose, isDisposed: false })); - } else { - dispose(); + disposable.add({ + dispose: () => { + collaborationProvider.disconnect(); + }, + get isDisposed() { + return false; + }, + }); } + } catch (error) { + console.error('Failed to setup collaboration:', error); } }; - connect().catch(error => { - console.error('Failed to setup collaboration connection.', error); - }); + setupCollaboration(); } else { const createModel = (nbformat: INotebookContent | undefined) => { const model = new NotebookModel(); @@ -791,7 +716,15 @@ export function useNotebookModel(options: IOptions): NotebookModel | null { isMounted = false; disposable.dispose(); }; - }, [collaborationServer, nbformat, readonly, url]); + }, [ + collaborationProvider, + nbformat, + readonly, + url, + path, + serviceManager, + id, + ]); return model; } diff --git a/packages/react/src/components/notebook/NotebookExtensions.ts b/packages/react/src/components/notebook/NotebookExtensions.ts index b4ec37b3a..7dc918fa8 100644 --- a/packages/react/src/components/notebook/NotebookExtensions.ts +++ b/packages/react/src/components/notebook/NotebookExtensions.ts @@ -9,17 +9,17 @@ import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { CommandRegistry } from '@lumino/commands'; import { NotebookAdapter } from './NotebookAdapter'; -export type IDatalayerNotebookExtensionProps = { +export type INotebookExtensionProps = { notebookId: string; commands: CommandRegistry; panel: NotebookPanel; adapter?: NotebookAdapter; }; -export type DatalayerNotebookExtension = DocumentRegistry.IWidgetExtension< +export type NotebookExtension = DocumentRegistry.IWidgetExtension< NotebookPanel, INotebookModel > & { - init(props: IDatalayerNotebookExtensionProps): void; + init(props: INotebookExtensionProps): void; get component(): JSX.Element | null; }; diff --git a/packages/react/src/components/notebook/cell/sidebar/CellSidebarExtension.tsx b/packages/react/src/components/notebook/cell/sidebar/CellSidebarExtension.tsx index ee49b8b94..7e1c8e620 100644 --- a/packages/react/src/components/notebook/cell/sidebar/CellSidebarExtension.tsx +++ b/packages/react/src/components/notebook/cell/sidebar/CellSidebarExtension.tsx @@ -16,8 +16,8 @@ import { Signal } from '@lumino/signaling'; import { JupyterReactTheme } from '../../../../theme'; import { CellSidebar, type ICellSidebarProps } from './CellSidebar'; import { - DatalayerNotebookExtension, - IDatalayerNotebookExtensionProps, + NotebookExtension, + INotebookExtensionProps, } from '../../NotebookExtensions'; class CellSidebarFactory implements IDisposable { @@ -130,7 +130,7 @@ type ICellSidebarExtensionOptions = { /** * Cell sidebar extension for notebook panels. */ -export class CellSidebarExtension implements DatalayerNotebookExtension { +export class CellSidebarExtension implements NotebookExtension { protected factory: React.JSXElementConstructor; protected commands?: CommandRegistry; protected nbgrader?: boolean; @@ -165,7 +165,7 @@ export class CellSidebarExtension implements DatalayerNotebookExtension { return sidebar; } - init(props: IDatalayerNotebookExtensionProps): void { + init(props: INotebookExtensionProps): void { this.commands = props.commands; } } diff --git a/packages/react/src/components/output/Output.tsx b/packages/react/src/components/output/Output.tsx index 2fa08dd5b..c25696bc6 100644 --- a/packages/react/src/components/output/Output.tsx +++ b/packages/react/src/components/output/Output.tsx @@ -14,13 +14,12 @@ import { useJupyter } from '../../jupyter/JupyterContext'; import { IExecutionPhaseOutput, Kernel } from '../../jupyter/kernel'; import { newUuid } from '../../utils'; import { KernelActionMenu, KernelProgressBar } from '../kernel'; -// import { CodeMirrorDatalayerEditor } from '../codemirror'; +import { CodeMirrorEditor } from '../codemirror'; import { OutputAdapter } from './OutputAdapter'; import { OutputRenderer } from './OutputRenderer'; import { useOutputsStore } from './OutputState'; import './Output.css'; -import { CodeMirrorDatalayerEditor } from '../codemirror'; export type IOutputProps = { adapter?: OutputAdapter; @@ -184,7 +183,7 @@ export const Output = (props: IOutputProps) => { }, }} > - { serviceManager={serviceManager} height="calc(100vh - 2.6rem)" // (Height - Toolbar Height). extensions={extensions} - /* - collaborationServer={{ - baseURL: 'https://prod1.datalayer.run', - token: '', - documentName: '', - type: 'datalayer' - }} - */ /> ) : ( diff --git a/packages/react/src/examples/Notebook2Collaborative.tsx b/packages/react/src/examples/Notebook2Collaborative.tsx new file mode 100644 index 000000000..7d68899ee --- /dev/null +++ b/packages/react/src/examples/Notebook2Collaborative.tsx @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +import { useMemo } from 'react'; +import { createRoot } from 'react-dom/client'; +import { JupyterReactTheme } from '../theme/JupyterReactTheme'; +import { useJupyter, JupyterCollaborationProvider } from '../jupyter'; +import { + Notebook2, + CellSidebarExtension, + CellSidebarButton, +} from '../components'; + +const Notebook2Collaborative = () => { + const { serviceManager } = useJupyter(); + const extensions = useMemo( + () => [new CellSidebarExtension({ factory: CellSidebarButton })], + [] + ); + + const collaborationProvider = useMemo( + () => new JupyterCollaborationProvider(), + [] + ); + + return serviceManager ? ( + + + + ) : ( + <> + ); +}; + +const div = document.createElement('div'); +document.body.appendChild(div); +const root = createRoot(div); + +root.render(); diff --git a/packages/react/src/examples/NotebookCollaborative.tsx b/packages/react/src/examples/NotebookCollaborative.tsx index ac87c0f34..857efdd0d 100644 --- a/packages/react/src/examples/NotebookCollaborative.tsx +++ b/packages/react/src/examples/NotebookCollaborative.tsx @@ -11,16 +11,24 @@ import { CellSidebarButton } from '../components/notebook/cell/sidebar/CellSideb import { Notebook } from '../components/notebook/Notebook'; import { JupyterReactTheme } from '../theme/JupyterReactTheme'; import { NotebookToolbar } from './../components/notebook/toolbar/NotebookToolbar'; +import { JupyterCollaborationProvider } from '../jupyter/collaboration/providers/JupyterCollaborationProvider'; const NotebookCollaborative = () => { const extensions = useMemo( () => [new CellSidebarExtension({ factory: CellSidebarButton })], [] ); + + // Create a Jupyter collaboration provider + const collaborationProvider = useMemo( + () => new JupyterCollaborationProvider(), + [] + ); + return ( { - const [index, setIndex] = useState(0); - const [nbformat, setNbformat] = useState(nbformatExample as INotebookContent); - const [readonly, setReadonly] = useState(true); - const [serverless, setServerless] = useState(true); - const [kernelIndex, setKernelIndex] = useState(-1); - const [waiting, setWaiting] = useState(false); - const [lite, setLite] = useState(false); - const [serviceManager, setServiceManager] = - useState(SERVICE_MANAGER_LESS); - const [sessions, setSessions] = useState>( - [] - ); - const { datalayerConfig } = useJupyterReactStore(); - const notebookStore = useNotebookStore(); - const notebook = notebookStore.selectNotebook(NOTEBOOK_ID); - const onSessionConnection: OnSessionConnection = ( - session: Session.ISessionConnection | undefined - ) => { - console.log('Received a Kernel Sessoin.', session); - if (session) { - setSessions(sessions.concat(session)); - } - }; - const changeIndex = (index: number) => { - setIndex(index); - switch (index) { - case 0: { - setKernelIndex(-1); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(true); - setReadonly(true); - setLite(false); - setServiceManager(SERVICE_MANAGER_LESS); - break; - } - case 1: { - setJupyterServerUrl(location.protocol + '//' + location.host); - createLiteServiceManager().then(liteServiceManager => { - setKernelIndex(-1); - console.log('Lite Service Manager is available', liteServiceManager); - setServiceManager(liteServiceManager); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setLite(true); - }); - break; - } - case 2: { - setJupyterServerUrl(DEFAULT_JUPYTER_SERVER_URL); - setKernelIndex(-1); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setLite(false); - const serverSettings = createServerSettings( - getJupyterServerUrl(), - getJupyterServerToken() - ); - const serviceManager = new ServiceManager({ serverSettings }); - (serviceManager as any)['__NAME__'] = 'MutatingServiceManager'; - setServiceManager(serviceManager); - break; - } - case 3: { - // setWaiting(true); - setLite(false); - createDatalayerServiceManager( - datalayerConfig?.cpuEnvironment || 'python-simple-env', - datalayerConfig?.credits || 1 - ).then(serviceManager => { - (serviceManager as any)['__NAME__'] = 'DatalayerCPUServiceManager'; - setServiceManager(serviceManager); - setServerless(false); - setReadonly(false); - setKernelIndex(0); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - // setWaiting(false); - }); - break; - } - case 4: { - setWaiting(true); - setLite(false); - createDatalayerServiceManager( - datalayerConfig?.gpuEnvironment || 'pytorch-cuda-env', - datalayerConfig?.credits || 1 - ).then(serviceManager => { - setKernelIndex(0); - (serviceManager as any)['__NAME__'] = 'DatalayerGPUServiceManager'; - setServiceManager(serviceManager); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setWaiting(false); - }); - break; - } - } - }; - return ( - - <> - - - changeIndex(index)} - aria-label="jupyter-react-example" - > - - Readonly - - - Browser Kernel - - - OSS Kernel (CPU) - - - Kernel (CPU) - - - Kernel (GPU) - - - - - {/* - - - */} - - - - - - - - - Kernel Sessions - - - {sessions.map(session => { - return ( - - - {session.name} {session.id} clientId [ - {session.kernel?.clientId}) - id {session.kernel?.id} - - - ); - })} - - {waiting ? ( - - ) : ( - - )} - - - ); -}; - -const div = document.createElement('div'); -document.body.appendChild(div); -const root = createRoot(div); - -root.render(); diff --git a/packages/react/src/examples/NotebookMutationsServiceManager.tsx b/packages/react/src/examples/NotebookMutationsServiceManager.tsx deleted file mode 100644 index f3d97b30d..000000000 --- a/packages/react/src/examples/NotebookMutationsServiceManager.tsx +++ /dev/null @@ -1,238 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -import { useState } from 'react'; -import { createRoot } from 'react-dom/client'; -import { Box, SegmentedControl, Label, Text } from '@primer/react'; -import { INotebookContent } from '@jupyterlab/nbformat'; -import { Session, ServiceManager } from '@jupyterlab/services'; -import { - createLiteServiceManager, - createServerSettings, - setJupyterServerUrl, - getJupyterServerUrl, - getJupyterServerToken, - ServiceManagerLess, - loadJupyterConfig, - DEFAULT_JUPYTER_SERVER_URL, - Lite, -} from '../jupyter'; -import { useJupyterReactStore, OnSessionConnection } from '../state'; -import { useNotebookStore, Notebook, SpinnerCentered } from './../components'; -import { JupyterReactTheme } from '../theme'; -import { createDatalayerServiceManager } from './../providers'; - -import nbformatExample from './notebooks/NotebookExample1.ipynb.json'; - -const NOTEBOOK_ID = 'notebook-mutations-id'; - -loadJupyterConfig(); - -const SERVICE_MANAGER_LESS = new ServiceManagerLess(); - -const NotebookMutationsServiceManager = () => { - const [index, setIndex] = useState(0); - const [nbformat, setNbformat] = useState(nbformatExample as INotebookContent); - const [readonly, setReadonly] = useState(true); - const [serverless, setServerless] = useState(true); - const [startDefaultKernel, setStartDefaultKernel] = useState(false); - const [kernelIndex, setKernelIndex] = useState(-1); - const [waiting, setWaiting] = useState(false); - const [lite, setLite] = useState(false); - const [serviceManager, setServiceManager] = - useState(SERVICE_MANAGER_LESS); - const [sessions, setSessions] = useState>( - [] - ); - const { datalayerConfig } = useJupyterReactStore(); - const notebookStore = useNotebookStore(); - const notebook = notebookStore.selectNotebook(NOTEBOOK_ID); - const onSessionConnection: OnSessionConnection = ( - session: Session.ISessionConnection | undefined - ) => { - console.log('Received a Kernel Session.', session); - if (session) { - setSessions(sessions.concat(session)); - } - }; - const changeIndex = (index: number) => { - setIndex(index); - switch (index) { - case 0: { - setKernelIndex(-1); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(true); - setReadonly(true); - setLite(false); - setStartDefaultKernel(false); - setServiceManager(SERVICE_MANAGER_LESS); - break; - } - case 1: { - setJupyterServerUrl(location.protocol + '//' + location.host); - createLiteServiceManager().then(liteServiceManager => { - console.log('Lite Service Manager is available', liteServiceManager); - setKernelIndex(-1); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setLite(true); - setStartDefaultKernel(true); - setServiceManager(liteServiceManager); - }); - break; - } - case 2: { - setJupyterServerUrl(DEFAULT_JUPYTER_SERVER_URL); - setKernelIndex(-1); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setLite(false); - setStartDefaultKernel(true); - const serverSettings = createServerSettings( - getJupyterServerUrl(), - getJupyterServerToken() - ); - const serviceManager = new ServiceManager({ serverSettings }); - (serviceManager as any)['__NAME__'] = 'MutatingServiceManager'; - setServiceManager(serviceManager); - break; - } - case 3: { - createDatalayerServiceManager( - datalayerConfig?.cpuEnvironment || 'python-simple-env', - datalayerConfig?.credits || 1 - ).then(serviceManager => { - setLite(false); - setServerless(false); - setReadonly(false); - setStartDefaultKernel(false); - setKernelIndex(0); - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - (serviceManager as any)['__NAME__'] = 'DatalayerCPUServiceManager'; - setServiceManager(serviceManager); - // setWaiting(false); - }); - break; - } - case 4: { - setWaiting(true); - setLite(false); - createDatalayerServiceManager( - datalayerConfig?.gpuEnvironment || 'pytorch-cuda-env', - datalayerConfig?.credits || 1 - ).then(serviceManager => { - setNbformat( - notebook?.adapter?.notebookPanel?.content.model?.toJSON() as INotebookContent - ); - setServerless(false); - setReadonly(false); - setStartDefaultKernel(false); - setWaiting(false); - setKernelIndex(0); - (serviceManager as any)['__NAME__'] = 'DatalayerGPUServiceManager'; - setServiceManager(serviceManager); - }); - break; - } - } - }; - return ( - - <> - - - changeIndex(index)} - aria-label="jupyter-react-example" - > - - Readonly - - - Browser Kernel - - - OSS Kernel (CPU) - - - Kernel (CPU) - - - Kernel (GPU) - - - - - {/* - - - */} - - - - - - - - - Kernel Sessions - - - {sessions.map(session => { - return ( - - - {session.name} {session.id} clientId{' '} - {session.kernel?.clientId} - id {session.kernel?.id} - - - ); - })} - - {waiting ? ( - - ) : ( - - )} - - - ); -}; - -const div = document.createElement('div'); -document.body.appendChild(div); -const root = createRoot(div); - -root.render(); diff --git a/packages/react/src/examples/extensions/celltoolbar/CellToolbar.tsx b/packages/react/src/examples/extensions/celltoolbar/CellToolbar.tsx index 2c71015f4..668eef730 100644 --- a/packages/react/src/examples/extensions/celltoolbar/CellToolbar.tsx +++ b/packages/react/src/examples/extensions/celltoolbar/CellToolbar.tsx @@ -6,16 +6,16 @@ import { ReactWidget } from '@jupyterlab/apputils'; import { CodeCell } from '@jupyterlab/cells'; -import { IDatalayerNotebookExtensionProps } from '../../../components'; +import { INotebookExtensionProps } from '../../../components'; import { CellToolbarComponent } from './CellToolbarComponent'; export const DATALAYER_CELL_TOOLBAR_CLASS = 'dla-CellToolbar-Container'; export class CellToolbar extends ReactWidget { private _cell: CodeCell; - private _props: IDatalayerNotebookExtensionProps; + private _props: INotebookExtensionProps; - constructor(cell: CodeCell, props: IDatalayerNotebookExtensionProps) { + constructor(cell: CodeCell, props: INotebookExtensionProps) { super(); this._cell = cell; this._props = props; diff --git a/packages/react/src/examples/extensions/celltoolbar/CellToolbarComponent.tsx b/packages/react/src/examples/extensions/celltoolbar/CellToolbarComponent.tsx index 7c6e5b6c6..9dfb15486 100644 --- a/packages/react/src/examples/extensions/celltoolbar/CellToolbarComponent.tsx +++ b/packages/react/src/examples/extensions/celltoolbar/CellToolbarComponent.tsx @@ -13,14 +13,11 @@ import { SquareIcon, XIcon, } from '@primer/octicons-react'; -import { - useNotebookStore, - IDatalayerNotebookExtensionProps, -} from '../../../components'; +import { useNotebookStore, INotebookExtensionProps } from '../../../components'; type ICellToolbarComponentProps = { cell: CodeCell; - extensionProps: IDatalayerNotebookExtensionProps; + extensionProps: INotebookExtensionProps; }; export const CellToolbarComponent = (props: ICellToolbarComponentProps) => { diff --git a/packages/react/src/examples/extensions/celltoolbar/CellToolbarExtension.tsx b/packages/react/src/examples/extensions/celltoolbar/CellToolbarExtension.tsx index b1f8d46f2..f5bbd1735 100644 --- a/packages/react/src/examples/extensions/celltoolbar/CellToolbarExtension.tsx +++ b/packages/react/src/examples/extensions/celltoolbar/CellToolbarExtension.tsx @@ -7,18 +7,18 @@ import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { - DatalayerNotebookExtension, - IDatalayerNotebookExtensionProps, + NotebookExtension, + INotebookExtensionProps, } from '../../../components'; import { CellToolbarWidget } from './CellToolbarWidget'; import './CellToolbarExtension.css'; -export class CellToolbarExtension implements DatalayerNotebookExtension { - private _props?: IDatalayerNotebookExtensionProps; +export class CellToolbarExtension implements NotebookExtension { + private _props?: INotebookExtensionProps; /* @override */ - init(props: IDatalayerNotebookExtensionProps) { + init(props: INotebookExtensionProps) { this._props = props; } diff --git a/packages/react/src/examples/extensions/celltoolbar/CellToolbarWidget.tsx b/packages/react/src/examples/extensions/celltoolbar/CellToolbarWidget.tsx index 2dc167119..31e4c399f 100644 --- a/packages/react/src/examples/extensions/celltoolbar/CellToolbarWidget.tsx +++ b/packages/react/src/examples/extensions/celltoolbar/CellToolbarWidget.tsx @@ -8,7 +8,7 @@ import { Widget, PanelLayout } from '@lumino/widgets'; import { NotebookPanel } from '@jupyterlab/notebook'; import { IObservableList } from '@jupyterlab/observables'; import { Cell, CodeCell, ICellModel } from '@jupyterlab/cells'; -import { IDatalayerNotebookExtensionProps } from '../../../components'; +import { INotebookExtensionProps } from '../../../components'; import { CellToolbar, DATALAYER_CELL_TOOLBAR_CLASS } from './CellToolbar'; export interface ICellToolbarSettings { @@ -18,9 +18,9 @@ export interface ICellToolbarSettings { export class CellToolbarWidget extends Widget { private _panel: NotebookPanel; - private _props: IDatalayerNotebookExtensionProps; + private _props: INotebookExtensionProps; - constructor(panel: NotebookPanel, props: IDatalayerNotebookExtensionProps) { + constructor(panel: NotebookPanel, props: INotebookExtensionProps) { super(); this._panel = panel; this._props = props; diff --git a/packages/react/src/examples/extensions/exectime/ExecTimeExtension.tsx b/packages/react/src/examples/extensions/exectime/ExecTimeExtension.tsx index 369779d7c..2d6104d13 100644 --- a/packages/react/src/examples/extensions/exectime/ExecTimeExtension.tsx +++ b/packages/react/src/examples/extensions/exectime/ExecTimeExtension.tsx @@ -7,16 +7,16 @@ import { INotebookModel, NotebookPanel } from '@jupyterlab/notebook'; import { DocumentRegistry } from '@jupyterlab/docregistry'; import { - DatalayerNotebookExtension, - IDatalayerNotebookExtensionProps, + NotebookExtension, + INotebookExtensionProps, } from '../../../components'; import { ExecTimeWidget } from './ExecTimeWidget'; import './ExecTimeExtension.css'; -export class ExecTimeExtension implements DatalayerNotebookExtension { +export class ExecTimeExtension implements NotebookExtension { /* @override */ - init(props: IDatalayerNotebookExtensionProps) {} + init(props: INotebookExtensionProps) {} /* @override */ createNew( diff --git a/packages/react/src/examples/extensions/toc/TocExtension.tsx b/packages/react/src/examples/extensions/toc/TocExtension.tsx index b9cde60c8..4a9fff667 100644 --- a/packages/react/src/examples/extensions/toc/TocExtension.tsx +++ b/packages/react/src/examples/extensions/toc/TocExtension.tsx @@ -12,8 +12,8 @@ import { } from '@jupyterlab/toc'; import { BoxPanel } from '@lumino/widgets'; import { - DatalayerNotebookExtension, - IDatalayerNotebookExtensionProps, + NotebookExtension, + INotebookExtensionProps, notebookStore, } from '../../../components'; import { JupyterLayoutFactory } from './JupyterLayoutFactory'; @@ -40,8 +40,8 @@ export interface TocExtensionOptions { } /** Table of Contents Extension */ -export class TocExtension implements DatalayerNotebookExtension { - private _props: IDatalayerNotebookExtensionProps; +export class TocExtension implements NotebookExtension { + private _props: INotebookExtensionProps; private _tocRegistry: TableOfContentsRegistry; private _tocTracker: TableOfContentsTracker; private _layoutFactory: TocLayoutFactory; @@ -51,7 +51,7 @@ export class TocExtension implements DatalayerNotebookExtension { this._layoutFactory = options.factory ?? new JupyterLayoutFactory(); } - init(props: IDatalayerNotebookExtensionProps) { + init(props: INotebookExtensionProps) { this._props = props; this._tocRegistry = new TableOfContentsRegistry(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 855462bfb..f51932e71 100755 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -8,6 +8,5 @@ export * from './app'; export * from './components'; export * from './jupyter'; export * from './state'; -export * from './providers'; export * from './theme'; export * from './utils'; diff --git a/packages/react/src/jupyter/JupyterConfig.ts b/packages/react/src/jupyter/JupyterConfig.ts index 4c8b064b6..32fc2ef9a 100644 --- a/packages/react/src/jupyter/JupyterConfig.ts +++ b/packages/react/src/jupyter/JupyterConfig.ts @@ -27,11 +27,6 @@ export type IJupyterConfig = { */ let config: IJupyterConfig | undefined = undefined; -/** - * Datalayer configuration is loaded. - */ -let datalayerConfigLoaded = false; - /** * Setter for jupyterServerUrl. */ @@ -75,37 +70,6 @@ export const getJupyterServerToken = () => { return config.jupyterServerToken; }; -/** - * Get the datalayer configuration fully - * or for a particular parameter. - * - * @param name The parameter name - * @returns The parameter value if {@link name} is specified, otherwise the full configuration. - */ -function loadDatalayerConfig(name?: string): any { - if (!datalayerConfigLoaded) { - const datalayerConfigData = document.getElementById( - 'datalayer-config-data' - ); - if (datalayerConfigData?.textContent) { - console.log('Found Datalayer config data in page', datalayerConfigData); - try { - config = { - ...config, - ...JSON.parse(datalayerConfigData.textContent), - }; - datalayerConfigLoaded = true; - } catch (error) { - console.error('Failed to parse the Datalayer configuration.', error); - } - } else { - console.log('No Datalayer config data found in page'); - } - } - // @ts-expect-error IJupyterConfig does not have index signature - return name ? config[name] : config; -} - /** * Method to load the Jupyter configuration from the host HTML page. */ @@ -145,35 +109,24 @@ export const loadJupyterConfig = ( } // Hub related information ('hubHost' 'hubPrefix' 'hubUser' ,'hubServerName'). config.insideJupyterHub = PageConfig.getOption('hubHost') !== ''; - // Load the Datalayer config. - loadDatalayerConfig(); - if (datalayerConfigLoaded) { - // There is a Datalayer config, mix the configs... - setJupyterServerUrl(jupyterServerUrl || config.jupyterServerUrl); - setJupyterServerToken(jupyterServerToken || config.jupyterServerToken); + // Look for a Jupyter config... + if (jupyterConfig) { + setJupyterServerUrl( + jupyterServerUrl ?? + jupyterConfig.baseUrl ?? + location.protocol + '//' + location.host + jupyterConfig.baseUrl + ); + setJupyterServerToken(jupyterServerToken ?? jupyterConfig.token ?? ''); } else { - // No Datalayer config, look for a Jupyter config... - if (jupyterConfig) { - setJupyterServerUrl( - jupyterServerUrl ?? - jupyterConfig.jupyterServerUrl ?? - location.protocol + '//' + location.host + jupyterConfig.baseUrl - ); - setJupyterServerToken(jupyterServerToken ?? jupyterConfig.token ?? ''); - } else { - // No Datalayer and no Jupyter config, rely on location... - setJupyterServerUrl( - jupyterServerUrl ?? - config.jupyterServerUrl ?? - location.protocol + - '//' + - location.host + - DEFAULT_API_KERNEL_PREFIX_URL - ); - setJupyterServerToken( - jupyterServerToken ?? config.jupyterServerToken ?? '' - ); - } + // No Jupyter config, rely on location... + setJupyterServerUrl( + jupyterServerUrl ?? + config.jupyterServerUrl ?? + location.protocol + '//' + location.host + DEFAULT_API_KERNEL_PREFIX_URL + ); + setJupyterServerToken( + jupyterServerToken ?? config.jupyterServerToken ?? '' + ); } if (lite) { setJupyterServerUrl(location.protocol + '//' + location.host); diff --git a/packages/react/src/jupyter/collaboration/CollaborationContext.tsx b/packages/react/src/jupyter/collaboration/CollaborationContext.tsx new file mode 100644 index 000000000..fe80dbf3b --- /dev/null +++ b/packages/react/src/jupyter/collaboration/CollaborationContext.tsx @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +import React, { + createContext, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { YNotebook } from '@jupyter/ydoc'; +import { + ICollaborationProvider, + CollaborationStatus, +} from './ICollaborationProvider'; + +/** + * Collaboration context value + */ +export interface ICollaborationContext { + /** + * The collaboration provider instance + */ + provider: ICollaborationProvider | null; + /** + * Current connection status + */ + status: CollaborationStatus; + /** + * Whether the provider is connected + */ + isConnected: boolean; + /** + * Whether the document is synchronized + */ + isSynced: boolean; + /** + * Error if any + */ + error: Error | null; + /** + * Connect to collaboration service + */ + connect: ( + sharedModel: YNotebook, + documentId: string, + options?: Record + ) => Promise; + /** + * Disconnect from collaboration service + */ + disconnect: () => void; +} + +const CollaborationContext = createContext( + undefined +); + +/** + * Props for CollaborationProvider component + */ +export interface ICollaborationProviderProps { + /** + * Collaboration provider instance + */ + provider?: ICollaborationProvider; + /** + * Children components + */ + children: React.ReactNode; +} + +/** + * Collaboration provider component + * + * This component provides collaboration context to its children. + */ +export function CollaborationProvider({ + provider: providerProp, + children, +}: ICollaborationProviderProps): JSX.Element { + const [status, setStatus] = useState( + CollaborationStatus.Disconnected + ); + const [isSynced, setIsSynced] = useState(false); + const [error, setError] = useState(null); + + // Use the provider instance + const provider = useMemo(() => { + return providerProp || null; + }, [providerProp]); + + // Subscribe to provider events + useEffect(() => { + if (!provider) { + return; + } + + const statusHandler = ( + sender: ICollaborationProvider, + newStatus: CollaborationStatus + ) => { + setStatus(newStatus); + }; + + const errorHandler = (sender: ICollaborationProvider, error: Error) => { + setError(error); + }; + + const syncHandler = (sender: ICollaborationProvider, synced: boolean) => { + setIsSynced(synced); + }; + + provider.events.statusChanged.connect(statusHandler); + provider.events.errorOccurred.connect(errorHandler); + provider.events.syncStateChanged.connect(syncHandler); + + // Set initial status + setStatus(provider.status); + + return () => { + provider.events.statusChanged.disconnect(statusHandler); + provider.events.errorOccurred.disconnect(errorHandler); + provider.events.syncStateChanged.disconnect(syncHandler); + }; + }, [provider]); + + // Cleanup on unmount + useEffect(() => { + return () => { + provider?.dispose(); + }; + }, [provider]); + + const connect = async ( + sharedModel: YNotebook, + documentId: string, + options?: Record + ): Promise => { + if (!provider) { + throw new Error('No collaboration provider configured'); + } + setError(null); + try { + await provider.connect(sharedModel, documentId, options); + } catch (err) { + setError(err as Error); + throw err; + } + }; + + const disconnect = (): void => { + provider?.disconnect(); + setIsSynced(false); + setError(null); + }; + + const value: ICollaborationContext = { + provider, + status, + isConnected: status === CollaborationStatus.Connected, + isSynced, + error, + connect, + disconnect, + }; + + return ( + + {children} + + ); +} + +/** + * Hook to use collaboration context + * + * @returns The collaboration context value + * @throws Error if used outside of CollaborationProvider + */ +export function useCollaboration(): ICollaborationContext { + const context = useContext(CollaborationContext); + if (!context) { + throw new Error( + 'useCollaboration must be used within a CollaborationProvider' + ); + } + return context; +} + +/** + * Hook to get collaboration status + */ +export function useCollaborationStatus(): CollaborationStatus { + const { status } = useCollaboration(); + return status; +} + +/** + * Hook to check if collaboration is connected + */ +export function useIsCollaborationConnected(): boolean { + const { isConnected } = useCollaboration(); + return isConnected; +} diff --git a/packages/react/src/jupyter/collaboration/DatalayerCollaboration.ts b/packages/react/src/jupyter/collaboration/DatalayerCollaboration.ts deleted file mode 100644 index e70cc9ecb..000000000 --- a/packages/react/src/jupyter/collaboration/DatalayerCollaboration.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -type IFetchSessionId = { - url: string; - token?: string; -}; - -/** - * Fetch the session ID of a collaborative documents from Datalayer. - */ -export async function requestDatalayerollaborationSessionId({ - url, - token, -}: IFetchSessionId): Promise { - const headers: HeadersInit = { - Accept: 'application/json', - }; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - const response = await fetch(url, { - method: 'GET', - headers, - credentials: token ? 'include' : 'omit', - mode: 'cors', - cache: 'no-store', - }); - if (response.ok) { - const content = await response.json(); - return content['sessionId']; - } - console.error('Failed to fetch session ID.', response); - throw new Error('Failed to fetch session ID.'); -} diff --git a/packages/react/src/jupyter/collaboration/ICollaborationProvider.ts b/packages/react/src/jupyter/collaboration/ICollaborationProvider.ts new file mode 100644 index 000000000..5ae694860 --- /dev/null +++ b/packages/react/src/jupyter/collaboration/ICollaborationProvider.ts @@ -0,0 +1,208 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +import { YNotebook } from '@jupyter/ydoc'; +import { WebsocketProvider } from 'y-websocket'; +import { IDisposable } from '@lumino/disposable'; +import { ISignal, Signal } from '@lumino/signaling'; + +/** + * Collaboration provider connection status + */ +export enum CollaborationStatus { + Disconnected = 'disconnected', + Connecting = 'connecting', + Connected = 'connected', + Error = 'error', +} + +/** + * Events emitted by collaboration providers + */ +export interface ICollaborationProviderEvents { + /** + * Signal emitted when connection status changes + */ + statusChanged: ISignal; + /** + * Signal emitted when an error occurs + */ + errorOccurred: ISignal; + /** + * Signal emitted when synchronization state changes + */ + syncStateChanged: ISignal; +} + +/** + * Interface for collaboration providers + * + * This interface defines the contract that all collaboration providers must implement. + * It provides a uniform way to connect to different collaboration backends while + * maintaining the same API for the notebook components. + */ +export interface ICollaborationProvider extends IDisposable { + /** + * Provider type identifier + */ + readonly type: string; + + /** + * Current connection status + */ + readonly status: CollaborationStatus; + + /** + * Whether the provider is currently connected + */ + readonly isConnected: boolean; + + /** + * Provider events + */ + readonly events: ICollaborationProviderEvents; + + /** + * Connect to the collaboration service + * + * @param sharedModel - The shared notebook model + * @param documentId - Document identifier + * @param options - Additional connection options + * @returns Promise that resolves when connected + */ + connect( + sharedModel: YNotebook, + documentId: string, + options?: Record + ): Promise; + + /** + * Disconnect from the collaboration service + */ + disconnect(): void; + + /** + * Get the underlying WebSocket provider + * + * @returns The WebSocket provider or null if not connected + */ + getProvider(): WebsocketProvider | null; + + /** + * Get the shared model + * + * @returns The shared model or null if not connected + */ + getSharedModel(): YNotebook | null; + + /** + * Handle connection close event + * + * @param event - Close event + */ + handleConnectionClose(event: CloseEvent): void; + + /** + * Handle synchronization event + * + * @param isSynced - Whether the document is synchronized + */ + handleSync(isSynced: boolean): void; +} + +/** + * Abstract base class for collaboration providers + * + * This class provides common functionality for all collaboration providers. + */ +export abstract class CollaborationProviderBase + implements ICollaborationProvider +{ + protected _status: CollaborationStatus = CollaborationStatus.Disconnected; + protected _provider: WebsocketProvider | null = null; + protected _sharedModel: YNotebook | null = null; + protected _statusChanged = new Signal(this); + protected _errorOccurred = new Signal(this); + protected _syncStateChanged = new Signal(this); + protected _isDisposed = false; + + constructor(public readonly type: string) {} + + get status(): CollaborationStatus { + return this._status; + } + + get isConnected(): boolean { + return this._status === CollaborationStatus.Connected; + } + + get isDisposed(): boolean { + return this._isDisposed; + } + + get events(): ICollaborationProviderEvents { + return { + statusChanged: this._statusChanged, + errorOccurred: this._errorOccurred, + syncStateChanged: this._syncStateChanged, + }; + } + + abstract connect( + sharedModel: YNotebook, + documentId: string, + options?: Record + ): Promise; + + disconnect(): void { + if (this._provider) { + this._provider.disconnect(); + this._provider.destroy(); + this._provider = null; + } + this._sharedModel = null; + this.setStatus(CollaborationStatus.Disconnected); + } + + getProvider(): WebsocketProvider | null { + return this._provider; + } + + getSharedModel(): YNotebook | null { + return this._sharedModel; + } + + handleConnectionClose(event: CloseEvent): void { + if (event.code > 1000) { + console.error('Connection closed unexpectedly:', event); + this.setStatus(CollaborationStatus.Error); + this._errorOccurred.emit(new Error(`Connection closed: ${event.reason}`)); + } + } + + handleSync(isSynced: boolean): void { + this._syncStateChanged.emit(isSynced); + if (isSynced) { + this.setStatus(CollaborationStatus.Connected); + } + } + + dispose(): void { + if (this._isDisposed) { + return; + } + this.disconnect(); + Signal.clearData(this); + this._isDisposed = true; + } + + protected setStatus(status: CollaborationStatus): void { + if (this._status !== status) { + this._status = status; + this._statusChanged.emit(status); + } + } +} diff --git a/packages/react/src/jupyter/collaboration/ICollaborative.ts b/packages/react/src/jupyter/collaboration/ICollaborative.ts deleted file mode 100644 index f89b8982f..000000000 --- a/packages/react/src/jupyter/collaboration/ICollaborative.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -import { ServerConnection } from '@jupyterlab/services'; - -export type IJupyterCollaborationServer = { - /** - * Base server URL - */ - baseURL: string; - /** - * Notebook document name to connect to. - */ - documentName: string; - /** - * JWT token - */ - token: string; - /** - * Server type - */ - type: 'datalayer'; -}; - -export type IDatalayerCollaborationServer = { - /** - * Notebook path - */ - path: string; - /** - * Jupyter server settings - */ - serverSettings: ServerConnection.ISettings; - /** - * Server type - */ - type: 'jupyter'; -}; - -export type ICollaborationServer = - | IJupyterCollaborationServer - | IDatalayerCollaborationServer; - -export type ICollaborationProvider = 'jupyter' | 'datalayer' | undefined; - -export default ICollaborationProvider; diff --git a/packages/react/src/jupyter/collaboration/JupyterCollaboration.ts b/packages/react/src/jupyter/collaboration/JupyterCollaboration.ts index 2ea12137a..a45bc000f 100644 --- a/packages/react/src/jupyter/collaboration/JupyterCollaboration.ts +++ b/packages/react/src/jupyter/collaboration/JupyterCollaboration.ts @@ -7,7 +7,7 @@ import { URLExt } from '@jupyterlab/coreutils'; import { Contents, ServerConnection } from '@jupyterlab/services'; -export const COLLABORATION_ROOM_URL_PATH = 'api/collaboration/document'; +export const COLLABORATION_ROOM_URL_PATH = 'api/collaboration/room'; export const COLLABORATION_SESSION_URL_PATH = 'api/collaboration/session'; diff --git a/packages/react/src/jupyter/collaboration/index.ts b/packages/react/src/jupyter/collaboration/index.ts index 7b1f3f96a..2c731f778 100644 --- a/packages/react/src/jupyter/collaboration/index.ts +++ b/packages/react/src/jupyter/collaboration/index.ts @@ -4,6 +4,7 @@ * MIT License */ -export * from './DatalayerCollaboration'; -export * from './ICollaborative'; export * from './JupyterCollaboration'; +export * from './ICollaborationProvider'; +export * from './CollaborationContext'; +export * from './providers'; diff --git a/packages/react/src/jupyter/collaboration/providers/JupyterCollaborationProvider.ts b/packages/react/src/jupyter/collaboration/providers/JupyterCollaborationProvider.ts new file mode 100644 index 000000000..4d70b1b41 --- /dev/null +++ b/packages/react/src/jupyter/collaboration/providers/JupyterCollaborationProvider.ts @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +import { YNotebook } from '@jupyter/ydoc'; +import { WebsocketProvider } from 'y-websocket'; +import { URLExt } from '@jupyterlab/coreutils'; +import { ServerConnection } from '@jupyterlab/services'; +import { + CollaborationProviderBase, + CollaborationStatus, +} from '../ICollaborationProvider'; +import { + COLLABORATION_ROOM_URL_PATH, + requestJupyterCollaborationSession, +} from '../JupyterCollaboration'; + +/** + * Configuration for Jupyter collaboration provider + */ +export interface IJupyterCollaborationConfig { + /** + * Notebook file path (optional - can be provided via connect options) + */ + path?: string; + /** + * Server settings + */ + serverSettings?: ServerConnection.ISettings; + /** + * Format of the document + */ + format?: string; + /** + * Type of the document + */ + documentType?: string; +} + +/** + * Jupyter collaboration provider + * + * This provider connects to Jupyter's collaboration service using WebSockets. + */ +export class JupyterCollaborationProvider extends CollaborationProviderBase { + private _config: IJupyterCollaborationConfig; + private _onSync: ((isSynced: boolean) => void) | null = null; + private _onConnectionClose: ((event: CloseEvent) => void) | null = null; + + constructor(config: IJupyterCollaborationConfig = {}) { + super('jupyter'); + this._config = config; + } + + async connect( + sharedModel: YNotebook, + documentId: string, + options?: Record + ): Promise { + if (this.isConnected) { + console.warn('Already connected to Jupyter collaboration service'); + return; + } + + this.setStatus(CollaborationStatus.Connecting); + + try { + const serverSettings = + this._config.serverSettings ?? ServerConnection.makeSettings(); + const { ydoc, awareness } = sharedModel; + + // Use path from options if provided, otherwise fall back to config + const path = options?.path || this._config.path; + if (!path) { + throw new Error( + 'Path is required for Jupyter collaboration. Provide it in the config or via connect options.' + ); + } + + // Request collaboration session from Jupyter + const session = await requestJupyterCollaborationSession( + this._config.format || 'json', + this._config.documentType || 'notebook', + path, + serverSettings + ); + + // Build WebSocket URL + const wsUrl = serverSettings.wsUrl; + if (!wsUrl) { + throw new Error('WebSocket URL is not available'); + } + const documentURL = URLExt.join(wsUrl, COLLABORATION_ROOM_URL_PATH); + const documentName = `${session.format}:${session.type}:${session.fileId}`; + + // Create WebSocket provider + const params: Record = { + sessionId: session.sessionId, + }; + if (serverSettings.token) { + params.token = serverSettings.token; + } + + this._provider = new WebsocketProvider(documentURL, documentName, ydoc, { + disableBc: true, + params, + awareness, + ...options, + }); + + this._sharedModel = sharedModel; + + // Set up event handlers + this._onSync = (isSynced: boolean) => { + this.handleSync(isSynced); + }; + this._onConnectionClose = (event: CloseEvent) => { + this.handleConnectionClose(event); + }; + + this._provider.on('sync', this._onSync); + this._provider.on('connection-close', this._onConnectionClose); + + console.log('Connected to Jupyter collaboration service'); + } catch (error) { + this.setStatus(CollaborationStatus.Error); + this._errorOccurred.emit(error as Error); + throw error; + } + } + + disconnect(): void { + if (this._provider) { + if (this._onSync) { + this._provider.off('sync', this._onSync); + } + if (this._onConnectionClose) { + this._provider.off('connection-close', this._onConnectionClose); + } + } + super.disconnect(); + } + + handleConnectionClose(event: CloseEvent): void { + super.handleConnectionClose(event); + + // Handle session expiration (code 4002) + if (event.code === 4002) { + console.warn('Jupyter collaboration session expired'); + // Attempt to reconnect could be implemented here? + } + } +} diff --git a/packages/react/src/jupyter/collaboration/providers/NoOpCollaborationProvider.ts b/packages/react/src/jupyter/collaboration/providers/NoOpCollaborationProvider.ts new file mode 100644 index 000000000..e8a47ed7f --- /dev/null +++ b/packages/react/src/jupyter/collaboration/providers/NoOpCollaborationProvider.ts @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +import { YNotebook } from '@jupyter/ydoc'; +import { WebsocketProvider } from 'y-websocket'; +import { + CollaborationProviderBase, + CollaborationStatus, +} from '../ICollaborationProvider'; + +/** + * Configuration for no-op collaboration provider + */ +export interface INoOpCollaborationConfig { + type?: 'none' | 'noop'; +} + +/** + * No-operation collaboration provider + * + * This provider is used when collaboration is disabled. + * It provides a null implementation of the collaboration interface. + */ +export class NoOpCollaborationProvider extends CollaborationProviderBase { + constructor(config?: INoOpCollaborationConfig) { + super('none'); + } + + async connect( + sharedModel: YNotebook, + documentId: string, + options?: Record + ): Promise { + // No-op: Just store the shared model without creating any connection + this._sharedModel = sharedModel; + this.setStatus(CollaborationStatus.Connected); + this._syncStateChanged.emit(true); + } + + disconnect(): void { + this._sharedModel = null; + this.setStatus(CollaborationStatus.Disconnected); + } + + getProvider(): WebsocketProvider | null { + // No WebSocket provider in no-op mode + return null; + } + + handleConnectionClose(event: CloseEvent): void { + // No-op: No connection to close + } + + handleSync(isSynced: boolean): void { + // No-op: Always considered synced + this._syncStateChanged.emit(true); + } +} diff --git a/packages/react/src/jupyter/collaboration/providers/index.ts b/packages/react/src/jupyter/collaboration/providers/index.ts new file mode 100644 index 000000000..277ae46b4 --- /dev/null +++ b/packages/react/src/jupyter/collaboration/providers/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) 2021-2023 Datalayer, Inc. + * + * MIT License + */ + +export * from './JupyterCollaborationProvider'; +export * from './NoOpCollaborationProvider'; diff --git a/packages/react/src/providers/index.ts b/packages/react/src/providers/index.ts deleted file mode 100755 index b76d1e76e..000000000 --- a/packages/react/src/providers/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -export * from './kernels'; diff --git a/packages/react/src/providers/kernels/DatalayerKernels.ts b/packages/react/src/providers/kernels/DatalayerKernels.ts deleted file mode 100755 index 80a95026b..000000000 --- a/packages/react/src/providers/kernels/DatalayerKernels.ts +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -import { URLExt } from '@jupyterlab/coreutils'; -import { jupyterReactStore } from '../../state'; -import { ServerConnection, ServiceManager } from '@jupyterlab/services'; - -export type KernelRequest = { - kernel_type: 'notebook'; - kernel_given_name: string; - credits_limit: number; - capabilities: string[]; -}; - -export type KernelResponse = { - success: boolean; - message: string; - kernel: { - burning_rate: number; - kernel_type: 'notebook'; - kernel_given_name: string; - environment_name: string; - environment_display_name: string; - jupyter_pod_name: string; - token: string; - ingress: string; - reservation_id: string; - started_at: string; - expired_at: string; - }; -}; - -export const createDatalayerServiceManager = async ( - environmentName: string, - credits: number -) => { - const datalayerConfig = jupyterReactStore.getState().datalayerConfig; - const token = datalayerConfig?.token || ''; - const runUrl = datalayerConfig?.runUrl || 'https://prod1.datalayer.io'; - const url = URLExt.join( - runUrl, - 'api/jupyter/v1/environment', - environmentName - ); - const headers = new Headers(); - headers.set('Accept', 'application/json'); - headers.set('Content-Type', 'application/json'); - headers.set('Authorization', `Bearer ${token}`); - const request: KernelRequest = { - kernel_type: 'notebook', - kernel_given_name: `Jupyter React Kernel - ${new Date()}`, - credits_limit: credits, - capabilities: [], - }; - const response = await fetch(url, { - method: 'POST', - headers: headers, - body: JSON.stringify(request), - credentials: token ? 'include' : 'omit', - mode: 'cors', - cache: 'no-store', - }) - .then((resp: Response) => { - if (resp.ok) { - return resp.json(); - } else { - throw new Error(resp.statusText); - } - }) - .catch((err: Error) => { - console.error(err); - return err; - }) - .finally(() => {}); - if (response instanceof Error) { - throw response as Error; - } - const kernelResponse = response as KernelResponse; - const serverSettings = ServerConnection.makeSettings({ - baseUrl: kernelResponse.kernel.ingress, - wsUrl: kernelResponse.kernel.ingress.replace(/^http/, 'ws'), - token: kernelResponse.kernel.token, - appendToken: true, - }); - const serviceManager = new ServiceManager({ serverSettings }); - return serviceManager; -}; diff --git a/packages/react/src/providers/kernels/index.ts b/packages/react/src/providers/kernels/index.ts deleted file mode 100755 index b6cc92338..000000000 --- a/packages/react/src/providers/kernels/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -export * from './DatalayerKernels'; diff --git a/packages/react/src/state/IDatalayerConfig.ts b/packages/react/src/state/IDatalayerConfig.ts deleted file mode 100644 index 413b5050e..000000000 --- a/packages/react/src/state/IDatalayerConfig.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2021-2023 Datalayer, Inc. - * - * MIT License - */ - -export type IDatalayerConfig = { - /** - * Datalayer RUN URL. - */ - runUrl: string; - /** - * Datalayer Token. - */ - token: string; - /** - * Credits. - */ - credits: number; - /** - * CPU Environment. - */ - cpuEnvironment: string; - /** - * GPU Environment. - */ - gpuEnvironment: string; -}; diff --git a/packages/react/src/state/JupyterReactState.ts b/packages/react/src/state/JupyterReactState.ts index efcd5f9fa..a94a17949 100644 --- a/packages/react/src/state/JupyterReactState.ts +++ b/packages/react/src/state/JupyterReactState.ts @@ -23,7 +23,6 @@ import { import { ServiceManagerLess } from '../jupyter/services'; import { Kernel } from '../jupyter/kernel/Kernel'; import { IJupyterConfig, loadJupyterConfig } from '../jupyter/JupyterConfig'; -import type { IDatalayerConfig } from './IDatalayerConfig'; import { cellsStore, CellsState } from '../components/cell/CellState'; import { consoleStore, ConsoleState } from '../components/console/ConsoleState'; import { @@ -47,7 +46,6 @@ export type KernelTransfer = { export type JupyterReactState = { cellsStore: CellsState; consoleStore: ConsoleState; - datalayerConfig?: IDatalayerConfig; jupyterConfig?: IJupyterConfig; kernel?: Kernel; kernelIsLoading: boolean; @@ -56,26 +54,13 @@ export type JupyterReactState = { serviceManager?: ServiceManager.IManager; terminalStore: TerminalState; version: string; - setDatalayerConfig: (configuration?: IDatalayerConfig) => void; setJupyterConfig: (configuration?: IJupyterConfig) => void; setServiceManager: (serviceManager?: ServiceManager.IManager) => void; setVersion: (version: string) => void; }; -let initialDatalayerConfig: IDatalayerConfig | undefined = undefined; - -try { - const pageConfig = document.getElementById('datalayer-config-data'); - if (pageConfig?.innerText) { - initialDatalayerConfig = JSON.parse(pageConfig?.innerText); - } -} catch (error) { - console.debug('Issue with page configuration.', error); -} - export const jupyterReactStore = createStore((set, get) => ({ collaborative: false, - datalayerConfig: initialDatalayerConfig, version: '', jupyterConfig: undefined, kernelIsLoading: true, @@ -87,9 +72,6 @@ export const jupyterReactStore = createStore((set, get) => ({ notebookStore: notebookStore.getState(), outputStore: outputsStore.getState(), terminalStore: terminalStore.getState(), - setDatalayerConfig: (datalayerConfig?: IDatalayerConfig) => { - set(state => ({ datalayerConfig })); - }, setJupyterConfig: (jupyterConfig?: IJupyterConfig) => { set(state => ({ jupyterConfig })); }, @@ -124,8 +106,8 @@ export function useJupyterReactStoreFromProps( const { defaultKernelName = DEFAULT_KERNEL_NAME, initCode = '', - jupyterServerToken = props.serviceManager?.serverSettings.token ?? '', - jupyterServerUrl = props.serviceManager?.serverSettings.baseUrl ?? '', + jupyterServerToken = props.serviceManager?.serverSettings.token, + jupyterServerUrl = props.serviceManager?.serverSettings.baseUrl, lite = false, serverless, serviceManager: propsServiceManager, diff --git a/packages/react/src/state/index.ts b/packages/react/src/state/index.ts index 27f144b10..5088c27a8 100644 --- a/packages/react/src/state/index.ts +++ b/packages/react/src/state/index.ts @@ -4,5 +4,4 @@ * MIT License */ -export * from './IDatalayerConfig'; export * from './JupyterReactState'; diff --git a/packages/react/webpack.config.js b/packages/react/webpack.config.js index 031844362..c6039e0f7 100644 --- a/packages/react/webpack.config.js +++ b/packages/react/webpack.config.js @@ -50,11 +50,12 @@ const ENTRY = // './src/examples/NotebookCellToolbar'; // './src/examples/NotebookColormode'; // './src/examples/NotebookCollaborative'; - // './src/examples/NotebookExtension'; - // './src/examples/NotebookKernel'; - // './src/examples/NotebookKernelChange'; - // './src/examples/NotebookLess'; - './src/examples/NotebookLite'; + './src/examples/Notebook2Collaborative'; +// './src/examples/NotebookExtension'; +// './src/examples/NotebookKernel'; +// './src/examples/NotebookKernelChange'; +// './src/examples/NotebookLess'; +// './src/examples/NotebookLite'; // './src/examples/NotebookLiteContext'; // './src/examples/NotebookLocalServer'; // './src/examples/NotebookMutationsKernel';