├── setup.py ├── tsconfig.test.json ├── docs ├── doc-requirements.txt ├── source │ ├── glue-jupyterlab.gif │ ├── conf.py │ └── index.rst ├── environment.yml ├── Makefile └── make.bat ├── .yarnrc.yml ├── glue_jupyterlab ├── tests │ ├── __init__.py │ ├── test_handlers.py │ ├── conftest.py │ ├── test_ydoc.py │ └── test_glue_session.py ├── handlers.py ├── __init__.py ├── glue_utils.py └── glue_ydoc.py ├── style ├── index.js ├── index.css └── icons │ └── glue-icon.svg ├── babel.config.js ├── binder ├── jupyter_config.json ├── start ├── environment.yml └── postBuild ├── examples └── w5.fits ├── glue-jupyterlab.png ├── src ├── svg.d.ts ├── __tests__ │ └── glue_lab.spec.ts ├── index.ts ├── leftPanel │ ├── data │ │ ├── subsetsWidget.ts │ │ ├── dataPanel.ts │ │ └── datasetsWidget.tsx │ ├── header.tsx │ ├── config │ │ ├── configWidget.ts │ │ ├── configPanel.ts │ │ └── configWidgetModel.ts │ ├── widget.ts │ ├── model.ts │ └── plugin.ts ├── schemas │ ├── data │ │ ├── attributes.schema.json │ │ └── dataset.schema.json │ ├── viewers │ │ ├── image.schema.json │ │ ├── 1dprofile.schema.json │ │ ├── 2dscatter.schema.json │ │ ├── histogram.schema.json │ │ └── 3dscatter.schema.json │ ├── link.schema.json │ └── glue.schema.json ├── document │ ├── tracker.ts │ ├── default.json │ ├── modelFactory.ts │ ├── widgetFactory.ts │ ├── docModel.ts │ ├── plugin.ts │ └── sharedModel.ts ├── token.ts ├── commands.ts ├── viewPanel │ ├── glueDocumentWidget.ts │ ├── gridStackItem.ts │ └── sessionWidget.ts ├── tools.ts ├── linkPanel │ ├── types.ts │ ├── linkEditorWidget.ts │ ├── linkEditor.ts │ ├── model.ts │ └── widgets │ │ └── summary.tsx ├── yWidget.ts ├── types.ts └── common │ └── tabPanel.ts ├── .prettierignore ├── .eslintignore ├── .prettierrc ├── jupyter-config ├── nb-config │ └── glue_jupyterlab.json └── server-config │ └── glue_jupyterlab.json ├── ui-tests ├── tests │ └── test.spec.ts-snapshots │ │ ├── link-editor-linux.png │ │ ├── session-tab1-linux.png │ │ ├── session-tab2-linux.png │ │ ├── control-panel-linux.png │ │ ├── add-data-data-added-linux.png │ │ ├── histogram-selection-linux.png │ │ ├── add-data-file-browser-linux.png │ │ ├── histogram-no-selection-linux.png │ │ ├── add-data-viewer-created-linux.png │ │ ├── add-data-viewer-selection-linux.png │ │ ├── control-panel-switch-tab-linux.png │ │ └── histogram-linked-selection-linux.png ├── jupyter_server_test_config.py ├── package.json ├── playwright.config.js └── README.md ├── .readthedocs.yaml ├── install.json ├── conftest.py ├── .stylelintrc ├── .github └── workflows │ ├── binder-on-pr.yml │ ├── check-release.yml │ ├── update-integration-tests.yml │ └── build.yml ├── jest.config.js ├── tsconfig.json ├── .eslintrc.js ├── CHANGELOG.md ├── LICENSE ├── .pre-commit-config.yaml ├── RELEASE.md ├── .gitignore ├── pyproject.toml ├── package.json └── README.md /setup.py: -------------------------------------------------------------------------------- 1 | __import__("setuptools").setup() 2 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig" 3 | } 4 | -------------------------------------------------------------------------------- /docs/doc-requirements.txt: -------------------------------------------------------------------------------- 1 | pydata-sphinx-theme 2 | sphinx_copybutton 3 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | enableImmutableInstalls: false 4 | -------------------------------------------------------------------------------- /glue_jupyterlab/tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Python unit tests for glue-jupyterlab.""" 2 | -------------------------------------------------------------------------------- /style/index.js: -------------------------------------------------------------------------------- 1 | import './base.css'; 2 | import 'gridstack/dist/gridstack.css'; 3 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@jupyterlab/testutils/lib/babel.config'); 2 | -------------------------------------------------------------------------------- /binder/jupyter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LabApp": { 3 | "collaborative": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /examples/w5.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/examples/w5.fits -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | @import url('base.css'); 2 | @import url('~gridstack/dist/gridstack.csss'); 3 | -------------------------------------------------------------------------------- /glue-jupyterlab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/glue-jupyterlab.png -------------------------------------------------------------------------------- /binder/start: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo $@ 6 | 7 | exec jupyter-lab --collaborative "${@:4}" 8 | -------------------------------------------------------------------------------- /src/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: string; // @ts-ignore 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/node_modules 3 | **/lib 4 | **/package.json 5 | !/package.json 6 | glue-jupyterlab 7 | -------------------------------------------------------------------------------- /docs/source/glue-jupyterlab.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/docs/source/glue-jupyterlab.gif -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | **/*.d.ts 5 | tests 6 | 7 | **/__tests__ 8 | ui-tests 9 | *.js 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "arrowParens": "avoid", 5 | "endOfLine": "auto" 6 | } 7 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: glue-jupyterlab 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - pip 6 | - sphinx 7 | - pydata-sphinx-theme 8 | -------------------------------------------------------------------------------- /jupyter-config/nb-config/glue_jupyterlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "glue_jupyterlab": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/server-config/glue_jupyterlab.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "glue_jupyterlab": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/link-editor-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/link-editor-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/session-tab1-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/session-tab1-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/session-tab2-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/session-tab2-linux.png -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: 'ubuntu-20.04' 5 | tools: 6 | python: 'mambaforge-4.10' 7 | 8 | conda: 9 | environment: docs/environment.yml 10 | -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/control-panel-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/control-panel-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/add-data-data-added-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/add-data-data-added-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/histogram-selection-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/histogram-selection-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/add-data-file-browser-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/add-data-file-browser-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/histogram-no-selection-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/histogram-no-selection-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/add-data-viewer-created-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/add-data-viewer-created-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/add-data-viewer-selection-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/add-data-viewer-selection-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/control-panel-switch-tab-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/control-panel-switch-tab-linux.png -------------------------------------------------------------------------------- /ui-tests/tests/test.spec.ts-snapshots/histogram-linked-selection-linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantStack/glue-jupyterlab/HEAD/ui-tests/tests/test.spec.ts-snapshots/histogram-linked-selection-linux.png -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "glue-jupyterlab", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package glue-jupyterlab" 5 | } 6 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | pytest_plugins = ("pytest_jupyter.jupyter_server",) 4 | 5 | 6 | @pytest.fixture 7 | def jp_server_config(jp_server_config): 8 | return {"ServerApp": {"jpserver_extensions": {"glue_jupyterlab": True}}} 9 | -------------------------------------------------------------------------------- /binder/environment.yml: -------------------------------------------------------------------------------- 1 | name: base 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | # runtime dependencies 6 | - python >=3.9,<3.10.0a0 7 | - nodejs >=16,<17 8 | - yarn 9 | 10 | # Dependencies 11 | - jupyterlab >=4.0.0,<5 12 | -------------------------------------------------------------------------------- /src/__tests__/glue_lab.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Example of [Jest](https://jestjs.io/docs/getting-started) unit tests 3 | */ 4 | 5 | describe('glue-jupyterlab', () => { 6 | it('should be tested', () => { 7 | expect(1 + 1).toEqual(2); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-recommended", 4 | "stylelint-config-standard", 5 | "stylelint-prettier/recommended" 6 | ], 7 | "rules": { 8 | "property-no-vendor-prefix": null, 9 | "selector-no-vendor-prefix": null, 10 | "value-no-vendor-prefix": null, 11 | "selector-class-pattern": null 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /glue_jupyterlab/tests/test_handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | async def test_get_advanced_links_list(jp_fetch): 5 | # When 6 | response = await jp_fetch("glue-jupyterlab", "advanced-links") 7 | 8 | # Then 9 | assert response.code == 200 10 | payload = json.loads(response.body) 11 | assert list(payload["data"].keys()) == ["General", "Astronomy", "Join"] 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { controlPanel } from './leftPanel/plugin'; 2 | import { 3 | gluePlugin, 4 | sessionTrackerPlugin, 5 | newFilePlugin 6 | } from './document/plugin'; 7 | import { yGlueSessionWidgetPlugin } from './yWidget'; 8 | 9 | export default [ 10 | sessionTrackerPlugin, 11 | gluePlugin, 12 | controlPanel, 13 | yGlueSessionWidgetPlugin, 14 | newFilePlugin 15 | ]; 16 | -------------------------------------------------------------------------------- /.github/workflows/binder-on-pr.yml: -------------------------------------------------------------------------------- 1 | name: Binder Badge 2 | on: 3 | pull_request_target: 4 | types: [opened] 5 | 6 | jobs: 7 | binder: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: jupyterlab/maintainer-tools/.github/actions/binder-link@v1 13 | with: 14 | github_token: ${{ secrets.github_token }} 15 | -------------------------------------------------------------------------------- /src/leftPanel/data/subsetsWidget.ts: -------------------------------------------------------------------------------- 1 | import { Widget } from '@lumino/widgets'; 2 | import { IControlPanelModel } from '../../types'; 3 | export class SubsetsWidget extends Widget { 4 | constructor(options: { model: IControlPanelModel }) { 5 | super(); 6 | this.title.label = 'Subsets'; 7 | this._model = options.model; 8 | void this._model; 9 | } 10 | private _model: IControlPanelModel; 11 | } 12 | -------------------------------------------------------------------------------- /src/schemas/data/attributes.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Attributes", 4 | "title": "IAttributes", 5 | "required": ["_type", "label"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "type": "string" 10 | }, 11 | "axis": { 12 | "type": "string" 13 | }, 14 | "label": { 15 | "type": "string" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/document/tracker.ts: -------------------------------------------------------------------------------- 1 | import { WidgetTracker } from '@jupyterlab/apputils'; 2 | import { IGlueSessionWidget, IGlueSessionSharedModel } from '../types'; 3 | import { IGlueSessionTracker } from '../token'; 4 | export class GlueSessionTracker 5 | extends WidgetTracker 6 | implements IGlueSessionTracker 7 | { 8 | currentSharedModel(): IGlueSessionSharedModel | undefined { 9 | return this.currentWidget?.context.model.sharedModel; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/token.ts: -------------------------------------------------------------------------------- 1 | import { IWidgetTracker } from '@jupyterlab/apputils'; 2 | import { Token } from '@lumino/coreutils'; 3 | 4 | import { IGlueSessionWidget, IGlueSessionSharedModel } from './types'; 5 | 6 | export interface IGlueSessionTracker 7 | extends IWidgetTracker { 8 | currentSharedModel(): IGlueSessionSharedModel | undefined; 9 | } 10 | 11 | export const IGlueSessionTracker = new Token( 12 | 'glueCanvasTracker' 13 | ); 14 | -------------------------------------------------------------------------------- /ui-tests/jupyter_server_test_config.py: -------------------------------------------------------------------------------- 1 | """Server configuration for integration tests. 2 | 3 | !! Never use this configuration in production because it 4 | opens the server to the world and provide access to JupyterLab 5 | JavaScript objects through the global window variable. 6 | """ 7 | 8 | from jupyterlab.galata import configure_jupyter_server 9 | 10 | configure_jupyter_server(c) # noqa 11 | 12 | # Uncomment to set server log level to debug level 13 | # c.ServerApp.log_level = "DEBUG" 14 | -------------------------------------------------------------------------------- /ui-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glue-jupyterlab-ui-tests", 3 | "version": "1.0.0", 4 | "description": "JupyterLab glue-jupyterlab Integration Tests", 5 | "private": true, 6 | "scripts": { 7 | "start": "rimraf .jupyter_ystore.db && jupyter lab ../examples --config jupyter_server_test_config.py", 8 | "test": "npx playwright test", 9 | "test:update": "npx playwright test --update-snapshots" 10 | }, 11 | "devDependencies": { 12 | "@jupyterlab/galata": "^5.0.0", 13 | "@playwright/test": "^1.32.0", 14 | "rimraf": "^3.0.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/leftPanel/header.tsx: -------------------------------------------------------------------------------- 1 | import { Widget } from '@lumino/widgets'; 2 | 3 | export class ControlPanelHeader extends Widget { 4 | /** 5 | * Instantiate a new sidebar header. 6 | */ 7 | constructor() { 8 | super({ node: createHeader() }); 9 | this.title.changed.connect(_ => { 10 | this.node.textContent = this.title.label; 11 | }); 12 | } 13 | } 14 | 15 | /** 16 | * Create a sidebar header node. 17 | */ 18 | export function createHeader(): HTMLElement { 19 | const title = document.createElement('h2'); 20 | title.textContent = '-'; 21 | return title; 22 | } 23 | -------------------------------------------------------------------------------- /src/document/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "DataCollection": { 3 | "_protocol": 4, 4 | "_type": "glue.core.data_collection.DataCollection", 5 | "cids": [], 6 | "components": [], 7 | "data": [], 8 | "groups": [], 9 | "links": [], 10 | "subset_group_count": 0 11 | }, 12 | "Session": { 13 | "_type": "glue.core.session.Session" 14 | }, 15 | "__main__": { 16 | "_type": "glue.app.qt.application.GlueApplication", 17 | "data": "DataCollection", 18 | "plugins": [], 19 | "session": "Session", 20 | "tab_names": ["Tab 1"], 21 | "viewers": [[]] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestJupyterLab = require('@jupyterlab/testutils/lib/jest-config'); 2 | 3 | const esModules = ['@jupyterlab/'].join('|'); 4 | 5 | const baseConfig = jestJupyterLab(__dirname); 6 | 7 | module.exports = { 8 | ...baseConfig, 9 | automock: false, 10 | collectCoverageFrom: [ 11 | 'src/**/*.{ts,tsx}', 12 | '!src/**/*.d.ts', 13 | '!src/**/.ipynb_checkpoints/*' 14 | ], 15 | coverageReporters: ['lcov', 'text'], 16 | testRegex: 'src/.*/.*.spec.ts[x]?$', 17 | transformIgnorePatterns: [ 18 | ...baseConfig.transformIgnorePatterns, 19 | `/node_modules/(?!${esModules}).+` 20 | ] 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "composite": true, 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "incremental": true, 8 | "jsx": "react", 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmitOnError": true, 12 | "noImplicitAny": true, 13 | "noUnusedLocals": true, 14 | "preserveWatchOutput": true, 15 | "resolveJsonModule": true, 16 | "outDir": "lib", 17 | "rootDir": "src", 18 | "strict": true, 19 | "strictNullChecks": true, 20 | "target": "ES2018", 21 | "types": ["jest"] 22 | }, 23 | "include": ["src/**/*", "src/document/*.json"] 24 | } 25 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | on_rtd = os.environ.get("READTHEDOCS", None) == "True" 4 | 5 | html_theme = "pydata_sphinx_theme" 6 | html_theme_options = {"github_url": "https://github.com/QuantStack/glue-jupyterlab"} 7 | 8 | extensions = [ 9 | "sphinx.ext.intersphinx", 10 | "sphinx.ext.napoleon", 11 | ] 12 | 13 | source_suffix = ".rst" 14 | master_doc = "index" 15 | project = "glue-jupyterlab" 16 | copyright = "2023, The glue-jupyterlab Development Team" 17 | author = "The glue-jupyterlab Development Team" 18 | language = "en" 19 | 20 | exclude_patterns = [] 21 | highlight_language = "python" 22 | pygments_style = "sphinx" 23 | todo_include_todos = False 24 | htmlhelp_basename = "glue-jupyterlabdoc" 25 | -------------------------------------------------------------------------------- /ui-tests/playwright.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration for Playwright using default from @jupyterlab/galata 3 | */ 4 | const baseConfig = require('@jupyterlab/galata/lib/playwright-config'); 5 | 6 | module.exports = { 7 | ...baseConfig, 8 | webServer: { 9 | command: 'jlpm start', 10 | url: 'http://localhost:8888/lab', 11 | timeout: 120 * 1000, 12 | reuseExistingServer: !process.env.CI 13 | }, 14 | retries: 0, 15 | expect: { 16 | toMatchSnapshot: { 17 | // An acceptable ratio of pixels that are different to the total amount of pixels, between 0 and 1. 18 | maxDiffPixelRatio: 0.01 19 | } 20 | }, 21 | use: { 22 | viewport: { width: 1920, height: 1080 }, 23 | video: 'retain-on-failure' 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /glue_jupyterlab/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from jupyter_server.base.handlers import APIHandler 4 | from jupyter_server.utils import url_path_join 5 | import tornado 6 | 7 | from .glue_utils import get_advanced_links 8 | 9 | """The handler to get the advanced links.""" 10 | 11 | 12 | class AdvancedLinkHandler(APIHandler): 13 | @tornado.web.authenticated 14 | def get(self): 15 | self.finish(json.dumps({"data": get_advanced_links()})) 16 | 17 | 18 | def setup_handlers(web_app): 19 | host_pattern = ".*$" 20 | 21 | base_url = web_app.settings["base_url"] 22 | route_pattern = url_path_join(base_url, "glue-jupyterlab", "advanced-links") 23 | handlers = [(route_pattern, AdvancedLinkHandler)] 24 | web_app.add_handlers(host_pattern, handlers) 25 | -------------------------------------------------------------------------------- /glue_jupyterlab/__init__.py: -------------------------------------------------------------------------------- 1 | from .handlers import setup_handlers 2 | 3 | 4 | def _jupyter_labextension_paths(): 5 | return [{"src": "labextension", "dest": "glue-jupyterlab"}] 6 | 7 | 8 | def _jupyter_server_extension_points(): 9 | return [{"module": "glue_jupyterlab"}] 10 | 11 | 12 | def _load_jupyter_server_extension(server_app): 13 | """Registers the API handler to receive HTTP requests from the frontend extension. 14 | 15 | Parameters 16 | ---------- 17 | server_app: jupyterlab.labapp.LabApp 18 | JupyterLab application instance 19 | """ 20 | setup_handlers(server_app.web_app) 21 | name = "glue_jupyterlab" 22 | server_app.log.info(f"Registered {name} server extension") 23 | 24 | 25 | # For backward compatibility with notebook server - useful for Binder/JupyterHub 26 | load_jupyter_server_extension = _load_jupyter_server_extension 27 | -------------------------------------------------------------------------------- /.github/workflows/check-release.yml: -------------------------------------------------------------------------------- 1 | name: Check Release 2 | on: 3 | push: 4 | branches: ['main'] 5 | pull_request: 6 | branches: ['*'] 7 | 8 | jobs: 9 | check_release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | - name: Base Setup 15 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 16 | - name: Install Dependencies 17 | run: | 18 | pip install -e . 19 | - name: Check Release 20 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | 24 | - name: Upload Distributions 25 | uses: actions/upload-artifact@v3 26 | with: 27 | name: glue-jupyterlab-releaser-dist-${{ github.run_number }} 28 | path: .jupyter_releaser_checkout/dist 29 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The command IDs used by the control panel. 3 | */ 4 | export namespace CommandIDs { 5 | export const createNew = 'glue-control:new-session'; 6 | 7 | export const new1DHistogram = 'glue-control:new-1d-histogram-viewer'; 8 | 9 | export const new1DProfile = 'glue-control:new-1d-profile-viewer'; 10 | 11 | export const new2DImage = 'glue-control:new-2d-image-viewer'; 12 | 13 | export const new2DScatter = 'glue-control:new-2d-scatter-viewer'; 14 | 15 | export const new3DScatter = 'glue-control:new-3d-scatter-viewer'; 16 | 17 | export const newTable = 'glue-control:new-table-viewer'; 18 | 19 | export const openControlPanel = 'glue-control:open-control-panel'; 20 | 21 | export const closeControlPanel = 'glue-control:close-control-panel'; 22 | 23 | export const addViewerLayer = 'glue-control:add-viewer-layer'; 24 | } 25 | 26 | export interface INewViewerArgs { 27 | dataset?: string; 28 | position?: [number, number]; 29 | size?: [number, number]; 30 | } 31 | -------------------------------------------------------------------------------- /src/schemas/viewers/image.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Viewer::Image", 4 | "title": "IGlueImageViewer", 5 | "required": ["_type", "layers", "pos", "session", "size", "state"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue.viewers.image.qt.data_viewer.ImageViewer" 10 | }, 11 | "layers": { 12 | "type": "array", 13 | "items": { 14 | "type": "object" 15 | } 16 | }, 17 | "pos": { 18 | "type": "array", 19 | "items": { 20 | "type": "number" 21 | } 22 | }, 23 | "session": { 24 | "type": "string" 25 | }, 26 | "size": { 27 | "type": "array", 28 | "items": { 29 | "type": "number" 30 | } 31 | }, 32 | "state": { 33 | "type": "object", 34 | "additionalProperties": false, 35 | "properties": { 36 | "values": { 37 | "type": "object" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/viewers/1dprofile.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Viewer::Profile", 4 | "title": "IGlueProfileViewer", 5 | "required": ["_type", "layers", "pos", "session", "size", "state"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue.viewers.profile.state.ProfileLayerState" 10 | }, 11 | "layers": { 12 | "type": "array", 13 | "items": { 14 | "type": "object" 15 | } 16 | }, 17 | "pos": { 18 | "type": "array", 19 | "items": { 20 | "type": "number" 21 | } 22 | }, 23 | "session": { 24 | "type": "string" 25 | }, 26 | "size": { 27 | "type": "array", 28 | "items": { 29 | "type": "number" 30 | } 31 | }, 32 | "state": { 33 | "type": "object", 34 | "additionalProperties": false, 35 | "properties": { 36 | "values": { 37 | "type": "object" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/link.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Link", 4 | "title": "ILink", 5 | "required": [ 6 | "_type", 7 | "cids1", 8 | "cids2", 9 | "cids1_labels", 10 | "cids2_labels", 11 | "data1", 12 | "data2" 13 | ], 14 | "additionalProperties": true, 15 | "properties": { 16 | "_type": { 17 | "type": "string" 18 | }, 19 | "cids1": { 20 | "type": "array", 21 | "items": { 22 | "type": "string" 23 | } 24 | }, 25 | "cids2": { 26 | "type": "array", 27 | "items": { 28 | "type": "string" 29 | } 30 | }, 31 | "cids1_labels": { 32 | "type": "array", 33 | "items": { 34 | "type": "string" 35 | } 36 | }, 37 | "cids2_labels": { 38 | "type": "array", 39 | "items": { 40 | "type": "string" 41 | } 42 | }, 43 | "data1": { 44 | "type": "string" 45 | }, 46 | "data2": { 47 | "type": "string" 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/schemas/viewers/2dscatter.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Viewer::2DScatter", 4 | "title": "IGlue2DScatterViewer", 5 | "required": ["_type", "layers", "pos", "session", "size", "state"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue.viewers.scatter.qt.data_viewer.ScatterViewer" 10 | }, 11 | "layers": { 12 | "type": "array", 13 | "items": { 14 | "type": "object" 15 | } 16 | }, 17 | "pos": { 18 | "type": "array", 19 | "items": { 20 | "type": "number" 21 | } 22 | }, 23 | "session": { 24 | "type": "string" 25 | }, 26 | "size": { 27 | "type": "array", 28 | "items": { 29 | "type": "number" 30 | } 31 | }, 32 | "state": { 33 | "type": "object", 34 | "additionalProperties": false, 35 | "properties": { 36 | "values": { 37 | "type": "object" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/viewers/histogram.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Viewer::Histogram", 4 | "title": "IGlueHistogramViewer", 5 | "required": ["_type", "layers", "pos", "session", "size", "state"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue.viewers.histogram.qt.data_viewer.HistogramViewer" 10 | }, 11 | "layers": { 12 | "type": "array", 13 | "items": { 14 | "type": "object" 15 | } 16 | }, 17 | "pos": { 18 | "type": "array", 19 | "items": { 20 | "type": "number" 21 | } 22 | }, 23 | "session": { 24 | "type": "string" 25 | }, 26 | "size": { 27 | "type": "array", 28 | "items": { 29 | "type": "number" 30 | } 31 | }, 32 | "state": { 33 | "type": "object", 34 | "additionalProperties": false, 35 | "properties": { 36 | "values": { 37 | "type": "object" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/schemas/viewers/3dscatter.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Viewer::3DScatter", 4 | "title": "IGlue3DScatterViewer", 5 | "required": ["_type", "layers", "pos", "session", "size", "state"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue_vispy_viewers.scatter.scatter_viewer.VispyScatterViewer" 10 | }, 11 | "layers": { 12 | "type": "array", 13 | "items": { 14 | "type": "object" 15 | } 16 | }, 17 | "pos": { 18 | "type": "array", 19 | "items": { 20 | "type": "number" 21 | } 22 | }, 23 | "session": { 24 | "type": "string" 25 | }, 26 | "size": { 27 | "type": "array", 28 | "items": { 29 | "type": "number" 30 | } 31 | }, 32 | "state": { 33 | "type": "object", 34 | "additionalProperties": false, 35 | "properties": { 36 | "values": { 37 | "type": "object" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/viewPanel/glueDocumentWidget.ts: -------------------------------------------------------------------------------- 1 | import { DocumentWidget } from '@jupyterlab/docregistry'; 2 | import { IGlueSessionModel, IGlueSessionWidget } from '../types'; 3 | import { SessionWidget } from './sessionWidget'; 4 | 5 | export class GlueDocumentWidget 6 | extends DocumentWidget 7 | implements IGlueSessionWidget 8 | { 9 | constructor( 10 | options: DocumentWidget.IOptions 11 | ) { 12 | super(options); 13 | this._sessionWidget = options.content; 14 | } 15 | 16 | get sessionWidget(): SessionWidget { 17 | return this._sessionWidget; 18 | } 19 | /** 20 | * Dispose of the resources held by the widget. 21 | */ 22 | dispose(): void { 23 | //TODO Shutdown kernel does not work?? 24 | this.context.sessionContext.shutdown(); 25 | this.content.dispose(); 26 | super.dispose(); 27 | } 28 | 29 | onResize = (msg: any): void => { 30 | window.dispatchEvent(new Event('resize')); 31 | }; 32 | 33 | private _sessionWidget: SessionWidget; 34 | } 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/eslint-recommended', 5 | 'plugin:@typescript-eslint/recommended', 6 | 'plugin:prettier/recommended' 7 | ], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | project: 'tsconfig.json', 11 | sourceType: 'module' 12 | }, 13 | plugins: ['@typescript-eslint'], 14 | rules: { 15 | '@typescript-eslint/naming-convention': [ 16 | 'error', 17 | { 18 | selector: 'interface', 19 | format: ['PascalCase'], 20 | custom: { 21 | regex: '^I[A-Z]', 22 | match: true 23 | } 24 | } 25 | ], 26 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 27 | '@typescript-eslint/no-non-null-assertion': 'off', 28 | '@typescript-eslint/no-explicit-any': 'off', 29 | '@typescript-eslint/no-namespace': 'off', 30 | '@typescript-eslint/no-use-before-define': 'off', 31 | '@typescript-eslint/quotes': [ 32 | 'error', 33 | 'single', 34 | { avoidEscape: true, allowTemplateLiterals: false } 35 | ], 36 | curly: ['error', 'all'], 37 | eqeqeq: 'error', 38 | 'prefer-arrow-callback': 'error' 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/leftPanel/config/configWidget.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from '@lumino/signaling'; 2 | import { Panel } from '@lumino/widgets'; 3 | 4 | import { ConfigWidgetModel, IOutputChangedArg } from './configWidgetModel'; 5 | 6 | export class ConfigWidget extends Panel { 7 | constructor(options: { model: ConfigWidgetModel }) { 8 | super(); 9 | this.addClass('glue-LeftPanel-configWidget'); 10 | this._model = options.model; 11 | this.title.label = `${this._model.config} Options`; 12 | this._model.outputChanged.connect(this._outputChangedHandler, this); 13 | } 14 | 15 | private _outputChangedHandler( 16 | sender: ConfigWidgetModel, 17 | args: IOutputChangedArg 18 | ): void { 19 | const { oldOuput, newOutput } = args; 20 | if (oldOuput) { 21 | this.layout?.removeWidget(oldOuput); 22 | } 23 | if (newOutput) { 24 | this.addWidget(newOutput); 25 | } 26 | if (!oldOuput && !newOutput) { 27 | for (const iterator of this.children()) { 28 | this.layout?.removeWidget(iterator); 29 | } 30 | } 31 | } 32 | 33 | dispose(): void { 34 | this._model.outputChanged.disconnect(this._outputChangedHandler); 35 | Signal.clearData(this); 36 | super.dispose(); 37 | } 38 | 39 | private _model: ConfigWidgetModel; 40 | } 41 | -------------------------------------------------------------------------------- /src/schemas/data/dataset.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "description": "Dataset", 4 | "title": "IDataset", 5 | "required": ["_type", "components", "label", "primary_owner"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "_type": { 9 | "const": "glue.core.data.Data" 10 | }, 11 | "components": { 12 | "type": "array", 13 | "items": { 14 | "type": "string" 15 | } 16 | }, 17 | "coords": { 18 | "type": "string" 19 | }, 20 | "label": { 21 | "type": "string" 22 | }, 23 | "meta": { 24 | "type": "object", 25 | "$ref": "#/definitions/meta" 26 | }, 27 | "primary_owner": { 28 | "type": "array", 29 | "items": { 30 | "type": "string" 31 | } 32 | }, 33 | "style": { 34 | "type": "object", 35 | "$ref": "#/definitions/style" 36 | } 37 | }, 38 | "definitions": { 39 | "meta": { 40 | "type": "object", 41 | "properties": { 42 | "contents": { 43 | "type": "object" 44 | } 45 | } 46 | }, 47 | "style": { 48 | "type": "object", 49 | "required": ["_type"], 50 | "properties": { 51 | "_type": { 52 | "const": "glue.core.visual.VisualAttributes" 53 | } 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Glue-jupyterlab 3 | =============== 4 | 5 | .. image:: glue-jupyterlab.gif 6 | :alt: glue-jupyterlab extension 7 | 8 | glue-jupyterlab allows [Glue](https://glueviz.org/) users to complete the tasks they used Glue Qt for, while giving them access to all JupyterLab features in the same application, "for free". The user experience has been researched in order for users to find the same mechanisms they have in Glue Qt, while removing the friction caused by integrating two user experiences that were initially not designed to be merged. This will bring value to new Glue users from all domains, who have never used the desktop application and who discover Glupyter. It will feel like a JupyterLab experience, with very few new functionalities to learn. 9 | glue-jupyterlab provides intuitive graphical interfaces with data drag-and-drop functionalities, enabling researchers and data analysts to import, visualise, connect and explore multidimensional data. 10 | It also facilitates the creation of appealing plots and charts within the interface, empowering users to gain valuable insights, with capabilities similar as the Glue Qt application. 11 | 12 | Installing glue-jupyterlab 13 | ========================== 14 | 15 | glue-jupyterlab can be installed with ``mamba`` or ``conda`` 16 | 17 | .. code-block:: bash 18 | 19 | mamba install -c conda-forge glue-jupyterlab 20 | 21 | or with ``pip`` 22 | 23 | .. code-block:: bash 24 | 25 | pip install glue-jupyterlab 26 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | 3 | import glueIconStr from '../style/icons/glue-icon.svg'; 4 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 5 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 6 | import { Signal } from '@lumino/signaling'; 7 | import { KernelMessage } from '@jupyterlab/services'; 8 | 9 | export const glueIcon = new LabIcon({ 10 | name: 'gluelab:glue-icon', 11 | svgstr: glueIconStr 12 | }); 13 | 14 | export function mockNotebook( 15 | rendermime: IRenderMimeRegistry, 16 | context?: DocumentRegistry.IContext 17 | ): any { 18 | const signal = new Signal({}); 19 | const panel = { 20 | context, 21 | content: { 22 | rendermime, 23 | widgets: [], 24 | activeCellChanged: signal, 25 | disposed: signal 26 | }, 27 | sessionContext: { 28 | session: null, 29 | sessionChanged: signal, 30 | kernelChanged: signal 31 | }, 32 | disposed: signal, 33 | node: document.createElement('div') 34 | }; 35 | return panel; 36 | } 37 | 38 | /** 39 | * Log the kernel error message to the JS console. 40 | * 41 | * @param {KernelMessage.IExecuteReplyMsg} msg 42 | */ 43 | export const logKernelError = (msg: KernelMessage.IExecuteReplyMsg): void => { 44 | const { content } = msg; 45 | if (content.status === 'error') { 46 | const { ename, evalue, traceback } = content; 47 | console.error('[Kernel Execution Error]', { ename, evalue, traceback }); 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ perform a development install of glue-jupyterlab 3 | On Binder, this will run _after_ the environment has been fully created from 4 | the environment.yml in this directory. 5 | This script should also run locally on Linux/MacOS/Windows: 6 | python3 binder/postBuild 7 | """ 8 | import subprocess 9 | import sys 10 | from pathlib import Path 11 | import shutil 12 | 13 | ROOT = Path.cwd() 14 | 15 | shutil.copy(ROOT / "binder" / "jupyter_config.json", ROOT) 16 | 17 | def _(*args, **kwargs): 18 | """Run a command, echoing the args 19 | fails hard if something goes wrong 20 | """ 21 | print("\n\t", " ".join(args), "\n", flush=True) 22 | return_code = subprocess.call(args, **kwargs) 23 | if return_code != 0: 24 | print("\nERROR", return_code, " ".join(args), flush=True) 25 | sys.exit(return_code) 26 | 27 | 28 | # remove incompatible binder baseline packages 29 | _("mamba", "uninstall", "jupyter-resource-usage") 30 | 31 | # verify the environment is self-consistent before even starting 32 | _(sys.executable, "-m", "pip", "check") 33 | 34 | # install the labextension 35 | _(sys.executable, "-m", "pip", "install", ".") 36 | 37 | # list the extensions 38 | _("jupyter", "server", "extension", "list") 39 | 40 | # initially list installed extensions to determine if there are any surprises 41 | _("jupyter", "labextension", "list") 42 | 43 | 44 | print("JupyterLab with glue-jupyterlab is ready to run with:\n") 45 | print("\tjupyter lab\n") 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## 0.3.0 6 | 7 | ([Full Changelog](https://github.com/QuantStack/glue-jupyterlab/compare/v0.2.0...363164d5a6b6368b75df7b82e93a889a5e0f7ee3)) 8 | 9 | ### Merged PRs 10 | 11 | - Add ability to remove tab [#117](https://github.com/QuantStack/glue-jupyterlab/pull/117) ([@trungleduc](https://github.com/trungleduc)) 12 | - Add ability to drag and drop data into existing viewers for new layer creation [#115](https://github.com/QuantStack/glue-jupyterlab/pull/115) ([@brichet](https://github.com/brichet)) 13 | - Add documentation [#114](https://github.com/QuantStack/glue-jupyterlab/pull/114) ([@martinRenou](https://github.com/martinRenou)) 14 | 15 | ### Contributors to this release 16 | 17 | ([GitHub contributors page for this release](https://github.com/QuantStack/glue-jupyterlab/graphs/contributors?from=2023-07-05&to=2023-07-10&type=c)) 18 | 19 | [@brichet](https://github.com/search?q=repo%3AQuantStack%2Fglue-jupyterlab+involves%3Abrichet+updated%3A2023-07-05..2023-07-10&type=Issues) | [@github-actions](https://github.com/search?q=repo%3AQuantStack%2Fglue-jupyterlab+involves%3Agithub-actions+updated%3A2023-07-05..2023-07-10&type=Issues) | [@martinRenou](https://github.com/search?q=repo%3AQuantStack%2Fglue-jupyterlab+involves%3AmartinRenou+updated%3A2023-07-05..2023-07-10&type=Issues) | [@trungleduc](https://github.com/search?q=repo%3AQuantStack%2Fglue-jupyterlab+involves%3Atrungleduc+updated%3A2023-07-05..2023-07-10&type=Issues) 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/linkPanel/types.ts: -------------------------------------------------------------------------------- 1 | import { ISignal } from '@lumino/signaling'; 2 | import { IGlueSessionSharedModel } from '../types'; 3 | import { ILink } from '../_interface/glue.schema'; 4 | 5 | export { ILink } from '../_interface/glue.schema'; 6 | 7 | export const ComponentLinkType = 'glue.core.component_link.ComponentLink'; 8 | export const IdentityLinkFunction = 'glue.core.link_helpers.identity'; 9 | 10 | export const IdentityLinkUsing = { 11 | _type: 'types.FunctionType', 12 | function: IdentityLinkFunction 13 | }; 14 | 15 | /** 16 | * The link editor model. 17 | */ 18 | export interface ILinkEditorModel { 19 | currentDatasets: [string, string]; 20 | setCurrentDataset(index: number, value: string): void; 21 | readonly currentDatasetsChanged: ISignal; 22 | readonly identityLinks: Map; 23 | readonly advancedLinks: Map; 24 | readonly linksChanged: ISignal; 25 | readonly sharedModel: IGlueSessionSharedModel | undefined; 26 | readonly advLinkCategories: IAdvLinkCategories; 27 | readonly advLinksPromise: Promise; 28 | } 29 | 30 | /** 31 | * The necessary information to create an advanced link. 32 | */ 33 | export interface IAdvLinkDescription { 34 | function: string; 35 | _type: string; 36 | display: string; 37 | description: string; 38 | labels1: string[]; 39 | labels2: string[]; 40 | } 41 | 42 | /** 43 | * The available advanced links by category. 44 | */ 45 | export interface IAdvLinkCategories { 46 | [category: string]: IAdvLinkDescription[]; 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2023, QuantStack 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /.github/workflows/update-integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Update Playwright Snapshots 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | update-snapshots: 13 | if: ${{ github.event.issue.pull_request && contains(github.event.comment.body, 'please update playwright snapshots') }} 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | token: ${{ secrets.GITHUB_TOKEN }} 21 | 22 | - name: Configure git to use https 23 | run: git config --global hub.protocol https 24 | 25 | - name: Checkout the branch from the PR that triggered the job 26 | run: hub pr checkout ${{ github.event.issue.number }} 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | - name: Base Setup 31 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 32 | 33 | - name: Install dependencies 34 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 35 | 36 | - name: Install extension 37 | run: | 38 | set -eux 39 | jlpm 40 | python -m pip install . 41 | 42 | - name: Install UI test dependencies 43 | working-directory: ui-tests 44 | run: jlpm install 45 | 46 | - uses: jupyterlab/maintainer-tools/.github/actions/update-snapshots@v1 47 | with: 48 | github_token: ${{ secrets.GITHUB_TOKEN }} 49 | # Playwright knows how to start JupyterLab server 50 | start_server_script: 'null' 51 | test_folder: ui-tests 52 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | # pre-commit.ci will open PRs updating our hooks once a month 3 | autoupdate_schedule: monthly 4 | # skip any check that needs internet access 5 | autofix_prs: true 6 | 7 | repos: 8 | # Autoformat and linting, misc. details 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: forbid-new-submodules 13 | - id: end-of-file-fixer 14 | exclude: galata/.*-snapshots 15 | - id: check-case-conflict 16 | - id: requirements-txt-fixer 17 | - id: check-added-large-files 18 | args: ['--maxkb=5000'] 19 | - id: check-case-conflict 20 | - id: check-toml 21 | - id: check-yaml 22 | - id: debug-statements 23 | - id: check-builtin-literals 24 | - id: trailing-whitespace 25 | exclude: .bumpversion.cfg 26 | 27 | # Autoformat: Python code 28 | - repo: https://github.com/psf/black 29 | rev: 25.1.0 30 | hooks: 31 | - id: black 32 | 33 | - repo: https://github.com/astral-sh/ruff-pre-commit 34 | rev: v0.12.2 35 | hooks: 36 | - id: ruff 37 | args: ['--fix'] 38 | 39 | - repo: https://github.com/pre-commit/mirrors-eslint 40 | rev: v9.30.1 41 | hooks: 42 | - id: eslint 43 | files: \.tsx?$ 44 | types: [file] 45 | additional_dependencies: 46 | - 'eslint@8.36.0' 47 | - '@typescript-eslint/eslint-plugin' 48 | - '@typescript-eslint/parser' 49 | - 'eslint-plugin-prettier' 50 | - 'eslint-config-prettier' 51 | 52 | - repo: https://github.com/pre-commit/mirrors-prettier 53 | rev: v4.0.0-alpha.8 54 | hooks: 55 | - id: prettier 56 | -------------------------------------------------------------------------------- /glue_jupyterlab/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from pathlib import Path 4 | from jupyter_ydoc import ydocs 5 | from glue_jupyterlab.glue_session import SharedGlueSession 6 | from glue_jupyterlab.glue_ydoc import COMPONENT_LINK_TYPE, IDENTITY_LINK_FUNCTION 7 | 8 | 9 | @pytest.fixture 10 | def session_path(): 11 | return str(Path(__file__).parents[2] / "examples" / "session.glu") 12 | 13 | 14 | @pytest.fixture 15 | def session_links_path(): 16 | return str(Path(__file__).parents[2] / "examples" / "session3.glu") 17 | 18 | 19 | @pytest.fixture(scope="function") 20 | def yglue_doc(session_path): 21 | with open(session_path, "r") as fobj: 22 | data = fobj.read() 23 | 24 | os.chdir(Path(__file__).parents[2] / "examples") 25 | 26 | glue = ydocs["glu"]() 27 | glue.set(data) 28 | 29 | return glue 30 | 31 | 32 | @pytest.fixture(scope="function") 33 | def yglue_session(session_path, yglue_doc): 34 | glue_session = SharedGlueSession(session_path) 35 | glue_session._document = yglue_doc 36 | 37 | return glue_session 38 | 39 | 40 | @pytest.fixture(scope="function") 41 | def yglue_doc_links(session_links_path): 42 | with open(session_links_path, "r") as fobj: 43 | data = fobj.read() 44 | 45 | os.chdir(Path(__file__).parents[2] / "examples") 46 | 47 | glue = ydocs["glu"]() 48 | glue.set(data) 49 | 50 | return glue 51 | 52 | 53 | @pytest.fixture 54 | def identity_link(): 55 | return { 56 | "_type": COMPONENT_LINK_TYPE, 57 | "data1": "w5", 58 | "data2": "w5_psc", 59 | "cids1": ["Declination"], 60 | "cids2": ["DEJ2000"], 61 | "cids1_labels": ["Declination"], 62 | "cids2_labels": ["DEJ2000"], 63 | "using": {"function": IDENTITY_LINK_FUNCTION}, 64 | } 65 | -------------------------------------------------------------------------------- /src/yWidget.ts: -------------------------------------------------------------------------------- 1 | import { 2 | JupyterFrontEnd, 3 | JupyterFrontEndPlugin 4 | } from '@jupyterlab/application'; 5 | import { 6 | JupyterYModel, 7 | IJupyterYModel, 8 | IJupyterYWidgetManager, 9 | IJupyterYWidget 10 | } from 'yjs-widgets'; 11 | import * as Y from 'yjs'; 12 | import { IGlueSessionTracker } from './token'; 13 | 14 | export interface ICommMetadata { 15 | create_ydoc: boolean; 16 | path: string; 17 | ymodel_name: string; 18 | } 19 | class YGlueSessionWidget implements IJupyterYWidget { 20 | constructor(yModel: IJupyterYModel, node: HTMLElement, ydocFactory: any) { 21 | this.yModel = yModel; 22 | this.node = node; 23 | yModel.sharedModel.ydoc = ydocFactory(yModel.sharedModel.commMetadata.path); 24 | } 25 | 26 | yModel: IJupyterYModel; 27 | node: HTMLElement; 28 | } 29 | 30 | export const yGlueSessionWidgetPlugin: JupyterFrontEndPlugin = { 31 | id: 'glue-jupyterlab:yjswidget-plugin', 32 | autoStart: true, 33 | requires: [IJupyterYWidgetManager, IGlueSessionTracker], 34 | activate: ( 35 | app: JupyterFrontEnd, 36 | yWidgetManager: IJupyterYWidgetManager, 37 | tracker: IGlueSessionTracker 38 | ): void => { 39 | class YGlueSessionModel extends JupyterYModel { 40 | ydocFactory(commMetadata: ICommMetadata): Y.Doc { 41 | const path = commMetadata.path; 42 | const sessionWidget = tracker.find(obj => { 43 | const filePath = obj.context.path.split(':')[1]; 44 | return filePath === path; 45 | }); 46 | 47 | let requestedYDoc: Y.Doc; 48 | if (sessionWidget) { 49 | requestedYDoc = sessionWidget.context.model.sharedModel.ydoc; 50 | } else { 51 | requestedYDoc = new Y.Doc(); 52 | } 53 | return requestedYDoc; 54 | } 55 | } 56 | 57 | yWidgetManager.registerWidget( 58 | 'GlueSession', 59 | YGlueSessionModel, 60 | YGlueSessionWidget 61 | ); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/schemas/glue.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "title": "IGlueSession", 4 | "required": ["contents", "tabs"], 5 | "additionalProperties": false, 6 | "properties": { 7 | "contents": { 8 | "type": "object" 9 | }, 10 | "attributes": { 11 | "$ref": "#/definitions/attributes" 12 | }, 13 | "dataset": { 14 | "$ref": "#/definitions/dataset" 15 | }, 16 | "links": { 17 | "$ref": "#/definitions/links" 18 | }, 19 | "tabs": { 20 | "$ref": "#/definitions/tabs" 21 | } 22 | }, 23 | "definitions": { 24 | "attributes": { 25 | "title": "IGlueSessionAttributes", 26 | "type": "object", 27 | "patternProperties": { 28 | ".*": { 29 | "$ref": "./data/attributes.schema.json" 30 | } 31 | } 32 | }, 33 | "dataset": { 34 | "title": "IGlueSessionDataset", 35 | "type": "object", 36 | "patternProperties": { 37 | ".*": { 38 | "$ref": "./data/dataset.schema.json" 39 | } 40 | } 41 | }, 42 | "links": { 43 | "title": "IGlueSessionLinks", 44 | "type": "object", 45 | "patternProperties": { 46 | ".*": { 47 | "$ref": "./link.schema.json" 48 | } 49 | } 50 | }, 51 | "tabs": { 52 | "title": "IGlueSessionTabs", 53 | "type": "object", 54 | "patternProperties": { 55 | ".*": { 56 | "type": "object", 57 | "items": { 58 | "anyOf": [ 59 | { 60 | "$ref": "./viewers/3dscatter.schema.json" 61 | }, 62 | { 63 | "$ref": "./viewers/2dscatter.schema.json" 64 | }, 65 | { 66 | "$ref": "./viewers/histogram.schema.json" 67 | }, 68 | { 69 | "$ref": "./viewers/image.schema.json" 70 | } 71 | ] 72 | } 73 | } 74 | }, 75 | "additionalProperties": false 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/linkPanel/linkEditorWidget.ts: -------------------------------------------------------------------------------- 1 | import { Panel, Widget } from '@lumino/widgets'; 2 | import { IGlueSessionSharedModel } from '../types'; 3 | import { ILinkEditorModel } from './types'; 4 | 5 | export class LinkEditorWidget extends Panel { 6 | constructor(options: LinkEditorWidget.IOptions) { 7 | super(); 8 | this._linkEditorModel = options.linkEditorModel; 9 | this._sharedModel = options.sharedModel; 10 | this.addClass('glue-LinkEditor-widget'); 11 | this._sharedModel.changed.connect(this.onSharedModelChanged, this); 12 | } 13 | 14 | /** 15 | * Getter and setter of the header. 16 | */ 17 | get header(): Widget | undefined { 18 | return this._header; 19 | } 20 | protected set header(header: Widget | undefined) { 21 | if (this._header) { 22 | this._header.dispose(); 23 | } 24 | if (!header) { 25 | return; 26 | } 27 | 28 | header.addClass('glue-LinkEditor-header'); 29 | 30 | this._header = header; 31 | this.insertWidget(0, this._header); 32 | } 33 | 34 | /** 35 | * Getter and setter of the content. 36 | */ 37 | get content(): Widget | undefined { 38 | return this._content; 39 | } 40 | protected set content(content: Widget | undefined) { 41 | if (this._content) { 42 | this._content.dispose(); 43 | } 44 | if (!content) { 45 | return; 46 | } 47 | 48 | content.addClass('glue-LinkEditor-content'); 49 | this._content = content; 50 | this.addWidget(this._content); 51 | } 52 | 53 | onSharedModelChanged(): void { 54 | /** no-op */ 55 | } 56 | 57 | protected _sharedModel: IGlueSessionSharedModel; 58 | protected _linkEditorModel: ILinkEditorModel; 59 | private _header: Widget | undefined = undefined; 60 | private _content: Widget | undefined = undefined; 61 | } 62 | 63 | export namespace LinkEditorWidget { 64 | export interface IOptions { 65 | linkEditorModel: ILinkEditorModel; 66 | sharedModel: IGlueSessionSharedModel; 67 | } 68 | 69 | export interface IMainContentItems { 70 | name: string; 71 | widget: Widget; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/document/modelFactory.ts: -------------------------------------------------------------------------------- 1 | import { IGlueSessionSharedModel } from './../types'; 2 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 3 | import { Contents } from '@jupyterlab/services'; 4 | 5 | import { GlueSessionModel } from './docModel'; 6 | 7 | export class GlueSessionModelFactory 8 | implements DocumentRegistry.IModelFactory 9 | { 10 | collaborative = true; 11 | /** 12 | * The name of the model. 13 | * 14 | * @returns The name 15 | */ 16 | get name(): string { 17 | return 'gluelab-session-model'; 18 | } 19 | 20 | /** 21 | * The content type of the file. 22 | * 23 | * @returns The content type 24 | */ 25 | get contentType(): Contents.ContentType { 26 | return 'glu'; 27 | } 28 | 29 | /** 30 | * The format of the file. 31 | * 32 | * @returns the file format 33 | */ 34 | get fileFormat(): Contents.FileFormat { 35 | return 'text'; 36 | } 37 | 38 | /** 39 | * Get whether the model factory has been disposed. 40 | * 41 | * @returns disposed status 42 | */ 43 | 44 | get isDisposed(): boolean { 45 | return this._disposed; 46 | } 47 | 48 | /** 49 | * Dispose the model factory. 50 | */ 51 | dispose(): void { 52 | this._disposed = true; 53 | } 54 | 55 | /** 56 | * Get the preferred language given the path on the file. 57 | * 58 | * @param path path of the file represented by this document model 59 | * @returns The preferred language 60 | */ 61 | preferredLanguage(path: string): string { 62 | return ''; 63 | } 64 | 65 | /** 66 | * Create a new instance of GlueSessionModel. 67 | * 68 | * @param languagePreference Language 69 | * @param modelDB Model database 70 | * @returns The model 71 | */ 72 | createNew(options: { 73 | sharedModel: IGlueSessionSharedModel; 74 | }): GlueSessionModel { 75 | const model = new GlueSessionModel(options); 76 | return model; 77 | } 78 | 79 | private _disposed = false; 80 | } 81 | 82 | export namespace GlueSessionModelFactory { 83 | export interface IOptions { 84 | o?: any; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/document/widgetFactory.ts: -------------------------------------------------------------------------------- 1 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 2 | import { SessionWidget } from '../viewPanel/sessionWidget'; 3 | import { ABCWidgetFactory, DocumentRegistry } from '@jupyterlab/docregistry'; 4 | 5 | import { GlueSessionModel } from './docModel'; 6 | 7 | import { GlueDocumentWidget } from '../viewPanel/glueDocumentWidget'; 8 | import { INotebookTracker } from '@jupyterlab/notebook'; 9 | import { CommandRegistry } from '@lumino/commands'; 10 | import { IJupyterYWidgetManager } from 'yjs-widgets'; 11 | 12 | export class GlueCanvasWidgetFactory extends ABCWidgetFactory< 13 | GlueDocumentWidget, 14 | GlueSessionModel 15 | > { 16 | constructor(options: GlueCanvasWidgetFactory.IOptions) { 17 | const { rendermime, notebookTracker, commands, yWidgetManager, ...rest } = 18 | options; 19 | super(rest); 20 | this._rendermime = rendermime; 21 | this._notebookTracker = notebookTracker; 22 | this._commands = commands; 23 | this._yWidgetManager = yWidgetManager; 24 | } 25 | 26 | /** 27 | * Create a new widget given a context. 28 | * 29 | * @param context Contains the information of the file 30 | * @returns The widget 31 | */ 32 | protected createNewWidget( 33 | context: DocumentRegistry.IContext 34 | ): GlueDocumentWidget { 35 | const content = new SessionWidget({ 36 | model: context.model.sharedModel, 37 | rendermime: this._rendermime, 38 | notebookTracker: this._notebookTracker, 39 | context, 40 | commands: this._commands, 41 | yWidgetManager: this._yWidgetManager 42 | }); 43 | return new GlueDocumentWidget({ context, content }); 44 | } 45 | 46 | private _rendermime: IRenderMimeRegistry; 47 | private _notebookTracker: INotebookTracker; 48 | private _commands: CommandRegistry; 49 | private _yWidgetManager: IJupyterYWidgetManager; 50 | } 51 | 52 | export namespace GlueCanvasWidgetFactory { 53 | export interface IOptions extends DocumentRegistry.IWidgetFactoryOptions { 54 | rendermime: IRenderMimeRegistry; 55 | notebookTracker: INotebookTracker; 56 | commands: CommandRegistry; 57 | yWidgetManager: IJupyterYWidgetManager; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a new release of glue-jupyterlab 2 | 3 | The extension can be published to `PyPI` and `npm` manually or using the [Jupyter Releaser](https://github.com/jupyter-server/jupyter_releaser). 4 | 5 | ## Manual release 6 | 7 | ### Python package 8 | 9 | This extension can be distributed as Python 10 | packages. All of the Python 11 | packaging instructions in the `pyproject.toml` file to wrap your extension in a 12 | Python package. Before generating a package, we first need to install `build`. 13 | 14 | ```bash 15 | pip install build twine hatch 16 | ``` 17 | 18 | Bump the version using `hatch`. By default this will create a tag. 19 | See the docs on [hatch-nodejs-version](https://github.com/agoose77/hatch-nodejs-version#semver) for details. 20 | 21 | ```bash 22 | hatch version 23 | ``` 24 | 25 | To create a Python source package (`.tar.gz`) and the binary package (`.whl`) in the `dist/` directory, do: 26 | 27 | ```bash 28 | python -m build 29 | ``` 30 | 31 | > `python setup.py sdist bdist_wheel` is deprecated and will not work for this package. 32 | 33 | Then to upload the package to PyPI, do: 34 | 35 | ```bash 36 | twine upload dist/* 37 | ``` 38 | 39 | ### NPM package 40 | 41 | To publish the frontend part of the extension as a NPM package, do: 42 | 43 | ```bash 44 | npm login 45 | npm publish --access public 46 | ``` 47 | 48 | ## Automated releases with the Jupyter Releaser 49 | 50 | The extension repository should already be compatible with the Jupyter Releaser. 51 | 52 | Check out the [workflow documentation](https://github.com/jupyter-server/jupyter_releaser#typical-workflow) for more information. 53 | 54 | Here is a summary of the steps to cut a new release: 55 | 56 | - Fork the [`jupyter-releaser` repo](https://github.com/jupyter-server/jupyter_releaser) 57 | - Add `ADMIN_GITHUB_TOKEN`, `PYPI_TOKEN` and `NPM_TOKEN` to the Github Secrets in the fork 58 | - Go to the Actions panel 59 | - Run the "Draft Changelog" workflow 60 | - Merge the Changelog PR 61 | - Run the "Draft Release" workflow 62 | - Run the "Publish Release" workflow 63 | 64 | ## Publishing to `conda-forge` 65 | 66 | If the package is not on conda forge yet, check the documentation to learn how to add it: https://conda-forge.org/docs/maintainer/adding_pkgs.html 67 | 68 | Otherwise a bot should pick up the new version publish to PyPI, and open a new PR on the feedstock repository automatically. 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.log 5 | .eslintcache 6 | .stylelintcache 7 | *.egg-info/ 8 | .ipynb_checkpoints 9 | *.tsbuildinfo 10 | glue_jupyterlab/labextension 11 | # Version file is handled by hatchling 12 | glue_jupyterlab/_version.py 13 | 14 | # Integration tests 15 | ui-tests/test-results/ 16 | ui-tests/playwright-report/ 17 | 18 | # Created by https://www.gitignore.io/api/python 19 | # Edit at https://www.gitignore.io/?templates=python 20 | 21 | ### Python ### 22 | # Byte-compiled / optimized / DLL files 23 | __pycache__/ 24 | *.py[cod] 25 | *$py.class 26 | 27 | # C extensions 28 | *.so 29 | 30 | # Distribution / packaging 31 | .Python 32 | build/ 33 | develop-eggs/ 34 | dist/ 35 | downloads/ 36 | eggs/ 37 | .eggs/ 38 | lib/ 39 | lib64/ 40 | parts/ 41 | sdist/ 42 | var/ 43 | wheels/ 44 | pip-wheel-metadata/ 45 | share/python-wheels/ 46 | .installed.cfg 47 | *.egg 48 | MANIFEST 49 | 50 | # PyInstaller 51 | # Usually these files are written by a python script from a template 52 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 53 | *.manifest 54 | *.spec 55 | 56 | # Installer logs 57 | pip-log.txt 58 | pip-delete-this-directory.txt 59 | 60 | # Unit test / coverage reports 61 | htmlcov/ 62 | .tox/ 63 | .nox/ 64 | .coverage 65 | .coverage.* 66 | .cache 67 | nosetests.xml 68 | coverage/ 69 | coverage.xml 70 | *.cover 71 | .hypothesis/ 72 | .pytest_cache/ 73 | 74 | # Translations 75 | *.mo 76 | *.pot 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # celery beat schedule file 91 | celerybeat-schedule 92 | 93 | # SageMath parsed files 94 | *.sage.py 95 | 96 | # Spyder project settings 97 | .spyderproject 98 | .spyproject 99 | 100 | # Rope project settings 101 | .ropeproject 102 | 103 | # Mr Developer 104 | .mr.developer.cfg 105 | .project 106 | .pydevproject 107 | 108 | # mkdocs documentation 109 | /site 110 | 111 | # mypy 112 | .mypy_cache/ 113 | .dmypy.json 114 | dmypy.json 115 | 116 | # Pyre type checker 117 | .pyre/ 118 | 119 | # End of https://www.gitignore.io/api/python 120 | 121 | # OSX files 122 | .DS_Store 123 | 124 | .yarn/ 125 | *.db 126 | 127 | _interface 128 | examples/*.vot 129 | examples/*.fits 130 | examples/*.csv 131 | -------------------------------------------------------------------------------- /src/linkPanel/linkEditor.ts: -------------------------------------------------------------------------------- 1 | import { SplitPanel, Widget } from '@lumino/widgets'; 2 | 3 | import { IGlueSessionSharedModel } from '../types'; 4 | import { LinkEditorModel } from './model'; 5 | import { Linking } from './widgets/linking'; 6 | import { Summary } from './widgets/summary'; 7 | import { LinkEditorWidget } from './linkEditorWidget'; 8 | import { Message } from '@lumino/messaging'; 9 | 10 | /** 11 | * The link editor widget. 12 | */ 13 | export class LinkEditor extends SplitPanel { 14 | constructor(options: LinkWidget.IOptions) { 15 | super({}); 16 | this._sharedModel = options.sharedModel; 17 | this.addClass('glue-linkEditor'); 18 | this.title.label = 'Link Data'; 19 | this.title.className = 'glue-LinkEditor-tab'; 20 | 21 | const model = new LinkEditorModel({ sharedModel: this._sharedModel }); 22 | const linking = new Linking({ 23 | linkEditorModel: model, 24 | sharedModel: this._sharedModel 25 | }); 26 | const summary = new Summary({ 27 | linkEditorModel: model, 28 | sharedModel: this._sharedModel 29 | }); 30 | 31 | this.addWidget(linking); 32 | this.addWidget(summary); 33 | 34 | SplitPanel.setStretch(linking, 3); 35 | SplitPanel.setStretch(summary, 2); 36 | } 37 | 38 | get sharedModel(): IGlueSessionSharedModel { 39 | return this._sharedModel; 40 | } 41 | 42 | /** 43 | * Set the header height to the maximal, in comparison to the others headers. 44 | */ 45 | protected onAfterShow(msg: Message): void { 46 | this._computeHeaderHeight(); 47 | } 48 | 49 | protected onResize(msg: Widget.ResizeMessage): void { 50 | this._computeHeaderHeight(); 51 | } 52 | 53 | /** 54 | * Compute and homogeneize the most height header. 55 | */ 56 | private _computeHeaderHeight() { 57 | let maxHeight = 0; 58 | const headers = this.widgets.map( 59 | widget => (widget as LinkEditorWidget).header 60 | ); 61 | maxHeight = Math.max( 62 | ...headers.map( 63 | header => (header?.node.firstChild as HTMLElement)?.offsetHeight || 0 64 | ) 65 | ); 66 | headers.forEach(header => { 67 | if (header) { 68 | header.node.style.minHeight = `${maxHeight}px`; 69 | } 70 | }); 71 | } 72 | 73 | private _sharedModel: IGlueSessionSharedModel; 74 | } 75 | 76 | namespace LinkWidget { 77 | export interface IOptions { 78 | sharedModel: IGlueSessionSharedModel; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/leftPanel/widget.ts: -------------------------------------------------------------------------------- 1 | import { SidePanel } from '@jupyterlab/ui-components'; 2 | import { CommandRegistry } from '@lumino/commands'; 3 | import { Message } from '@lumino/messaging'; 4 | import { BoxPanel } from '@lumino/widgets'; 5 | import { IDocumentManager } from '@jupyterlab/docmanager'; 6 | 7 | import { HTabPanel } from '../common/tabPanel'; 8 | import { IGlueSessionTracker } from '../token'; 9 | import { IControlPanelModel } from '../types'; 10 | import { ConfigPanel } from './config/configPanel'; 11 | import { DataPanel } from './data/dataPanel'; 12 | import { ControlPanelHeader } from './header'; 13 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 14 | import { Signal } from '@lumino/signaling'; 15 | 16 | export class ControlPanelWidget extends SidePanel { 17 | constructor(options: LeftPanelWidget.IOptions) { 18 | const content = new BoxPanel(); 19 | super({ content }); 20 | this.addClass('glue-sidepanel-widget'); 21 | const { model, rendermime, commands, manager } = options; 22 | this._model = model; 23 | const header = new ControlPanelHeader(); 24 | this.header.addWidget(header); 25 | 26 | this._tabPanel = new HTabPanel({ 27 | tabBarPosition: 'top', 28 | tabBarClassList: ['lm-DockPanel-tabBar', 'glue-Panel-tabBar'] 29 | }); 30 | const data = new DataPanel({ 31 | model: this._model, 32 | commands, 33 | manager 34 | }); 35 | const canvas = new ConfigPanel({ model, rendermime }); 36 | 37 | this._tabPanel.addTab(data, 0); 38 | this._tabPanel.addTab(canvas, 1); 39 | this._tabPanel.activateTab(0); 40 | this.addWidget(this._tabPanel); 41 | BoxPanel.setStretch(this._tabPanel, 1); 42 | 43 | this._model.glueSessionChanged.connect(async (_, changed) => { 44 | if (changed) { 45 | header.title.label = changed.context.localPath; 46 | } else { 47 | header.title.label = '-'; 48 | } 49 | }); 50 | this._model.displayConfigRequested.connect(() => 51 | this._tabPanel.activateTab(1) 52 | ); 53 | } 54 | 55 | protected onActivateRequest(msg: Message): void { 56 | this._tabPanel.activate(); 57 | this._tabPanel.show(); 58 | } 59 | dispose(): void { 60 | Signal.clearData(this); 61 | super.dispose(); 62 | } 63 | 64 | private _model: IControlPanelModel; 65 | private _tabPanel: HTabPanel; 66 | } 67 | 68 | export namespace LeftPanelWidget { 69 | export interface IOptions { 70 | model: IControlPanelModel; 71 | tracker: IGlueSessionTracker; 72 | commands: CommandRegistry; 73 | rendermime: IRenderMimeRegistry; 74 | manager: IDocumentManager; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/leftPanel/data/dataPanel.ts: -------------------------------------------------------------------------------- 1 | import { CommandRegistry } from '@lumino/commands'; 2 | 3 | import { SidePanel, ToolbarButton } from '@jupyterlab/ui-components'; 4 | 5 | import { FileDialog } from '@jupyterlab/filebrowser'; 6 | import { IDocumentManager } from '@jupyterlab/docmanager'; 7 | 8 | import { DatasetsWidget } from './datasetsWidget'; 9 | import { SubsetsWidget } from './subsetsWidget'; 10 | import { IControlPanelModel } from '../../types'; 11 | import { logKernelError } from '../../tools'; 12 | 13 | export class DataPanel extends SidePanel { 14 | constructor(options: { 15 | model: IControlPanelModel; 16 | commands: CommandRegistry; 17 | manager: IDocumentManager; 18 | }) { 19 | super(); 20 | 21 | this.title.label = 'Data'; 22 | this.toolbar.addItem( 23 | 'Add data', 24 | new ToolbarButton({ 25 | tooltip: 'Add Data', 26 | label: 'Add Data', 27 | onClick: async () => { 28 | if (!options.model.currentSessionPath) { 29 | // TODO Show an error dialog, or disable the button? 30 | return; 31 | } 32 | 33 | const output = await FileDialog.getOpenFiles({ 34 | title: 'Load Data Files Into Glue Session', 35 | manager: options.manager 36 | }); 37 | 38 | if (!output.value) { 39 | return; 40 | } 41 | 42 | const kernel = this._model.currentSessionKernel(); 43 | if (kernel === undefined) { 44 | // TODO Show an error dialog 45 | return; 46 | } 47 | 48 | for (const file of output.value) { 49 | const filePath = file.path.includes(':') 50 | ? file.path.split(':')[1] 51 | : file.path; 52 | const code = ` 53 | GLUE_SESSION.add_data("${filePath}") 54 | `; 55 | 56 | const future = kernel.requestExecute({ code }, false); 57 | future.onReply = logKernelError; 58 | await future.done; 59 | } 60 | } 61 | }) 62 | ); 63 | this._model = options.model; 64 | this.toolbar.addItem( 65 | 'New Subset', 66 | new ToolbarButton({ 67 | tooltip: 'New Subset', 68 | label: 'New Subset', 69 | onClick: () => console.log('clicked') 70 | }) 71 | ); 72 | const dataset = new DatasetsWidget({ 73 | model: this._model, 74 | commands: options.commands 75 | }); 76 | this.addWidget(dataset); 77 | 78 | const subset = new SubsetsWidget({ model: this._model }); 79 | this.addWidget(subset); 80 | } 81 | 82 | private _model: IControlPanelModel; 83 | } 84 | -------------------------------------------------------------------------------- /src/document/docModel.ts: -------------------------------------------------------------------------------- 1 | import { IChangedArgs } from '@jupyterlab/coreutils'; 2 | import { PartialJSONObject } from '@lumino/coreutils'; 3 | import { ISignal, Signal } from '@lumino/signaling'; 4 | 5 | import { IGlueSessionSharedModel, IGlueSessionModel } from '../types'; 6 | import { GlueSessionSharedModel } from './sharedModel'; 7 | 8 | interface IOptions { 9 | sharedModel?: IGlueSessionSharedModel; 10 | languagePreference?: string; 11 | } 12 | export class GlueSessionModel implements IGlueSessionModel { 13 | constructor(options: IOptions) { 14 | const { sharedModel } = options; 15 | 16 | if (sharedModel) { 17 | this._sharedModel = sharedModel; 18 | } else { 19 | this._sharedModel = GlueSessionSharedModel.create(); 20 | } 21 | } 22 | 23 | readonly collaborative = true; 24 | 25 | get sharedModel(): IGlueSessionSharedModel { 26 | return this._sharedModel; 27 | } 28 | 29 | get isDisposed(): boolean { 30 | return this._isDisposed; 31 | } 32 | 33 | get contentChanged(): ISignal { 34 | return this._contentChanged; 35 | } 36 | 37 | get stateChanged(): ISignal> { 38 | return this._stateChanged; 39 | } 40 | 41 | get dirty(): boolean { 42 | return this._dirty; 43 | } 44 | set dirty(value: boolean) { 45 | this._dirty = value; 46 | } 47 | 48 | get readOnly(): boolean { 49 | return this._readOnly; 50 | } 51 | set readOnly(value: boolean) { 52 | this._readOnly = value; 53 | } 54 | 55 | get disposed(): ISignal { 56 | return this._disposed; 57 | } 58 | 59 | dispose(): void { 60 | if (this._isDisposed) { 61 | return; 62 | } 63 | this._isDisposed = true; 64 | this._sharedModel.dispose(); 65 | this._disposed.emit(); 66 | Signal.clearData(this); 67 | } 68 | 69 | toString(): string { 70 | return JSON.stringify({}, null, 2); 71 | } 72 | 73 | fromString(data: string): void { 74 | /** */ 75 | } 76 | 77 | toJSON(): PartialJSONObject { 78 | return JSON.parse(this.toString()); 79 | } 80 | 81 | fromJSON(data: PartialJSONObject): void { 82 | // nothing to do 83 | } 84 | 85 | initialize(): void { 86 | // 87 | } 88 | 89 | readonly defaultKernelName: string = ''; 90 | readonly defaultKernelLanguage: string = ''; 91 | 92 | private _sharedModel: IGlueSessionSharedModel; 93 | 94 | private _dirty = false; 95 | private _readOnly = false; 96 | private _isDisposed = false; 97 | 98 | private _disposed = new Signal(this); 99 | private _contentChanged = new Signal(this); 100 | private _stateChanged = new Signal>(this); 101 | } 102 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling>=1.4.0", "jupyterlab>=4.0.0,<5", "hatch-nodejs-version"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "glue-jupyterlab" 7 | readme = "README.md" 8 | license = { file = "LICENSE" } 9 | requires-python = ">=3.7" 10 | classifiers = [ 11 | "Framework :: Jupyter", 12 | "Framework :: Jupyter :: JupyterLab", 13 | "Framework :: Jupyter :: JupyterLab :: 3", 14 | "Framework :: Jupyter :: JupyterLab :: Extensions", 15 | "Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt", 16 | "License :: OSI Approved :: BSD License", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.7", 20 | "Programming Language :: Python :: 3.8", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | ] 25 | dependencies = [ 26 | "jupyter_server>=1.21,<3", 27 | "jupyter_collaboration>=1.0.0,<2.0.0", 28 | "glue-core", 29 | "glue-jupyter", 30 | "yjs-widgets>=0.3.3,<0.4.0", 31 | "ypywidgets>=0.4.0,<0.5.0", 32 | ] 33 | dynamic = ["version", "description", "authors", "urls", "keywords"] 34 | 35 | [project.optional-dependencies] 36 | test = [ 37 | "coverage", 38 | "pytest", 39 | "pytest-asyncio", 40 | "pytest-cov", 41 | "pytest-jupyter[server]>=0.6.0", 42 | ] 43 | 44 | [tool.hatch.version] 45 | source = "nodejs" 46 | 47 | [tool.hatch.metadata.hooks.nodejs] 48 | fields = ["description", "authors", "urls"] 49 | 50 | [tool.hatch.build.targets.sdist] 51 | artifacts = ["glue_jupyterlab/labextension"] 52 | exclude = [".github", "binder"] 53 | 54 | [tool.hatch.build.targets.wheel.shared-data] 55 | "glue_jupyterlab/labextension" = "share/jupyter/labextensions/glue-jupyterlab" 56 | "install.json" = "share/jupyter/labextensions/glue-jupyterlab/install.json" 57 | "jupyter-config/server-config" = "etc/jupyter/jupyter_server_config.d" 58 | "jupyter-config/nb-config" = "etc/jupyter/jupyter_notebook_config.d" 59 | 60 | [tool.hatch.build.hooks.version] 61 | path = "glue_jupyterlab/_version.py" 62 | 63 | [tool.hatch.build.hooks.jupyter-builder] 64 | dependencies = ["hatch-jupyter-builder>=0.5"] 65 | build-function = "hatch_jupyter_builder.npm_builder" 66 | ensured-targets = [ 67 | "glue_jupyterlab/labextension/static/style.js", 68 | "glue_jupyterlab/labextension/package.json", 69 | ] 70 | skip-if-exists = ["glue_jupyterlab/labextension/static/style.js"] 71 | 72 | [tool.hatch.build.hooks.jupyter-builder.build-kwargs] 73 | build_cmd = "build:prod" 74 | npm = ["jlpm"] 75 | 76 | [tool.hatch.build.hooks.jupyter-builder.editable-build-kwargs] 77 | build_cmd = "install:extension" 78 | npm = ["jlpm"] 79 | source_dir = "src" 80 | build_dir = "glue_jupyterlab/labextension" 81 | 82 | [tool.jupyter-releaser.options] 83 | version_cmd = "hatch version" 84 | 85 | [tool.jupyter-releaser.hooks] 86 | before-build-npm = [ 87 | "python -m pip install 'jupyterlab>=4.0.0,<5'", 88 | "jlpm", 89 | "jlpm build:prod", 90 | ] 91 | before-build-python = ["jlpm clean:all"] 92 | 93 | [tool.check-wheel-contents] 94 | ignore = ["W002"] 95 | 96 | [project.entry-points.jupyter_ydoc] 97 | glu = "glue_jupyterlab.glue_ydoc:YGlue" 98 | -------------------------------------------------------------------------------- /glue_jupyterlab/tests/test_ydoc.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def test_set(yglue_doc): 5 | assert "Tab 1" in yglue_doc._ytabs 6 | assert "Tab 2" in yglue_doc._ytabs 7 | 8 | assert "HistogramViewer" in yglue_doc._ycontents 9 | 10 | assert "DEJ2000" in yglue_doc._yattributes 11 | 12 | assert "w5_psc" in yglue_doc._ydataset 13 | 14 | 15 | def test_get(session_path, yglue_doc): 16 | with open(session_path, "r") as fobj: 17 | data = fobj.read() 18 | 19 | content = yglue_doc.get() 20 | 21 | # Test that reading and saving does not change the content 22 | assert json.loads(data) == json.loads(content) 23 | 24 | ## Fake editing of the y structure 25 | with yglue_doc._ydoc.begin_transaction() as t: 26 | # Create a new tab 27 | old_tabs = json.loads(yglue_doc._ytabs.to_json()) 28 | old_tabs["Tab 3"] = { 29 | "NewScatter": { 30 | "_type": "glue.viewers.scatter.qt.data_viewer.ScatterViewer", 31 | "pos": [0, 0], 32 | "session": "Session", 33 | "size": [600, 400], 34 | "state": {"values": {"layer": "w5"}}, 35 | } 36 | } 37 | 38 | yglue_doc._ytabs.update(t, old_tabs.items()) 39 | 40 | updated_content = json.loads(yglue_doc.get()) 41 | 42 | assert "Tab 3" in updated_content["__main__"]["tab_names"] 43 | assert len(updated_content["__main__"]["viewers"]) == 3 44 | assert "NewScatter" in updated_content["__main__"]["viewers"][2] 45 | 46 | 47 | def test_get_remove_tab(yglue_doc): 48 | ## Fake editing of the y structure 49 | with yglue_doc._ydoc.begin_transaction() as t: 50 | # Remove a tab 51 | yglue_doc._ytabs.pop(t, "Tab 1", None) 52 | 53 | updated_content = json.loads(yglue_doc.get()) 54 | 55 | assert "Tab 1" not in updated_content["__main__"]["tab_names"] 56 | assert len(updated_content["__main__"]["viewers"]) == 1 57 | 58 | 59 | def test_links(yglue_doc_links, identity_link): 60 | yglue_doc_links.get() 61 | links = yglue_doc_links.links 62 | required = [ 63 | "_type", 64 | "data1", 65 | "data2", 66 | "cids1", 67 | "cids2", 68 | "cids1_labels", 69 | "cids2_labels", 70 | ] 71 | types = [str, str, str, list, list, list, list] 72 | 73 | # Links should have been populated according to the session file, and all links 74 | # should have the same schema. 75 | assert len(links) == 3 76 | for link in links.values(): 77 | assert all(item in link.keys() for item in required) 78 | assert type(link["cids1"]) == list 79 | assert all( 80 | [type(link[key]) == value_type for key, value_type in zip(required, types)] 81 | ) 82 | 83 | ## Fake editing of the y structure 84 | with yglue_doc_links._ydoc.begin_transaction() as t: 85 | links["TestLink"] = identity_link 86 | 87 | yglue_doc_links._ylinks.update(t, links.items()) 88 | 89 | # The new link should be in the glue session content. 90 | updated_content = json.loads(yglue_doc_links.get()) 91 | _data_collection_name: str = updated_content.get("__main__", {}).get("data", "") 92 | link_names = updated_content.get(_data_collection_name, {}).get("links", []) 93 | 94 | assert "TestLink" in link_names 95 | -------------------------------------------------------------------------------- /style/icons/glue-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /glue_jupyterlab/glue_utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | from inspect import getfullargspec 3 | from typing import Dict, List 4 | 5 | from glue.config import link_function, link_helper 6 | from glue.main import load_plugins 7 | from IPython.display import display 8 | from ipywidgets import HTML 9 | 10 | load_plugins() 11 | 12 | 13 | class ErrorWidget: 14 | """Wrapper of a HTML widget for showing error message""" 15 | 16 | def __init__(self, e: Exception, path: str) -> None: 17 | value = f"{type(e).__name__} at line {e.__traceback__.tb_lineno} of {path}: {e}" 18 | self._widget = HTML(value=value) 19 | 20 | def show(self): 21 | display(self._widget) 22 | 23 | 24 | def get_function_info(function_or_helper): 25 | item_info: Dict[str, Dict] = {} 26 | attributes = ["description", "labels1", "labels2", "display"] 27 | 28 | item = function_or_helper[0] 29 | 30 | if hasattr(function_or_helper, "function"): 31 | try: 32 | item_info["description"] = function_or_helper.info 33 | item_info["labels1"] = getfullargspec(item)[0] 34 | item_info["labels2"] = function_or_helper.output_labels 35 | item_info["display"] = function_or_helper.function.__name__ 36 | except Exception as e: 37 | print(f"The link function {function_or_helper} is not loaded\n{e.args}") 38 | else: 39 | for attr in attributes: 40 | item_info[attr] = getattr(item, attr, "") 41 | 42 | item_info["function"] = item.__name__ 43 | item_info["_type"] = f"{item.__module__}.{item.__name__}" 44 | if not item_info["display"]: 45 | item_info["display"] = item.__name__ 46 | 47 | return item_info 48 | 49 | 50 | def get_advanced_links(): 51 | advanced_links: Dict[str, List] = {} 52 | 53 | for function in link_function.members: 54 | if len(function.output_labels) == 1: 55 | if function.category not in advanced_links: 56 | advanced_links[function.category]: List[Dict] = [] 57 | advanced_info = get_function_info(function) 58 | advanced_links[function.category].append(advanced_info) 59 | 60 | for helper in link_helper.members: 61 | if helper.category not in advanced_links: 62 | advanced_links[helper.category]: List[Dict] = [] 63 | advanced_info = get_function_info(helper) 64 | try: 65 | json.dumps(advanced_info) 66 | advanced_links[helper.category].append(advanced_info) 67 | except TypeError: 68 | advanced_links[helper.category].append( 69 | { 70 | "display": str(advanced_info["display"]), 71 | "description": "This link is not available", 72 | } 73 | ) 74 | 75 | # Reordering the dict 76 | categories = ["General"] + sorted(set(advanced_links.keys()) - set(["General"])) 77 | advanced_links = {k: advanced_links[k] for k in categories} 78 | return advanced_links 79 | 80 | 81 | def nested_compare(value1, value2): 82 | # Compare lists 83 | if isinstance(value1, list) and isinstance(value2, list): 84 | if not len(value1) == len(value2): 85 | return False 86 | 87 | for v1, v2 in zip(value1, value2): 88 | if not nested_compare(v1, v2): 89 | return False 90 | 91 | return True 92 | 93 | # Compare dict 94 | if isinstance(value1, dict) and isinstance(value2, dict): 95 | for k1, v1 in value1.items(): 96 | if k1 not in value2.keys(): 97 | return False 98 | 99 | if not nested_compare(v1, value2[k1]): 100 | return False 101 | 102 | return True 103 | 104 | # Compare immutable 105 | return value1 == value2 106 | -------------------------------------------------------------------------------- /ui-tests/README.md: -------------------------------------------------------------------------------- 1 | # Integration Testing 2 | 3 | This folder contains the integration tests of the extension. 4 | 5 | They are defined using [Playwright](https://playwright.dev/docs/intro) test runner 6 | and [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) helper. 7 | 8 | The Playwright configuration is defined in [playwright.config.js](./playwright.config.js). 9 | 10 | The JupyterLab server configuration to use for the integration test is defined 11 | in [jupyter_server_test_config.py](./jupyter_server_test_config.py). 12 | 13 | The default configuration will produce video for failing tests and an HTML report. 14 | 15 | ## Run the tests 16 | 17 | > All commands are assumed to be executed from the root directory 18 | 19 | To run the tests, you need to: 20 | 21 | 1. Compile the extension: 22 | 23 | ```sh 24 | jlpm install 25 | jlpm build:prod 26 | ``` 27 | 28 | > Check the extension is installed in JupyterLab. 29 | 30 | 2. Install test dependencies (needed only once): 31 | 32 | ```sh 33 | cd ./ui-tests 34 | jlpm install 35 | jlpm playwright install 36 | cd .. 37 | ``` 38 | 39 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) tests: 40 | 41 | ```sh 42 | cd ./ui-tests 43 | jlpm playwright test 44 | ``` 45 | 46 | Test results will be shown in the terminal. In case of any test failures, the test report 47 | will be opened in your browser at the end of the tests execution; see 48 | [Playwright documentation](https://playwright.dev/docs/test-reporters#html-reporter) 49 | for configuring that behavior. 50 | 51 | ## Update the tests snapshots 52 | 53 | > All commands are assumed to be executed from the root directory 54 | 55 | If you are comparing snapshots to validate your tests, you may need to update 56 | the reference snapshots stored in the repository. To do that, you need to: 57 | 58 | 1. Compile the extension: 59 | 60 | ```sh 61 | jlpm install 62 | jlpm build:prod 63 | ``` 64 | 65 | > Check the extension is installed in JupyterLab. 66 | 67 | 2. Install test dependencies (needed only once): 68 | 69 | ```sh 70 | cd ./ui-tests 71 | jlpm install 72 | jlpm playwright install 73 | cd .. 74 | ``` 75 | 76 | 3. Execute the [Playwright](https://playwright.dev/docs/intro) command: 77 | 78 | ```sh 79 | cd ./ui-tests 80 | jlpm playwright test -u 81 | ``` 82 | 83 | > Some discrepancy may occurs between the snapshots generated on your computer and 84 | > the one generated on the CI. To ease updating the snapshots on a PR, you can 85 | > type `please update playwright snapshots` to trigger the update by a bot on the CI. 86 | > Once the bot has computed new snapshots, it will commit them to the PR branch. 87 | 88 | ## Create tests 89 | 90 | > All commands are assumed to be executed from the root directory 91 | 92 | To create tests, the easiest way is to use the code generator tool of playwright: 93 | 94 | 1. Compile the extension: 95 | 96 | ```sh 97 | jlpm install 98 | jlpm build:prod 99 | ``` 100 | 101 | > Check the extension is installed in JupyterLab. 102 | 103 | 2. Install test dependencies (needed only once): 104 | 105 | ```sh 106 | cd ./ui-tests 107 | jlpm install 108 | jlpm playwright install 109 | cd .. 110 | ``` 111 | 112 | 3. Execute the [Playwright code generator](https://playwright.dev/docs/codegen): 113 | 114 | ```sh 115 | cd ./ui-tests 116 | jlpm playwright codegen localhost:8888 117 | ``` 118 | 119 | ## Debug tests 120 | 121 | > All commands are assumed to be executed from the root directory 122 | 123 | To debug tests, a good way is to use the inspector tool of playwright: 124 | 125 | 1. Compile the extension: 126 | 127 | ```sh 128 | jlpm install 129 | jlpm build:prod 130 | ``` 131 | 132 | > Check the extension is installed in JupyterLab. 133 | 134 | 2. Install test dependencies (needed only once): 135 | 136 | ```sh 137 | cd ./ui-tests 138 | jlpm install 139 | jlpm playwright install 140 | cd .. 141 | ``` 142 | 143 | 3. Execute the Playwright tests in [debug mode](https://playwright.dev/docs/debug): 144 | 145 | ```sh 146 | cd ./ui-tests 147 | PWDEBUG=1 jlpm playwright test 148 | ``` 149 | -------------------------------------------------------------------------------- /src/leftPanel/data/datasetsWidget.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { CommandRegistry } from '@lumino/commands'; 4 | import { Menu } from '@lumino/widgets'; 5 | 6 | import { ReactWidget } from '@jupyterlab/ui-components'; 7 | 8 | import { 9 | DATASET_MIME, 10 | IControlPanelModel, 11 | IGlueSessionSharedModel 12 | } from '../../types'; 13 | import { CommandIDs } from '../../commands'; 14 | 15 | export class DatasetsWidget extends ReactWidget { 16 | constructor(options: { 17 | model: IControlPanelModel; 18 | commands: CommandRegistry; 19 | }) { 20 | super(); 21 | const { model, commands } = options; 22 | 23 | this._model = model; 24 | this.title.label = 'Datasets'; 25 | 26 | this._model.glueSessionChanged.connect(this._sessionChanged, this); 27 | 28 | // Construct the context menu. 29 | this._menu = new Menu({ commands }); 30 | const viewerSubmenu = new Menu({ commands }); 31 | viewerSubmenu.title.label = 'New Viewer'; 32 | viewerSubmenu.title.iconClass = 'fa fa-caret-right'; 33 | viewerSubmenu.addItem({ command: CommandIDs.new1DHistogram }); 34 | viewerSubmenu.addItem({ command: CommandIDs.new1DProfile }); 35 | viewerSubmenu.addItem({ command: CommandIDs.new2DScatter }); 36 | viewerSubmenu.addItem({ command: CommandIDs.new3DScatter }); 37 | viewerSubmenu.addItem({ command: CommandIDs.new2DImage }); 38 | viewerSubmenu.addItem({ command: CommandIDs.newTable }); 39 | this._menu.addItem({ type: 'submenu', submenu: viewerSubmenu }); 40 | 41 | this._model.selectedDatasetChanged.connect(this.update, this); 42 | } 43 | 44 | private _contextMenu(event: React.MouseEvent) { 45 | const target = event.target as HTMLElement; 46 | if (this._dataNames.includes(target.id)) { 47 | this._model.selectedDataset = target.id; 48 | } 49 | 50 | event.preventDefault(); 51 | const x = event.clientX; 52 | const y = event.clientY; 53 | this._menu.open(x, y); 54 | } 55 | 56 | private _sessionChanged() { 57 | if (this._currentSharedModel) { 58 | this._currentSharedModel.contentsChanged.disconnect( 59 | this._updateDataSets, 60 | this 61 | ); 62 | } 63 | 64 | if (!this._model.sharedModel) { 65 | this._currentSharedModel = null; 66 | return; 67 | } 68 | 69 | this._currentSharedModel = this._model.sharedModel; 70 | this._updateDataSets(); 71 | this._currentSharedModel.datasetChanged.connect(this._updateDataSets, this); 72 | } 73 | 74 | private _updateDataSets() { 75 | if (!this._currentSharedModel) { 76 | return; 77 | } 78 | 79 | this._currentSharedModel.changed.connect(this._updateDataSets, this); 80 | 81 | this._dataNames = Object.keys(this._currentSharedModel.dataset); 82 | this.update(); 83 | } 84 | 85 | private _onClick(event: React.MouseEvent) { 86 | const target = event.target as HTMLElement; 87 | if (this._dataNames.includes(target.id)) { 88 | this._model.selectedDataset = target.id; 89 | } 90 | } 91 | 92 | private _getDatasetItem(id: string): JSX.Element { 93 | const className = `glue-Control-datasets-item ${ 94 | id === this._model.selectedDataset 95 | ? 'glue-Control-datasets-item-selected' 96 | : '' 97 | }`; 98 | return ( 99 |
  • 106 | {id} 107 |
  • 108 | ); 109 | } 110 | 111 | private _onDragStart(id: string): (event: React.DragEvent) => void { 112 | return (event: React.DragEvent) => { 113 | event.dataTransfer.setData(DATASET_MIME, id); 114 | }; 115 | } 116 | 117 | render(): JSX.Element { 118 | return ( 119 |
      123 | {this._dataNames.map(this._getDatasetItem.bind(this))} 124 |
    125 | ); 126 | } 127 | 128 | private _menu: Menu; 129 | private _dataNames: string[] = []; 130 | private _currentSharedModel: IGlueSessionSharedModel | null = null; 131 | private _model: IControlPanelModel; 132 | } 133 | -------------------------------------------------------------------------------- /glue_jupyterlab/tests/test_glue_session.py: -------------------------------------------------------------------------------- 1 | import y_py as Y 2 | from copy import deepcopy 3 | from pathlib import Path 4 | from ipywidgets import Output 5 | from glue_jupyterlab.glue_session import SharedGlueSession 6 | from glue_jupyterlab.glue_utils import nested_compare 7 | 8 | 9 | def test_init(session_path): 10 | glue_session = SharedGlueSession(session_path) 11 | assert isinstance(glue_session, SharedGlueSession) 12 | assert glue_session.app is not None 13 | assert isinstance(glue_session._sessionYDoc, Y.YDoc) 14 | 15 | 16 | def test__load_data(yglue_session): 17 | yglue_session._load_data() 18 | assert "w5" in yglue_session._data 19 | assert "w5_psc" in yglue_session._data 20 | 21 | 22 | def test_create_viewer(yglue_session): 23 | yglue_session.create_viewer("Tab 1", "ScatterViewer") 24 | assert "Tab 1" in yglue_session._viewers 25 | assert "ScatterViewer" in yglue_session._viewers["Tab 1"] 26 | assert yglue_session._viewers["Tab 1"]["ScatterViewer"]["widget"] is None 27 | assert isinstance( 28 | yglue_session._viewers["Tab 1"]["ScatterViewer"]["output"], Output 29 | ) 30 | 31 | 32 | def test_remove_viewer(yglue_session): 33 | yglue_session.create_viewer("Tab 1", "ScatterViewer") 34 | yglue_session.remove_viewer("Tab 1", "ScatterViewer") 35 | assert "Tab 1" in yglue_session._viewers 36 | assert "ScatterViewer" not in yglue_session._viewers["Tab 1"] 37 | 38 | 39 | def test_remove_tab(yglue_session): 40 | yglue_session.create_viewer("Tab 1", "ScatterViewer") 41 | yglue_session.remove_tab("Tab 1") 42 | assert "Tab 1" not in yglue_session._viewers 43 | 44 | 45 | def test_render_viewer(yglue_session): 46 | yglue_session._load_data() 47 | yglue_session.create_viewer("Tab 1", "ScatterViewer") 48 | yglue_session.render_viewer() 49 | assert yglue_session._viewers["Tab 1"]["ScatterViewer"]["widget"] is not None 50 | 51 | 52 | def test_render_removed_viewer(yglue_session): 53 | yglue_session._load_data() 54 | yglue_session.create_viewer("Tab 1", "ScatterViewer") 55 | yglue_session._document.remove_tab_viewer("Tab 1", "ScatterViewer") 56 | yglue_session.render_viewer() 57 | assert "ScatterViewer" not in yglue_session._viewers["Tab 1"] 58 | 59 | 60 | def test__read_view_state(yglue_session): 61 | yglue_session._load_data() 62 | view_type, state = yglue_session._read_view_state("Tab 1", "ScatterViewer") 63 | assert view_type == "glue.viewers.scatter.qt.data_viewer.ScatterViewer" 64 | assert len(state) > 0 65 | 66 | 67 | def test_add_data(yglue_session): 68 | yglue_session._load_data() 69 | file_path = Path(__file__).parents[2] / "examples" / "w6_psc.vot" 70 | 71 | contents = deepcopy(yglue_session._document.contents) 72 | yglue_session.add_data(file_path) 73 | updated_contents = yglue_session._document.contents 74 | 75 | assert "w6_psc" in updated_contents.keys() 76 | 77 | # Assert there is no change in previous structure 78 | for key, value in contents.items(): 79 | if key == "DataCollection": 80 | continue 81 | assert key in updated_contents.keys() 82 | assert nested_compare(value, updated_contents[key]) 83 | 84 | # Compare the DataCollection 85 | for key, value in contents["DataCollection"].items(): 86 | if key == "data" or key == "cids" or key == "components": 87 | assert not nested_compare(value, updated_contents["DataCollection"][key]) 88 | else: 89 | assert nested_compare(value, updated_contents["DataCollection"][key]) 90 | 91 | assert "w6_psc" in updated_contents["DataCollection"]["data"] 92 | 93 | 94 | def test_add_identity_link(yglue_session, identity_link): 95 | yglue_session._load_data() 96 | change = {"LinkTest": {"action": "add", "newValue": identity_link}} 97 | yglue_session._update_links(change) 98 | 99 | assert yglue_session._get_identity_link(identity_link) is not None 100 | 101 | 102 | def test_delete_identity_link(yglue_session, identity_link): 103 | test_add_identity_link(yglue_session, identity_link) 104 | change = {"LinkTest": {"action": "delete", "oldValue": identity_link}} 105 | yglue_session._update_links(change) 106 | 107 | assert yglue_session._get_identity_link(identity_link) is None 108 | -------------------------------------------------------------------------------- /src/leftPanel/model.ts: -------------------------------------------------------------------------------- 1 | import { Signal, ISignal } from '@lumino/signaling'; 2 | 3 | import { 4 | IControlPanelModel, 5 | IGlueSessionWidget, 6 | IGlueSessionSharedModel, 7 | IGlueSessionModel, 8 | IRequestConfigDisplay 9 | } from '../types'; 10 | import { IGlueSessionTracker } from '../token'; 11 | import { IGlueSessionTabs } from '../_interface/glue.schema'; 12 | import { ISessionContext } from '@jupyterlab/apputils'; 13 | import { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; 14 | 15 | export class ControlPanelModel implements IControlPanelModel { 16 | constructor(options: ControlPanelModel.IOptions) { 17 | this._tracker = options.tracker; 18 | this._tracker.currentChanged.connect(async (_, changed) => { 19 | await changed?.context.ready; 20 | if (this._sessionModel) { 21 | this._sessionModel.sharedModel.tabsChanged.disconnect( 22 | this._onTabsChanged 23 | ); 24 | } 25 | this._sessionModel = changed?.context.model; 26 | this._currentSessionPath = changed?.context.path; 27 | this._tabs = this._sessionModel?.sharedModel.tabs ?? {}; 28 | this._sessionModel?.sharedModel.tabsChanged.connect( 29 | this._onTabsChanged, 30 | this 31 | ); 32 | this._glueSessionChanged.emit(changed); 33 | }); 34 | } 35 | 36 | get sharedModel(): IGlueSessionSharedModel | undefined { 37 | return this._tracker.currentSharedModel(); 38 | } 39 | get currentSessionWidget(): IGlueSessionWidget | null { 40 | return this._tracker.currentWidget; 41 | } 42 | get glueSessionChanged(): ISignal< 43 | IControlPanelModel, 44 | IGlueSessionWidget | null 45 | > { 46 | return this._glueSessionChanged; 47 | } 48 | get displayConfigRequested(): ISignal< 49 | IControlPanelModel, 50 | IRequestConfigDisplay 51 | > { 52 | return this._displayConfigRequested; 53 | } 54 | get clearConfigRequested(): ISignal { 55 | return this._clearConfigRequested; 56 | } 57 | 58 | get currentSessionPath(): string | undefined { 59 | return this._currentSessionPath; 60 | } 61 | 62 | get selectedDataset(): string | null { 63 | return this._selectedDataset; 64 | } 65 | 66 | set selectedDataset(value: string | null) { 67 | if (value !== this._selectedDataset) { 68 | this._selectedDataset = value; 69 | this._selectedDatasetChanged.emit(); 70 | } 71 | } 72 | 73 | get selectedDatasetChanged(): ISignal { 74 | return this._selectedDatasetChanged; 75 | } 76 | 77 | get tabsChanged(): ISignal { 78 | return this._tabsChanged; 79 | } 80 | 81 | getTabs(): IGlueSessionTabs { 82 | return this._tabs; 83 | } 84 | 85 | currentSessionContext(): ISessionContext | undefined { 86 | return this._tracker.currentWidget?.context.sessionContext; 87 | } 88 | 89 | currentSessionKernel(): IKernelConnection | undefined { 90 | return this._tracker.currentWidget?.sessionWidget.kernel; 91 | } 92 | 93 | displayConfig(args: IRequestConfigDisplay): void { 94 | this._displayConfigRequested.emit(args); 95 | } 96 | clearConfig(): void { 97 | this._clearConfigRequested.emit(); 98 | } 99 | private _onTabsChanged(_: any, e: any): void { 100 | this._tabs = this._sessionModel?.sharedModel.tabs ?? {}; 101 | this._tabsChanged.emit(); 102 | } 103 | 104 | private readonly _tracker: IGlueSessionTracker; 105 | private _glueSessionChanged = new Signal< 106 | IControlPanelModel, 107 | IGlueSessionWidget | null 108 | >(this); 109 | private _tabs: IGlueSessionTabs = {}; 110 | private _currentSessionPath: string | undefined = undefined; 111 | private _tabsChanged = new Signal(this); 112 | private _selectedDataset: string | null = null; 113 | private _selectedDatasetChanged = new Signal(this); 114 | 115 | private _displayConfigRequested = new Signal< 116 | IControlPanelModel, 117 | IRequestConfigDisplay 118 | >(this); 119 | private _clearConfigRequested = new Signal(this); 120 | 121 | private _sessionModel?: IGlueSessionModel; 122 | } 123 | 124 | namespace ControlPanelModel { 125 | export interface IOptions { 126 | tracker: IGlueSessionTracker; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/viewPanel/gridStackItem.ts: -------------------------------------------------------------------------------- 1 | import { Panel, Widget } from '@lumino/widgets'; 2 | import { Toolbar, ToolbarButton, closeIcon } from '@jupyterlab/ui-components'; 3 | import { Message } from '@lumino/messaging'; 4 | import { ISignal, Signal } from '@lumino/signaling'; 5 | import { DATASET_MIME } from '../types'; 6 | 7 | export class GridStackItem extends Panel { 8 | constructor(options: GridStackItem.IOptions) { 9 | super(); 10 | this.removeClass('lm-Widget'); 11 | this.removeClass('p-Widget'); 12 | this.addClass('grid-stack-item'); 13 | this.addClass('glue-item'); 14 | 15 | const { cellIdentity, cell, itemTitle = '', pos, size, tabName } = options; 16 | this._cellOutput = cell; 17 | this.cellIdentity = cellIdentity; 18 | this._pos = pos; 19 | this._size = size; 20 | this._title = itemTitle; 21 | this._tabName = tabName; 22 | 23 | const content = new Panel(); 24 | content.addClass('grid-stack-item-content'); 25 | 26 | const toolbar = this._createToolbar(itemTitle); 27 | content.addWidget(toolbar); 28 | cell.addClass('grid-item-widget'); 29 | content.addWidget(cell); 30 | 31 | this._spinner = document.createElement('div'); 32 | this._spinner.classList.add('glue-Spinner'); 33 | const spinnerContent = document.createElement('div'); 34 | spinnerContent.classList.add('glue-SpinnerContent'); 35 | this._spinner.appendChild(spinnerContent); 36 | cell.node.appendChild(this._spinner); 37 | 38 | this.addWidget(content); 39 | 40 | this._changed = new Signal(this); 41 | } 42 | 43 | readonly cellIdentity: string; 44 | 45 | get cellOutput(): Widget { 46 | return this._cellOutput; 47 | } 48 | 49 | get itemTitle(): string { 50 | return this._title; 51 | } 52 | 53 | get pos(): number[] { 54 | return this._pos; 55 | } 56 | 57 | set pos(value: number[]) { 58 | this._pos = value; 59 | } 60 | 61 | get size(): number[] { 62 | return this._size; 63 | } 64 | 65 | set size(value: number[]) { 66 | this._size = value; 67 | } 68 | 69 | get tabName(): string { 70 | return this._tabName; 71 | } 72 | 73 | set tabName(value: string) { 74 | this._tabName = value; 75 | } 76 | get changed(): ISignal { 77 | return this._changed; 78 | } 79 | 80 | protected onAfterAttach(msg: Message): void { 81 | super.onAfterAttach(msg); 82 | this.node.addEventListener('drop', this._ondrop.bind(this)); 83 | } 84 | 85 | protected onBeforeDetach(msg: Message): void { 86 | this.node.removeEventListener('drop', this._ondrop.bind(this)); 87 | super.onBeforeDetach(msg); 88 | } 89 | 90 | private async _ondrop(event: DragEvent) { 91 | const datasetId = event.dataTransfer?.getData(DATASET_MIME); 92 | if (!datasetId) { 93 | return; 94 | } 95 | this._changed.emit({ action: 'layer', dataLayer: datasetId }); 96 | } 97 | 98 | private _createToolbar(itemTitle: string): Toolbar { 99 | const toolbar = new Toolbar(); 100 | toolbar.node.addEventListener('click', () => { 101 | this._changed.emit({ action: 'edit' }); 102 | }); 103 | toolbar.addClass('glue-Session-tab-toolbar'); 104 | 105 | toolbar.addItem( 106 | 'Close', 107 | new ToolbarButton({ 108 | tooltip: 'Close', 109 | icon: closeIcon, 110 | onClick: () => this._changed.emit({ action: 'close' }) 111 | }) 112 | ); 113 | 114 | const title = new Widget(); 115 | title.node.innerText = itemTitle; 116 | title.node.style.flexGrow = '1'; 117 | title.node.style.justifyContent = 'center'; 118 | toolbar.addItem('Title', title); 119 | return toolbar; 120 | } 121 | 122 | private _spinner: HTMLDivElement; 123 | 124 | private _pos: number[]; 125 | private _size: number[]; 126 | private _title: string; 127 | private _cellOutput: Widget; 128 | private _tabName: string; 129 | private _changed: Signal; 130 | } 131 | 132 | export namespace GridStackItem { 133 | export interface IOptions { 134 | cellIdentity: string; 135 | cell: Widget; 136 | itemTitle?: string; 137 | pos: number[]; 138 | size: number[]; 139 | tabName: string; 140 | } 141 | 142 | export interface IChange { 143 | action: 'close' | 'lock' | 'edit' | 'layer'; 144 | dataLayer?: string; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/leftPanel/config/configPanel.ts: -------------------------------------------------------------------------------- 1 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 2 | import { SidePanel } from '@jupyterlab/ui-components'; 3 | import { Widget } from '@lumino/widgets'; 4 | 5 | import { 6 | IControlPanelModel, 7 | IDict, 8 | IGlueSessionSharedModel, 9 | IGlueSessionWidget, 10 | IRequestConfigDisplay 11 | } from '../../types'; 12 | import { ConfigWidget } from './configWidget'; 13 | import { ConfigWidgetModel } from './configWidgetModel'; 14 | 15 | export class ConfigPanel extends SidePanel { 16 | constructor(options: { 17 | model: IControlPanelModel; 18 | rendermime: IRenderMimeRegistry; 19 | }) { 20 | super(); 21 | const { model, rendermime } = options; 22 | this.title.label = 'Control Panel'; 23 | this._model = model; 24 | 25 | const viewerControl = new ConfigWidget({ 26 | model: new ConfigWidgetModel({ 27 | model: this._model, 28 | config: 'Viewer', 29 | rendermime 30 | }) 31 | }); 32 | 33 | const layerControl = new ConfigWidget({ 34 | model: new ConfigWidgetModel({ 35 | model: this._model, 36 | config: 'Layer', 37 | rendermime 38 | }) 39 | }); 40 | this.addWidget(viewerControl); 41 | this.addWidget(layerControl); 42 | this._createHeader(); 43 | this._model.glueSessionChanged.connect(this._onSessionChanged, this); 44 | } 45 | 46 | dispose(): void { 47 | this._model.glueSessionChanged.disconnect(this._onSessionChanged); 48 | this._model.sharedModel?.tabChanged?.disconnect(this._removeHeader); 49 | this._model.displayConfigRequested.disconnect(this._updateHeader); 50 | super.dispose(); 51 | } 52 | 53 | private _onSessionChanged( 54 | sender: IControlPanelModel, 55 | glueSessionWidget: IGlueSessionWidget | null 56 | ) { 57 | if (!glueSessionWidget) { 58 | this._panelHeader.node.innerHTML = ''; 59 | this._headerData.clear(); 60 | return; 61 | } 62 | 63 | const headerData = this._headerData.get(glueSessionWidget) ?? {}; 64 | const { tabId, cellId } = headerData; 65 | 66 | this._panelHeader.node.innerHTML = this._headerFactory(tabId, cellId); 67 | this._model.sharedModel?.tabChanged.connect(this._removeHeader, this); 68 | } 69 | 70 | private _removeHeader(sender: IGlueSessionSharedModel, args: IDict): void { 71 | if (!this._model.currentSessionWidget) { 72 | return; 73 | } 74 | const headerData = 75 | this._headerData.get(this._model.currentSessionWidget) ?? {}; 76 | const { sharedModel, tabId, cellId } = headerData; 77 | const { tab, changes } = args; 78 | if (sender === sharedModel && tab === tabId) { 79 | const keys = changes.keys as Map; 80 | keys.forEach((v, k) => { 81 | if (v.action === 'delete' && k === cellId) { 82 | this._panelHeader.node.innerHTML = ''; 83 | this._headerData.delete(this._model.currentSessionWidget!); 84 | } 85 | }); 86 | } 87 | } 88 | 89 | private _createHeader(): void { 90 | this.toolbar.addItem('Header', this._panelHeader); 91 | this._model.displayConfigRequested.connect(this._updateHeader, this); 92 | this._model.clearConfigRequested.connect(() => { 93 | this._panelHeader.node.innerHTML = ''; 94 | if (this._model.currentSessionWidget) { 95 | this._headerData.delete(this._model.currentSessionWidget); 96 | } 97 | }, this); 98 | } 99 | 100 | private _updateHeader( 101 | sender: IControlPanelModel, 102 | args: IRequestConfigDisplay 103 | ): void { 104 | const headerData = { 105 | sharedModel: sender.sharedModel, 106 | tabId: args.tabId, 107 | cellId: args.cellId 108 | }; 109 | if (this._model.currentSessionWidget) { 110 | this._headerData.set(this._model.currentSessionWidget, headerData); 111 | } 112 | this._panelHeader.node.innerHTML = this._headerFactory( 113 | args.tabId, 114 | args.cellId 115 | ); 116 | } 117 | 118 | private _headerFactory(tabId?: string, cellId?: string): string { 119 | return `${tabId?.toUpperCase() ?? ''} - ${ 120 | cellId?.toUpperCase() ?? '' 121 | }`; 122 | } 123 | 124 | private _model: IControlPanelModel; 125 | 126 | private _headerData = new Map< 127 | IGlueSessionWidget, 128 | { 129 | sharedModel?: IGlueSessionSharedModel; 130 | tabId?: string; 131 | cellId?: string; 132 | } 133 | >(); 134 | private _panelHeader = new Widget(); 135 | } 136 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: '*' 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Base Setup 18 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | 20 | - name: Install dependencies 21 | run: python -m pip install -U "jupyterlab>=4.0.0,<5" 22 | 23 | - name: Test the extension 24 | run: | 25 | set -eux 26 | jlpm 27 | jlpm run test 28 | 29 | - name: Build the extension 30 | run: | 31 | set -eux 32 | python -m pip install .[test] 33 | 34 | pytest -vv -r ap --cov glue_jupyterlab 35 | jupyter server extension list 36 | jupyter server extension list 2>&1 | grep -ie "glue_jupyterlab.*OK" 37 | 38 | jupyter labextension list 39 | jupyter labextension list 2>&1 | grep -ie "glue-jupyterlab.*OK" 40 | python -m jupyterlab.browser_check 41 | 42 | - name: Package the extension 43 | run: | 44 | set -eux 45 | 46 | pip install build 47 | python -m build 48 | pip uninstall -y "glue-jupyterlab" jupyterlab 49 | 50 | - name: Upload extension packages 51 | uses: actions/upload-artifact@v3 52 | with: 53 | name: extension-artifacts 54 | path: dist/* 55 | if-no-files-found: error 56 | 57 | test_isolated: 58 | needs: build 59 | runs-on: ubuntu-latest 60 | 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v3 64 | - name: Install Python 65 | uses: actions/setup-python@v4 66 | with: 67 | python-version: '3.9' 68 | architecture: 'x64' 69 | - uses: actions/download-artifact@v3 70 | with: 71 | name: extension-artifacts 72 | - name: Install and Test 73 | run: | 74 | set -eux 75 | # Remove NodeJS, twice to take care of system and locally installed node versions. 76 | sudo rm -rf $(which node) 77 | sudo rm -rf $(which node) 78 | 79 | pip install "jupyterlab>=4.0.0,<5" glue_jupyterlab*.whl 80 | 81 | jupyter server extension list 82 | jupyter server extension list 2>&1 | grep -ie "glue_jupyterlab.*OK" 83 | 84 | jupyter labextension list 85 | jupyter labextension list 2>&1 | grep -ie "glue-jupyterlab.*OK" 86 | python -m jupyterlab.browser_check --no-browser-test 87 | 88 | integration-tests: 89 | name: Integration tests 90 | needs: build 91 | runs-on: ubuntu-latest 92 | 93 | env: 94 | PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/pw-browsers 95 | 96 | steps: 97 | - name: Checkout 98 | uses: actions/checkout@v3 99 | 100 | - name: Base Setup 101 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 102 | 103 | - name: Download extension package 104 | uses: actions/download-artifact@v3 105 | with: 106 | name: extension-artifacts 107 | 108 | - name: Install the extension 109 | run: | 110 | set -eux 111 | python -m pip install "jupyterlab==4.0.2" glue_jupyterlab*.whl 112 | 113 | - name: Install dependencies 114 | working-directory: ui-tests 115 | env: 116 | PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 117 | run: jlpm install 118 | 119 | - name: Set up browser cache 120 | uses: actions/cache@v3 121 | with: 122 | path: | 123 | ${{ github.workspace }}/pw-browsers 124 | key: ${{ runner.os }}-${{ hashFiles('ui-tests/yarn.lock') }} 125 | 126 | - name: Install browser 127 | run: npx playwright install chromium 128 | working-directory: ui-tests 129 | 130 | - name: Execute integration tests 131 | working-directory: ui-tests 132 | run: | 133 | jlpm run test --retries=3 134 | 135 | - name: Upload Playwright Test report 136 | if: always() 137 | uses: actions/upload-artifact@v3 138 | with: 139 | name: glue-jupyterlab-playwright-tests 140 | path: | 141 | ui-tests/test-results 142 | ui-tests/playwright-report 143 | 144 | check_links: 145 | name: Check Links 146 | runs-on: ubuntu-latest 147 | timeout-minutes: 15 148 | steps: 149 | - uses: actions/checkout@v3 150 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 151 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 152 | -------------------------------------------------------------------------------- /src/leftPanel/config/configWidgetModel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IControlPanelModel, 3 | IGlueSessionWidget, 4 | IRequestConfigDisplay 5 | } from '../../types'; 6 | import { SimplifiedOutputArea, OutputAreaModel } from '@jupyterlab/outputarea'; 7 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 8 | import { ISignal, Signal } from '@lumino/signaling'; 9 | import { IDisposable } from '@lumino/disposable'; 10 | import { SessionWidget } from '../../viewPanel/sessionWidget'; 11 | 12 | export interface IOutputChangedArg { 13 | oldOuput: SimplifiedOutputArea | null | undefined; 14 | newOutput: SimplifiedOutputArea | null | undefined; 15 | } 16 | export class ConfigWidgetModel implements IDisposable { 17 | constructor(options: { 18 | config: 'Layer' | 'Viewer'; 19 | model: IControlPanelModel; 20 | rendermime: IRenderMimeRegistry; 21 | }) { 22 | this._model = options.model; 23 | this._config = options.config; 24 | this._model.glueSessionChanged.connect(this._sessionChanged, this); 25 | this._model.displayConfigRequested.connect(this._showConfig, this); 26 | this._model.clearConfigRequested.connect(this._clearConfig, this); 27 | } 28 | 29 | get config(): 'Layer' | 'Viewer' { 30 | return this._config; 31 | } 32 | get isDisposed(): boolean { 33 | return this._disposed; 34 | } 35 | 36 | get outputChanged(): ISignal { 37 | return this._outputChanged; 38 | } 39 | 40 | dispose(): void { 41 | if (this._disposed) { 42 | return; 43 | } 44 | 45 | this._disposed = true; 46 | this._model.glueSessionChanged.disconnect(this._sessionChanged); 47 | this._model.displayConfigRequested.disconnect(this._showConfig); 48 | this._model.clearConfigRequested.disconnect(this._clearConfig); 49 | 50 | Signal.clearData(this); 51 | } 52 | 53 | private _clearConfig(): void { 54 | const context = this._model.currentSessionContext(); 55 | 56 | if (context && this._currentSessionWidget) { 57 | const output = this._outputs.get(this._currentSessionWidget); 58 | if (!output) { 59 | return; 60 | } 61 | output.model.clear(); 62 | this._currentArgs = undefined; 63 | } 64 | } 65 | private _showConfig( 66 | sender: IControlPanelModel, 67 | args: IRequestConfigDisplay 68 | ): void { 69 | const context = this._model.currentSessionContext(); 70 | if (this._currentArgs) { 71 | const { gridItem, tabId, cellId } = this._currentArgs; 72 | if ( 73 | gridItem === args.gridItem && 74 | tabId === args.tabId && 75 | cellId === args.cellId 76 | ) { 77 | return; 78 | } 79 | } 80 | 81 | if (context && this._currentSessionWidget) { 82 | const output = this._outputs.get(this._currentSessionWidget); 83 | if (!output) { 84 | return; 85 | } 86 | SimplifiedOutputArea.execute( 87 | `GLUE_SESSION.render_config("${this._config}","${args.tabId}","${args.cellId}")`, 88 | output, 89 | context 90 | ); 91 | this._currentArgs = { ...args }; 92 | } 93 | } 94 | 95 | private _sessionChanged( 96 | sender: IControlPanelModel, 97 | glueSessionWidget: IGlueSessionWidget | null 98 | ): void { 99 | if (!glueSessionWidget) { 100 | if (this._currentSessionWidget) { 101 | this._outputs.delete(this._currentSessionWidget); 102 | this._currentSessionWidget = null; 103 | this._outputChanged.emit({ 104 | oldOuput: null, 105 | newOutput: null 106 | }); 107 | } 108 | } else { 109 | if (!this._outputs.has(glueSessionWidget)) { 110 | const output = new SimplifiedOutputArea({ 111 | model: new OutputAreaModel({ trusted: true }), 112 | rendermime: (glueSessionWidget.content as SessionWidget).rendermime 113 | }); 114 | output.id = glueSessionWidget.context.path; 115 | this._outputs.set(glueSessionWidget, output); 116 | } 117 | this._outputChanged.emit({ 118 | oldOuput: this._currentSessionWidget 119 | ? this._outputs.get(this._currentSessionWidget) 120 | : null, 121 | newOutput: this._outputs.get(glueSessionWidget)! 122 | }); 123 | 124 | this._currentSessionWidget = glueSessionWidget; 125 | } 126 | } 127 | 128 | private _outputs = new Map(); 129 | private _model: IControlPanelModel; 130 | private _config: 'Layer' | 'Viewer'; 131 | private _disposed = false; 132 | private _currentSessionWidget: IGlueSessionWidget | null = null; 133 | private _outputChanged = new Signal( 134 | this 135 | ); 136 | private _currentArgs?: IRequestConfigDisplay; 137 | } 138 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { MapChange, StateChange, YDocument } from '@jupyter/ydoc'; 2 | import { DocumentRegistry, IDocumentWidget } from '@jupyterlab/docregistry'; 3 | import { JSONObject } from '@lumino/coreutils'; 4 | import { ISignal } from '@lumino/signaling'; 5 | import { Widget } from '@lumino/widgets'; 6 | import { 7 | IGlueSessionAttributes, 8 | IGlueSessionDataset, 9 | IGlueSessionLinks, 10 | IGlueSessionTabs, 11 | ILink 12 | } from './_interface/glue.schema'; 13 | import { ISessionContext } from '@jupyterlab/apputils'; 14 | import { SessionWidget } from './viewPanel/sessionWidget'; 15 | import { GridStackItem } from './viewPanel/gridStackItem'; 16 | import { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; 17 | 18 | export const DATASET_MIME = 'application/x-gluejupyter-dataset'; 19 | 20 | export interface IDict { 21 | [key: string]: T; 22 | } 23 | 24 | export type ValueOf = T[keyof T]; 25 | 26 | export interface IGlueSessionObjectChange { 27 | objectChange?: Array; 28 | } 29 | 30 | export interface IGlueSessionSharedModelChange { 31 | contextChange?: MapChange; 32 | contentChange?: MapChange; 33 | objectChange?: Array<{ 34 | name: string; 35 | key: string; 36 | newValue: any; 37 | }>; 38 | optionChange?: MapChange; 39 | stateChange?: StateChange[]; 40 | } 41 | 42 | export interface IGlueSessionSharedModel 43 | extends YDocument { 44 | contents: JSONObject; 45 | attributes: IGlueSessionAttributes; 46 | dataset: IGlueSessionDataset; 47 | links: IGlueSessionLinks; 48 | tabs: IGlueSessionTabs; 49 | contentsChanged: ISignal; 50 | datasetChanged: ISignal; 51 | linksChanged: ISignal; 52 | tabChanged: ISignal; 53 | tabsChanged: ISignal; 54 | localStateChanged: ISignal; 55 | addTab(): void; 56 | removeTab(tabName: string): void; 57 | getTabNames(): string[]; 58 | getTabData(tabName: string): IDict | undefined; 59 | 60 | getTabItem( 61 | tabName: string, 62 | itemID: string 63 | ): IGlueSessionViewerTypes | undefined; 64 | setTabItem( 65 | tabName: string, 66 | itemID: string, 67 | data: IGlueSessionViewerTypes 68 | ): void; 69 | updateTabItem( 70 | tabName: string, 71 | itemID: string, 72 | data: IGlueSessionViewerTypes 73 | ): void; 74 | removeTabItem(tabName: string, itemID: string): void; 75 | moveTabItem(name: string, fromTab: string, toTab: string): void; 76 | 77 | setSelectedTab(tab: number, emitter?: string): void; 78 | getSelectedTab(): number | null; 79 | 80 | setLink(linkName: string, link: ILink): void; 81 | removeLink(linkName: string): void; 82 | } 83 | 84 | export interface IGlueSessionModel extends DocumentRegistry.IModel { 85 | isDisposed: boolean; 86 | sharedModel: IGlueSessionSharedModel; 87 | disposed: ISignal; 88 | } 89 | 90 | export interface IGlueSessionWidget 91 | extends IDocumentWidget { 92 | sessionWidget: SessionWidget; 93 | } 94 | 95 | export interface IRequestConfigDisplay { 96 | tabId: string; 97 | cellId?: string; 98 | gridItem?: GridStackItem; 99 | } 100 | 101 | export interface IControlPanelModel { 102 | sharedModel: IGlueSessionSharedModel | undefined; 103 | glueSessionChanged: ISignal; 104 | currentSessionPath: string | undefined; 105 | selectedDataset: string | null; 106 | selectedDatasetChanged: ISignal; 107 | tabsChanged: ISignal; 108 | currentSessionWidget: IGlueSessionWidget | null; 109 | displayConfigRequested: ISignal; 110 | clearConfigRequested: ISignal; 111 | getTabs(): IGlueSessionTabs; 112 | displayConfig(args: IRequestConfigDisplay): void; 113 | clearConfig(): void; 114 | currentSessionContext(): ISessionContext | undefined; 115 | currentSessionKernel(): IKernelConnection | undefined; 116 | } 117 | 118 | export type IGlueSessionViewerTypes = ValueOf[0]; 119 | 120 | export type DashboardCellView = { 121 | /** 122 | * If cell output+widget are visible in the layout. 123 | */ 124 | hidden?: boolean; 125 | /** 126 | * Logical row position. 127 | */ 128 | row?: number; 129 | /** 130 | * Logical column position. 131 | */ 132 | col?: number; 133 | /** 134 | * Logical width. 135 | */ 136 | width?: number; 137 | /** 138 | * Logical height. 139 | */ 140 | height?: number; 141 | /** 142 | * Lock item. 143 | */ 144 | locked?: boolean; 145 | }; 146 | 147 | export interface ILoadLog { 148 | path: string; 149 | } 150 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "glue-jupyterlab", 3 | "version": "0.3.0", 4 | "description": "A JupyterLab extension for glue-viz", 5 | "keywords": [ 6 | "jupyter", 7 | "jupyterlab", 8 | "jupyterlab-extension" 9 | ], 10 | "homepage": "https://github.com/QuantStack/glue-jupyterlab", 11 | "bugs": { 12 | "url": "https://github.com/QuantStack/glue-jupyterlab/issues" 13 | }, 14 | "license": "BSD-3-Clause", 15 | "author": { 16 | "name": "QuantStack", 17 | "email": "info@quantstack.net" 18 | }, 19 | "files": [ 20 | "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", 21 | "style/**/*.{css,js,eot,gif,html,jpg,json,png,svg,woff2,ttf}" 22 | ], 23 | "main": "lib/index.js", 24 | "types": "lib/index.d.ts", 25 | "style": "style/index.css", 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/QuantStack/glue-jupyterlab.git" 29 | }, 30 | "scripts": { 31 | "build": "jlpm build:schema && jlpm build:lib && jlpm build:labextension:dev", 32 | "build:prod": "jlpm clean && jlpm build:schema && jlpm build:lib:prod && jlpm build:labextension", 33 | "build:labextension": "jupyter labextension build .", 34 | "build:labextension:dev": "jupyter labextension build --development True .", 35 | "build:lib": "tsc --sourceMap", 36 | "build:lib:prod": "tsc", 37 | "build:schema": "json2ts -i src/schemas -o src/_interface --no-unknownAny --unreachableDefinitions --cwd ./src/schemas", 38 | "clean": "jlpm clean:lib", 39 | "clean:lib": "rimraf lib tsconfig.tsbuildinfo", 40 | "clean:lintcache": "rimraf .eslintcache .stylelintcache", 41 | "clean:labextension": "rimraf glue_jupyterlab/labextension glue_jupyterlab/_version.py", 42 | "clean:all": "jlpm clean:lib && jlpm clean:labextension && jlpm clean:lintcache", 43 | "eslint": "jlpm eslint:check --fix", 44 | "eslint:check": "eslint . --cache --ext .ts,.tsx", 45 | "install:extension": "jlpm build", 46 | "lint": "jlpm stylelint && jlpm prettier && jlpm eslint", 47 | "lint:check": "jlpm stylelint:check && jlpm prettier:check && jlpm eslint:check", 48 | "prettier": "jlpm prettier:base --write --list-different", 49 | "prettier:base": "prettier \"**/*{.ts,.tsx,.js,.jsx,.css,.json,.md}\"", 50 | "prettier:check": "jlpm prettier:base --check", 51 | "stylelint": "jlpm stylelint:check --fix", 52 | "stylelint:check": "stylelint --cache \"style/**/*.css\"", 53 | "test": "jest --coverage", 54 | "watch": "run-p watch:src watch:labextension", 55 | "watch:src": "tsc -w", 56 | "watch:labextension": "jupyter labextension watch ." 57 | }, 58 | "dependencies": { 59 | "@jupyter/docprovider": "^1.0.0-alpha.8", 60 | "@jupyter/ydoc": "^1.0.2", 61 | "@jupyterlab/application": "^4.0.0", 62 | "@jupyterlab/apputils": "^4.0.0", 63 | "@jupyterlab/coreutils": "^6.0.0", 64 | "@jupyterlab/docregistry": "^4.0.0", 65 | "@jupyterlab/launcher": "^4.0.2", 66 | "@jupyterlab/mainmenu": "^4.0.0", 67 | "@jupyterlab/notebook": "^4.0.0", 68 | "@jupyterlab/observables": "^5.0.0", 69 | "@jupyterlab/rendermime": "^4.0.0", 70 | "@jupyterlab/services": "^7.0.0", 71 | "@jupyterlab/ui-components": "^4.0.0", 72 | "@lumino/algorithm": "^2.0.0", 73 | "@lumino/commands": "^2.1.0", 74 | "@lumino/coreutils": "^2.1.0", 75 | "@lumino/disposable": "^2.1.0", 76 | "@lumino/messaging": "^2.0.0", 77 | "@lumino/signaling": "^2.1.0", 78 | "@lumino/widgets": "^2.1.0", 79 | "gridstack": "^6.0.1", 80 | "yjs-widgets": "^0.3.3" 81 | }, 82 | "devDependencies": { 83 | "@jupyterlab/builder": "^4.0.0", 84 | "@jupyterlab/testutils": "^4.0.0", 85 | "@types/jest": "^29.2.0", 86 | "@typescript-eslint/eslint-plugin": "^4.8.1", 87 | "@typescript-eslint/parser": "^4.8.1", 88 | "eslint": "^7.14.0", 89 | "eslint-config-prettier": "^6.15.0", 90 | "eslint-plugin-prettier": "^3.1.4", 91 | "jest": "^29.5.0", 92 | "json-schema-to-typescript": "^10.1.5", 93 | "mkdirp": "^1.0.3", 94 | "npm-run-all": "^4.1.5", 95 | "prettier": "^2.1.1", 96 | "rimraf": "^3.0.2", 97 | "stylelint": "^14.3.0", 98 | "stylelint-config-prettier": "^9.0.4", 99 | "stylelint-config-recommended": "^6.0.0", 100 | "stylelint-config-standard": "~24.0.0", 101 | "stylelint-prettier": "^2.0.0", 102 | "typescript": "~5.0.4" 103 | }, 104 | "sideEffects": [ 105 | "style/*.css", 106 | "style/index.js" 107 | ], 108 | "styleModule": "style/index.js", 109 | "publishConfig": { 110 | "access": "public" 111 | }, 112 | "jupyterlab": { 113 | "discovery": { 114 | "server": { 115 | "managers": [ 116 | "pip" 117 | ], 118 | "base": { 119 | "name": "glue-jupyterlab" 120 | } 121 | } 122 | }, 123 | "extension": true, 124 | "outputDir": "glue_jupyterlab/labextension", 125 | "sharedPackages": { 126 | "yjs": { 127 | "bundled": false, 128 | "singleton": true 129 | }, 130 | "yjs-widgets": { 131 | "bundled": false, 132 | "singleton": true 133 | } 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # glue-jupyterlab 2 | 3 | The Glue application for JupyterLab 4 | 5 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/QuantStack/glue-jupyterlab/stable?urlpath=lab) 6 | 7 | ## Overview 8 | 9 | Glue is an open-source data exploration tool that allows users to visualize and analyze data in a way that is intuitive and easy to understand. This JupyterLab extension brings Glue's data exploration capabilities directly into JupyterLab, allowing users to seamlessly analyze and visualize data without leaving their JupyterLab environment. 10 | 11 | ![glue-jupyterlab](glue-jupyterlab.png) 12 | 13 | ## What is the difference with Glue-jupyter? 14 | 15 | [`glue-jupyter`](https://github.com/glue-viz/glue-jupyter) is a library for creating a Glue "application" object directly in Python from a Jupyter Notebook (either using classic Notebook or JupyterLab) and create Glue "viewers" using tools like bqplot or Matplotlib. 16 | 17 | glue-jupyterlab is an extension that allows opening Glue sessions directly from the JupyterLab application. It reuses components of glue-jupyter for creating the viewers and the underlying Glue application object. 18 | 19 | ## Requirements 20 | 21 | - JupyterLab >= 4.0.0,<5 22 | 23 | ## Install 24 | 25 | To install the extension, execute: 26 | 27 | ```bash 28 | pip install glue-jupyterlab 29 | ``` 30 | 31 | ## Uninstall 32 | 33 | To remove the extension, execute: 34 | 35 | ```bash 36 | pip uninstall glue-jupyterlab 37 | ``` 38 | 39 | ## Troubleshoot 40 | 41 | If you are seeing the frontend extension, but it is not working, check 42 | that the server extension is enabled: 43 | 44 | ```bash 45 | jupyter server extension list 46 | ``` 47 | 48 | If the server extension is installed and enabled, but you are not seeing 49 | the frontend extension, check the frontend extension is installed: 50 | 51 | ```bash 52 | jupyter labextension list 53 | ``` 54 | 55 | ## Contributing 56 | 57 | ### Development install 58 | 59 | Note: You will need NodeJS to build the extension package. 60 | 61 | The `jlpm` command is JupyterLab's pinned version of 62 | [yarn](https://yarnpkg.com/) that is installed with JupyterLab. You may use 63 | `yarn` or `npm` in lieu of `jlpm` below. 64 | 65 | ```bash 66 | # Clone the repo to your local environment 67 | # Change directory to the glue-jupyterlab directory 68 | # Install package in development mode 69 | pip install -e ".[test]" 70 | # Link your development version of the extension with JupyterLab 71 | jupyter labextension develop . --overwrite 72 | # Server extension must be manually installed in develop mode 73 | jupyter server extension enable glue-jupyterlab 74 | # Rebuild extension Typescript source after making changes 75 | jlpm build 76 | ``` 77 | 78 | You can watch the source directory and run JupyterLab at the same time in different terminals to watch for changes in the extension's source and automatically rebuild the extension. 79 | 80 | ```bash 81 | # Watch the source directory in one terminal, automatically rebuilding when needed 82 | jlpm watch 83 | # Run JupyterLab in another terminal 84 | jupyter lab 85 | ``` 86 | 87 | With the watch command running, every saved change will immediately be built locally and available in your running JupyterLab. Refresh JupyterLab to load the change in your browser (you may need to wait several seconds for the extension to be rebuilt). 88 | 89 | By default, the `jlpm build` command generates the source maps for this extension to make it easier to debug using the browser dev tools. To also generate source maps for the JupyterLab core extensions, you can run the following command: 90 | 91 | ```bash 92 | jupyter lab build --minimize=False 93 | ``` 94 | 95 | ### Development uninstall 96 | 97 | ```bash 98 | # Server extension must be manually disabled in develop mode 99 | jupyter server extension disable glue-jupyterlab 100 | pip uninstall glue-jupyterlab 101 | ``` 102 | 103 | In development mode, you will also need to remove the symlink created by `jupyter labextension develop` 104 | command. To find its location, you can run `jupyter labextension list` to figure out where the `labextensions` 105 | folder is located. Then you can remove the symlink named `glue-jupyterlab` within that folder. 106 | 107 | ### Testing the extension 108 | 109 | #### Server tests 110 | 111 | This extension is using [Pytest](https://docs.pytest.org/) for Python code testing. 112 | 113 | Install test dependencies (needed only once): 114 | 115 | ```sh 116 | pip install -e ".[test]" 117 | # Each time you install the Python package, you need to restore the front-end extension link 118 | jupyter labextension develop . --overwrite 119 | ``` 120 | 121 | To execute them, run: 122 | 123 | ```sh 124 | pytest -vv -r ap --cov glue-jupyterlab 125 | ``` 126 | 127 | #### Frontend tests 128 | 129 | This extension is using [Jest](https://jestjs.io/) for JavaScript code testing. 130 | 131 | To execute them, execute: 132 | 133 | ```sh 134 | jlpm 135 | jlpm test 136 | ``` 137 | 138 | #### Integration tests 139 | 140 | This extension uses [Playwright] for the integration tests (aka user level tests). 141 | More precisely, the JupyterLab helper [Galata](https://github.com/jupyterlab/jupyterlab/tree/master/galata) is used to handle testing the extension in JupyterLab. 142 | 143 | More information are provided within the [ui-tests](./ui-tests/README.md) README. 144 | 145 | ### Packaging the extension 146 | 147 | See [RELEASE](RELEASE.md) 148 | -------------------------------------------------------------------------------- /src/document/plugin.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ICollaborativeDrive, 3 | SharedDocumentFactory 4 | } from '@jupyter/docprovider'; 5 | import { 6 | JupyterFrontEnd, 7 | JupyterFrontEndPlugin 8 | } from '@jupyterlab/application'; 9 | import { ICommandPalette, WidgetTracker } from '@jupyterlab/apputils'; 10 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser'; 11 | import { ILauncher } from '@jupyterlab/launcher'; 12 | import { INotebookTracker } from '@jupyterlab/notebook'; 13 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 14 | import { IJupyterYWidgetManager } from 'yjs-widgets'; 15 | 16 | import { CommandIDs } from '../commands'; 17 | import { IGlueSessionTracker } from '../token'; 18 | import { glueIcon } from '../tools'; 19 | import defaultContent from './default.json'; 20 | import { GlueSessionModelFactory } from './modelFactory'; 21 | import { GlueSessionSharedModel } from './sharedModel'; 22 | import { GlueSessionTracker } from './tracker'; 23 | import { GlueCanvasWidgetFactory } from './widgetFactory'; 24 | 25 | const NAME_SPACE = 'glue-jupyterlab'; 26 | 27 | export const sessionTrackerPlugin: JupyterFrontEndPlugin = 28 | { 29 | id: 'glue-jupyterlab:tracker-plugin', 30 | autoStart: true, 31 | provides: IGlueSessionTracker, 32 | activate: (app: JupyterFrontEnd) => { 33 | const tracker = new GlueSessionTracker({ 34 | namespace: NAME_SPACE 35 | }); 36 | return tracker; 37 | } 38 | }; 39 | 40 | /** 41 | * Initialization data for the glue-jupyterlab extension. 42 | */ 43 | export const gluePlugin: JupyterFrontEndPlugin = { 44 | id: 'glue-jupyterlab:document-plugin', 45 | autoStart: true, 46 | requires: [ 47 | IRenderMimeRegistry, 48 | IGlueSessionTracker, 49 | ICollaborativeDrive, 50 | INotebookTracker, 51 | IJupyterYWidgetManager 52 | ], 53 | activate: ( 54 | app: JupyterFrontEnd, 55 | rendermime: IRenderMimeRegistry, 56 | canvasTracker: WidgetTracker, 57 | drive: ICollaborativeDrive, 58 | notebookTracker: INotebookTracker, 59 | yWidgetManager: IJupyterYWidgetManager 60 | ) => { 61 | const widgetFactory = new GlueCanvasWidgetFactory({ 62 | name: 'Glue Lab', 63 | modelName: 'gluelab-session-model', 64 | fileTypes: ['glu'], 65 | defaultFor: ['glu'], 66 | rendermime, 67 | notebookTracker, 68 | yWidgetManager: yWidgetManager, 69 | preferKernel: true, 70 | canStartKernel: true, 71 | autoStartDefault: true, 72 | commands: app.commands 73 | }); 74 | widgetFactory.widgetCreated.connect((_, widget) => { 75 | widget.context.pathChanged.connect(() => { 76 | canvasTracker.save(widget); 77 | }); 78 | canvasTracker.add(widget); 79 | app.shell.activateById('glue-jupyterlab::controlPanel'); 80 | }); 81 | app.docRegistry.addWidgetFactory(widgetFactory); 82 | 83 | const modelFactory = new GlueSessionModelFactory(); 84 | app.docRegistry.addModelFactory(modelFactory); 85 | // register the filetype 86 | app.docRegistry.addFileType({ 87 | name: 'glu', 88 | displayName: 'GLU', 89 | mimeTypes: ['text/json'], 90 | extensions: ['.glu', '.GLU'], 91 | fileFormat: 'text', 92 | contentType: 'glu' 93 | }); 94 | 95 | const glueSharedModelFactory: SharedDocumentFactory = () => { 96 | return new GlueSessionSharedModel(); 97 | }; 98 | drive.sharedModelFactory.registerDocumentFactory( 99 | 'glu', 100 | glueSharedModelFactory 101 | ); 102 | } 103 | }; 104 | 105 | /** 106 | * Add launcher button to create a new glue session. 107 | */ 108 | export const newFilePlugin: JupyterFrontEndPlugin = { 109 | id: 'glue-jupyterlab:create-new-plugin', 110 | autoStart: true, 111 | requires: [IFileBrowserFactory], 112 | optional: [ILauncher, ICommandPalette], 113 | activate: ( 114 | app: JupyterFrontEnd, 115 | browserFactory: IFileBrowserFactory, 116 | launcher?: ILauncher, 117 | commandPalette?: ICommandPalette 118 | ) => { 119 | const { commands } = app; 120 | 121 | commands.addCommand(CommandIDs.createNew, { 122 | label: args => 'New Glue Session', 123 | caption: 'Create a new Glue Session', 124 | icon: args => (args['isPalette'] ? undefined : glueIcon), 125 | execute: async args => { 126 | const cwd = (args['cwd'] || 127 | browserFactory.tracker.currentWidget?.model.path) as string; 128 | 129 | let model = await app.serviceManager.contents.newUntitled({ 130 | path: cwd, 131 | type: 'file', 132 | ext: '.glu' 133 | }); 134 | model = await app.serviceManager.contents.save(model.path, { 135 | ...model, 136 | format: 'text', 137 | size: undefined, 138 | content: JSON.stringify(defaultContent) 139 | }); 140 | 141 | // Open the newly created file with the 'Editor' 142 | return app.commands.execute('docmanager:open', { 143 | path: model.path 144 | }); 145 | } 146 | }); 147 | 148 | // Add the command to the launcher 149 | if (launcher) { 150 | launcher.add({ 151 | command: CommandIDs.createNew, 152 | category: 'glue-jupyterlab', 153 | rank: 1 154 | }); 155 | } 156 | 157 | // Add the command to the palette 158 | if (commandPalette) { 159 | commandPalette.addItem({ 160 | command: CommandIDs.createNew, 161 | args: { isPalette: true }, 162 | category: 'glue-jupyterlab' 163 | }); 164 | } 165 | } 166 | }; 167 | -------------------------------------------------------------------------------- /src/linkPanel/model.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { ServerConnection } from '@jupyterlab/services'; 3 | import { PromiseDelegate } from '@lumino/coreutils'; 4 | import { ISignal, Signal } from '@lumino/signaling'; 5 | import { IGlueSessionSharedModel } from '../types'; 6 | 7 | import { 8 | ILinkEditorModel, 9 | IAdvLinkCategories, 10 | IAdvLinkDescription, 11 | ComponentLinkType, 12 | IdentityLinkFunction 13 | } from './types'; 14 | import { ILink } from '../_interface/glue.schema'; 15 | 16 | const ADVANCED_LINKS_URL = '/glue-jupyterlab/advanced-links'; 17 | 18 | /** 19 | * The link editor model. 20 | */ 21 | export class LinkEditorModel implements ILinkEditorModel { 22 | constructor(options: LinkEditorModel.IOptions) { 23 | this._sharedModel = options.sharedModel; 24 | this._sharedModel.linksChanged.connect(this.onLinksChanged, this); 25 | this._sharedModel.datasetChanged.connect(this.onDatasetsChanged, this); 26 | this._getAdvancedLinksCategories(); 27 | } 28 | 29 | get sharedModel(): IGlueSessionSharedModel { 30 | return this._sharedModel; 31 | } 32 | 33 | /** 34 | * Getter and setter for datasets. 35 | */ 36 | get currentDatasets(): [string, string] { 37 | return this._currentDatasets; 38 | } 39 | set currentDatasets(datasets: [string, string]) { 40 | this._currentDatasets = datasets; 41 | this._datasetsChanged.emit(this._currentDatasets); 42 | } 43 | 44 | /** 45 | * Replace one current dataset. 46 | */ 47 | setCurrentDataset(index: number, value: string): void { 48 | this._currentDatasets[index] = value; 49 | this._datasetsChanged.emit(this._currentDatasets); 50 | } 51 | 52 | /** 53 | * A signal emits when current datasets changes 54 | */ 55 | get currentDatasetsChanged(): ISignal { 56 | return this._datasetsChanged; 57 | } 58 | 59 | /** 60 | * The identity links. 61 | */ 62 | get identityLinks(): Map { 63 | return this._identityLinks; 64 | } 65 | 66 | /** 67 | * The advanced links definitions. 68 | */ 69 | get advLinkCategories(): IAdvLinkCategories { 70 | return this._advLinkCategories; 71 | } 72 | 73 | /** 74 | * The advanced links. 75 | */ 76 | get advancedLinks(): Map { 77 | return this._advancedLinks; 78 | } 79 | 80 | /** 81 | * A signal emitted when the links changed. 82 | */ 83 | get linksChanged(): ISignal { 84 | return this._linksChanged; 85 | } 86 | 87 | /** 88 | * A promise that resolve when the advanced links definitions are fetched. 89 | */ 90 | get advLinksPromise(): Promise { 91 | return this._advLinksPromise.promise; 92 | } 93 | 94 | /** 95 | * Populate the advanced links definitions. 96 | */ 97 | private async _getAdvancedLinksCategories(): Promise { 98 | // Make request to Jupyter API. 99 | const settings = ServerConnection.makeSettings(); 100 | const requestUrl = URLExt.join(settings.baseUrl, ADVANCED_LINKS_URL); 101 | 102 | let response: Response; 103 | try { 104 | response = await ServerConnection.makeRequest(requestUrl, {}, settings); 105 | } catch (error: any) { 106 | throw new ServerConnection.NetworkError(error); 107 | } 108 | 109 | const data = await response.json(); 110 | 111 | if (!response.ok) { 112 | this._advLinksPromise.reject(data.message); 113 | throw new ServerConnection.ResponseError(response, data.message); 114 | } 115 | 116 | Object.entries(data.data).forEach(([category, links]) => { 117 | this._advLinkCategories[category] = links as IAdvLinkDescription[]; 118 | }); 119 | 120 | this._advLinksPromise.resolve(this._advLinkCategories); 121 | } 122 | 123 | /** 124 | * Called when the datasets have changed in the glue session model. 125 | */ 126 | onDatasetsChanged(): void { 127 | // Reset the current dataset, with empty values if there is less than 2 datasets. 128 | if (this._sharedModel.dataset) { 129 | const datasetsList = Object.keys(this._sharedModel.dataset); 130 | this._currentDatasets = [ 131 | datasetsList.length > 1 ? datasetsList[0] : '', 132 | datasetsList.length > 1 ? datasetsList[1] : '' 133 | ]; 134 | this._datasetsChanged.emit(this._currentDatasets); 135 | } 136 | } 137 | 138 | /** 139 | * Populates the identity and advanced links when the links have changed in the glue session model. 140 | */ 141 | onLinksChanged(): void { 142 | this._identityLinks = new Map(); 143 | this._advancedLinks = new Map(); 144 | 145 | Object.entries(this._sharedModel?.links).forEach( 146 | ([linkName, link], idx) => { 147 | if ( 148 | link._type === ComponentLinkType && 149 | link.using?.function === IdentityLinkFunction 150 | ) { 151 | this._identityLinks.set(linkName, link); 152 | } else { 153 | this._advancedLinks.set(linkName, link); 154 | } 155 | } 156 | ); 157 | this._linksChanged.emit(); 158 | } 159 | 160 | private _sharedModel: IGlueSessionSharedModel; 161 | private _currentDatasets: [string, string] = ['', '']; 162 | private _datasetsChanged = new Signal(this); 163 | private _advLinksPromise = new PromiseDelegate(); 164 | private _advLinkCategories: IAdvLinkCategories = {}; 165 | private _identityLinks = new Map(); 166 | private _advancedLinks = new Map(); 167 | private _linksChanged = new Signal(this); 168 | } 169 | 170 | /** 171 | * A namespace for the link editor model. 172 | */ 173 | export namespace LinkEditorModel { 174 | /** 175 | * Options to build a link editor object. 176 | */ 177 | export interface IOptions { 178 | sharedModel: IGlueSessionSharedModel; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/common/tabPanel.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon, classes, TabBarSvg } from '@jupyterlab/ui-components'; 2 | import { Widget, TabBar, StackedPanel, BoxPanel, Title } from '@lumino/widgets'; 3 | import { find, ArrayExt } from '@lumino/algorithm'; 4 | import { Signal, ISignal } from '@lumino/signaling'; 5 | 6 | export class HTabPanel extends BoxPanel { 7 | constructor(options: HTabPanel.IOptions) { 8 | const direction = options.tabBarPosition 9 | ? options.tabBarPosition === 'top' 10 | ? 'top-to-bottom' 11 | : 'bottom-to-top' 12 | : 'top-to-bottom'; 13 | super({ direction }); 14 | const tabBarOption = options.tabBarOption ?? { 15 | insertBehavior: 'none', 16 | removeBehavior: 'none', 17 | allowDeselect: false, 18 | orientation: 'horizontal' 19 | }; 20 | this._topBar = new TabBarSvg(tabBarOption); 21 | const tabBarClasses = options.tabBarClassList ?? []; 22 | tabBarClasses.forEach(cls => this._topBar.addClass(cls)); 23 | 24 | BoxPanel.setStretch(this._topBar, 0); 25 | this._topBar.hide(); 26 | this._topBar.tabActivateRequested.connect( 27 | this._onTabActivateRequested, 28 | this 29 | ); 30 | this._topBar.currentChanged.connect(this._onCurrentChanged, this); 31 | this._stackedPanel = new StackedPanel(); 32 | this._stackedPanel.hide(); 33 | BoxPanel.setStretch(this._stackedPanel, 1); 34 | 35 | this.addWidget(this._topBar); 36 | this.addWidget(this._stackedPanel); 37 | } 38 | 39 | /** 40 | * Get the tab bar managed by the handler. 41 | */ 42 | get topBar(): TabBar { 43 | return this._topBar; 44 | } 45 | 46 | /** 47 | * Get the stacked panel managed by the handler 48 | */ 49 | get stackedPanel(): StackedPanel { 50 | return this._stackedPanel; 51 | } 52 | 53 | /** 54 | * Signal fires when the stack panel or the sidebar changes 55 | */ 56 | get updated(): ISignal { 57 | return this._updated; 58 | } 59 | 60 | /** 61 | * Activate a widget residing in the side bar by ID. 62 | * 63 | * @param id - The widget's unique ID. 64 | */ 65 | activateById(id: string): void { 66 | const widget = this._findWidgetByID(id); 67 | if (widget) { 68 | this._topBar.currentTitle = widget.title; 69 | widget.activate(); 70 | } 71 | } 72 | 73 | activateTab(idx: number): void { 74 | if (idx < this._topBar.titles.length) { 75 | this._topBar.currentIndex = idx; 76 | } 77 | } 78 | addTab(widget: Widget, rank: number): void { 79 | widget.parent = null; 80 | widget.hide(); 81 | const item = { widget, rank }; 82 | const index = this._findInsertIndex(item); 83 | ArrayExt.insert(this._items, index, item); 84 | this._stackedPanel.insertWidget(index, widget); 85 | const title = this._topBar.insertTab(index, widget.title); 86 | // Store the parent id in the title dataset 87 | // in order to dispatch click events to the right widget. 88 | title.dataset = { id: widget.id }; 89 | 90 | if (title.icon instanceof LabIcon) { 91 | // bind an appropriate style to the icon 92 | title.icon = title.icon.bindprops({ 93 | stylesheet: 'sideBar' 94 | }); 95 | } else if (typeof title.icon === 'string' && title.icon !== '') { 96 | // add some classes to help with displaying css background imgs 97 | title.iconClass = classes(title.iconClass, 'jp-Icon', 'jp-Icon-20'); 98 | } 99 | this._refreshVisibility(); 100 | } 101 | 102 | protected onActivateRequest(msg: any): void { 103 | this._topBar.show(); 104 | this._stackedPanel.show(); 105 | } 106 | 107 | /** 108 | * Find the widget with the given id, or `null`. 109 | */ 110 | private _findWidgetByID(id: string): Widget | null { 111 | const item = find(this._items, value => value.widget.id === id); 112 | return item ? item.widget : null; 113 | } 114 | 115 | /** 116 | * Find the insertion index for a rank item. 117 | */ 118 | private _findInsertIndex(item: Private.IRankItem): number { 119 | return ArrayExt.upperBound(this._items, item, Private.itemCmp); 120 | } 121 | 122 | private _findWidgetByTitle(title: Title): Widget | null { 123 | const item = find(this._items, value => value.widget.title === title); 124 | return item ? item.widget : null; 125 | } 126 | 127 | /** 128 | * Refresh the visibility of the side bar and stacked panel. 129 | */ 130 | private _refreshVisibility(): void { 131 | this._stackedPanel.setHidden(this._topBar.currentTitle === null); 132 | this._topBar.setHidden( 133 | this._isHiddenByUser || this._topBar.titles.length === 0 134 | ); 135 | this._updated.emit(); 136 | } 137 | /** 138 | * Handle a `tabActivateRequest` signal from the sidebar. 139 | */ 140 | private _onTabActivateRequested( 141 | sender: TabBar, 142 | args: TabBar.ITabActivateRequestedArgs 143 | ): void { 144 | args.title.owner.activate(); 145 | args.title.owner.show(); 146 | } 147 | 148 | private _onCurrentChanged( 149 | sender: TabBar, 150 | args: TabBar.ICurrentChangedArgs 151 | ): void { 152 | const oldWidget = args.previousTitle 153 | ? this._findWidgetByTitle(args.previousTitle) 154 | : null; 155 | const newWidget = args.currentTitle 156 | ? this._findWidgetByTitle(args.currentTitle) 157 | : null; 158 | if (oldWidget) { 159 | oldWidget.hide(); 160 | } 161 | if (newWidget) { 162 | newWidget.show(); 163 | } 164 | } 165 | 166 | private _topBar: TabBar; 167 | private _stackedPanel: StackedPanel; 168 | private _items = new Array(); 169 | private _updated: Signal = new Signal(this); 170 | private _isHiddenByUser = false; 171 | } 172 | 173 | namespace Private { 174 | /** 175 | * An object which holds a widget and its sort rank. 176 | */ 177 | export interface IRankItem { 178 | /** 179 | * The widget for the item. 180 | */ 181 | widget: Widget; 182 | 183 | /** 184 | * The sort rank of the widget. 185 | */ 186 | rank: number; 187 | } 188 | 189 | export function itemCmp(first: IRankItem, second: IRankItem): number { 190 | return first.rank - second.rank; 191 | } 192 | } 193 | 194 | export namespace HTabPanel { 195 | export interface IOptions { 196 | tabBarPosition?: 'top' | 'bottom'; 197 | tabBarOption?: TabBar.IOptions; 198 | tabBarClassList?: string[]; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext api 23 | 24 | default: html 25 | 26 | help: 27 | @echo "Please use \`make ' where is one of" 28 | @echo " html to make standalone HTML files" 29 | @echo " dirhtml to make HTML files named index.html in directories" 30 | @echo " singlehtml to make a single large HTML file" 31 | @echo " pickle to make pickle files" 32 | @echo " json to make JSON files" 33 | @echo " htmlhelp to make HTML files and a HTML help project" 34 | @echo " qthelp to make HTML files and a qthelp project" 35 | @echo " applehelp to make an Apple Help Book" 36 | @echo " devhelp to make HTML files and a Devhelp project" 37 | @echo " epub to make an epub" 38 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 39 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 40 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 41 | @echo " text to make text files" 42 | @echo " man to make manual pages" 43 | @echo " texinfo to make Texinfo files" 44 | @echo " info to make Texinfo files and run them through makeinfo" 45 | @echo " gettext to make PO message catalogs" 46 | @echo " changes to make an overview of all changed/added/deprecated items" 47 | @echo " xml to make Docutils-native XML files" 48 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 49 | @echo " linkcheck to check all external links for integrity" 50 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 51 | @echo " coverage to run coverage check of the documentation (if enabled)" 52 | 53 | clean: 54 | rm -rf $(BUILDDIR)/* 55 | 56 | html: 57 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 58 | @echo 59 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 60 | 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | singlehtml: 67 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 68 | @echo 69 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 70 | 71 | pickle: 72 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 73 | @echo 74 | @echo "Build finished; now you can process the pickle files." 75 | 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | htmlhelp: 82 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 83 | @echo 84 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 85 | ".hhp project file in $(BUILDDIR)/htmlhelp." 86 | 87 | qthelp: 88 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 89 | @echo 90 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 91 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 92 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/voila.qhcp" 93 | @echo "To view the help file:" 94 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/voila.qhc" 95 | 96 | applehelp: 97 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 98 | @echo 99 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 100 | @echo "N.B. You won't be able to view it unless you put it in" \ 101 | "~/Library/Documentation/Help or install it in your application" \ 102 | "bundle." 103 | 104 | devhelp: 105 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 106 | @echo 107 | @echo "Build finished." 108 | @echo "To view the help file:" 109 | @echo "# mkdir -p $$HOME/.local/share/devhelp/voila" 110 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/voila" 111 | @echo "# devhelp" 112 | 113 | epub: 114 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 115 | @echo 116 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 117 | 118 | latex: 119 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 120 | @echo 121 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 122 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 123 | "(use \`make latexpdf' here to do that automatically)." 124 | 125 | latexpdf: 126 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 127 | @echo "Running LaTeX files through pdflatex..." 128 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 129 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 130 | 131 | latexpdfja: 132 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 133 | @echo "Running LaTeX files through platex and dvipdfmx..." 134 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 135 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 136 | 137 | text: 138 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 139 | @echo 140 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 141 | 142 | man: 143 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 144 | @echo 145 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 146 | 147 | texinfo: 148 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 149 | @echo 150 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 151 | @echo "Run \`make' in that directory to run these through makeinfo" \ 152 | "(use \`make info' here to do that automatically)." 153 | 154 | info: 155 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 156 | @echo "Running Texinfo files through makeinfo..." 157 | make -C $(BUILDDIR)/texinfo info 158 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 159 | 160 | gettext: 161 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 162 | @echo 163 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 164 | 165 | changes: 166 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 167 | @echo 168 | @echo "The overview file is in $(BUILDDIR)/changes." 169 | 170 | linkcheck: 171 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 172 | @echo 173 | @echo "Link check complete; look for any errors in the above output " \ 174 | "or in $(BUILDDIR)/linkcheck/output.txt." 175 | 176 | doctest: 177 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 178 | @echo "Testing of doctests in the sources finished, look at the " \ 179 | "results in $(BUILDDIR)/doctest/output.txt." 180 | 181 | coverage: 182 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 183 | @echo "Testing of coverage in the sources finished, look at the " \ 184 | "results in $(BUILDDIR)/coverage/python.txt." 185 | 186 | xml: 187 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 188 | @echo 189 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 190 | 191 | pseudoxml: 192 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 193 | @echo 194 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 195 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% source 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\voila.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\voila.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /src/document/sharedModel.ts: -------------------------------------------------------------------------------- 1 | import { YDocument, createMutex } from '@jupyter/ydoc'; 2 | import { JSONExt, JSONObject } from '@lumino/coreutils'; 3 | import { ISignal, Signal } from '@lumino/signaling'; 4 | 5 | import * as Y from 'yjs'; 6 | 7 | import { 8 | IDict, 9 | IGlueSessionSharedModel, 10 | IGlueSessionSharedModelChange, 11 | IGlueSessionViewerTypes 12 | } from '../types'; 13 | import { 14 | IGlueSessionAttributes, 15 | IGlueSessionDataset, 16 | IGlueSessionLinks, 17 | IGlueSessionTabs, 18 | ILink 19 | } from '../_interface/glue.schema'; 20 | 21 | export const globalMutex = createMutex(); 22 | 23 | export class GlueSessionSharedModel 24 | extends YDocument 25 | implements IGlueSessionSharedModel 26 | { 27 | constructor() { 28 | super(); 29 | 30 | this._contents = this.ydoc.getMap('contents'); 31 | this._attributes = this.ydoc.getMap('attributes'); 32 | this._dataset = this.ydoc.getMap('dataset'); 33 | this._links = this.ydoc.getMap('links'); 34 | this._tabs = this.ydoc.getMap>('tabs'); 35 | 36 | this.undoManager.addToScope(this._contents); 37 | 38 | this._contents.observe(this._contentsObserver); 39 | this._attributes.observe(this._attributesObserver); 40 | this._dataset.observe(this._datasetObserver); 41 | this._links.observe(this._linksObserver); 42 | this._tabs.observeDeep(this._tabsObserver); 43 | } 44 | 45 | static create(): IGlueSessionSharedModel { 46 | return new GlueSessionSharedModel(); 47 | } 48 | 49 | dispose(): void { 50 | super.dispose(); 51 | } 52 | 53 | /** 54 | * Document version 55 | */ 56 | readonly version: string = '1.0.0'; 57 | 58 | get contents(): JSONObject { 59 | return JSONExt.deepCopy(this._contents.toJSON()); 60 | } 61 | 62 | get attributes(): IGlueSessionAttributes { 63 | return JSONExt.deepCopy(this._attributes.toJSON()); 64 | } 65 | 66 | get dataset(): IGlueSessionDataset { 67 | return JSONExt.deepCopy(this._dataset.toJSON()); 68 | } 69 | 70 | get links(): IGlueSessionLinks { 71 | return JSONExt.deepCopy(this._links.toJSON()); 72 | } 73 | 74 | get tabs(): IGlueSessionTabs { 75 | return JSONExt.deepCopy(this._tabs.toJSON()); 76 | } 77 | 78 | get contentsChanged(): ISignal { 79 | return this._contentsChanged; 80 | } 81 | 82 | get attributesChanged(): ISignal { 83 | return this._attributesChanged; 84 | } 85 | 86 | get datasetChanged(): ISignal { 87 | return this._datasetChanged; 88 | } 89 | 90 | get linksChanged(): ISignal { 91 | return this._linksChanged; 92 | } 93 | 94 | get tabChanged(): ISignal { 95 | return this._tabChanged; 96 | } 97 | 98 | get tabsChanged(): ISignal { 99 | return this._tabsChanged; 100 | } 101 | 102 | get localStateChanged(): ISignal< 103 | IGlueSessionSharedModel, 104 | { keys: string[] } 105 | > { 106 | return this._localStateChanged; 107 | } 108 | 109 | getValue(key: string): IDict | undefined { 110 | const content = this._contents.get(key); 111 | if (!content) { 112 | return; 113 | } 114 | return JSONExt.deepCopy(content) as IDict; 115 | } 116 | 117 | setValue(key: any, value: IDict): void { 118 | this._contents.set(key, value); 119 | } 120 | 121 | addTab(): void { 122 | let idx = 1; 123 | let tabName = 'Tab 1'; 124 | while (this._tabs.has(tabName)) { 125 | idx += 1; 126 | tabName = `Tab ${idx}`; 127 | } 128 | const newTab = new Y.Map(); 129 | this.transact(() => { 130 | this._tabs.set(tabName, newTab); 131 | }, false); 132 | } 133 | 134 | removeTab(name: string): void { 135 | if (this._tabs.has(name)) { 136 | this.transact(() => { 137 | this._tabs.delete(name); 138 | }, false); 139 | } 140 | } 141 | 142 | getTabNames(): string[] { 143 | return [...this._tabs.keys()]; 144 | } 145 | 146 | getTabData(tabName: string): IDict | undefined { 147 | const tab = this._tabs.get(tabName); 148 | if (tab) { 149 | return JSONExt.deepCopy(tab.toJSON()); 150 | } 151 | } 152 | 153 | getTabItem(tabName: string, itemID: string): IGlueSessionViewerTypes { 154 | const tab = this._tabs.get(tabName); 155 | const view = tab?.get(itemID); 156 | return JSONExt.deepCopy(view ?? {}); 157 | } 158 | 159 | setTabItem( 160 | tabName: string, 161 | itemID: string, 162 | data: IGlueSessionViewerTypes 163 | ): void { 164 | const tab = this._tabs.get(tabName); 165 | tab?.set(itemID, JSONExt.deepCopy(data)); 166 | } 167 | 168 | updateTabItem( 169 | tabName: string, 170 | itemID: string, 171 | data: IGlueSessionViewerTypes 172 | ): void { 173 | const tab = this._tabs.get(tabName); 174 | 175 | if (tab) { 176 | const view = tab.get(itemID); 177 | 178 | if (view) { 179 | const content = { ...view, ...JSONExt.deepCopy(data) }; 180 | tab.set(itemID, content); 181 | } 182 | } 183 | } 184 | 185 | removeTabItem(tabName: string, itemID: string): void { 186 | const tab = this._tabs.get(tabName); 187 | tab?.delete(itemID); 188 | } 189 | 190 | moveTabItem(itemID: string, fromTab: string, toTab: string): void { 191 | const tab1 = this._tabs.get(fromTab); 192 | const tab2 = this._tabs.get(toTab); 193 | 194 | if (tab1 && tab2) { 195 | const view = tab1.get(itemID); 196 | 197 | if (view) { 198 | const content = JSONExt.deepCopy(view); 199 | this.transact(() => { 200 | tab1.delete(itemID); 201 | tab2.set(itemID, content); 202 | }, false); 203 | } 204 | } 205 | } 206 | 207 | setSelectedTab(tab: number, emitter?: string): void { 208 | this.awareness.setLocalStateField('selectedTab', { 209 | value: tab, 210 | emitter: emitter 211 | }); 212 | this._localStateChanged.emit({ keys: ['selectedTab'] }); 213 | } 214 | 215 | getSelectedTab(): number | null { 216 | const state = this.awareness.getLocalState(); 217 | 218 | if (!state || !state['selectedTab']) { 219 | return null; 220 | } 221 | 222 | return state['selectedTab'].value; 223 | } 224 | 225 | /** 226 | * Adds a link to the glue shared model. 227 | * 228 | * @param linkName - The link name. 229 | * @param link - The component or advanced link. 230 | */ 231 | setLink(linkName: string, link: ILink): void { 232 | this._links.set(linkName, link as IDict); 233 | } 234 | 235 | /** 236 | * remove a link from the glue session model. 237 | * 238 | * @param linkName - the link name. 239 | */ 240 | removeLink(linkName: string): void { 241 | this._links.delete(linkName); 242 | } 243 | 244 | private _contentsObserver = (event: Y.YMapEvent): void => { 245 | const contents = this.contents; 246 | this._changed.emit(contents); 247 | this._contentsChanged.emit(contents); 248 | }; 249 | private _attributesObserver = (event: Y.YMapEvent): void => { 250 | this._attributesChanged.emit({}); 251 | }; 252 | 253 | private _datasetObserver = (event: Y.YMapEvent): void => { 254 | this._datasetChanged.emit({}); 255 | }; 256 | 257 | private _linksObserver = (event: Y.YMapEvent): void => { 258 | this._linksChanged.emit({}); 259 | }; 260 | 261 | private _tabsObserver = (events: Y.YEvent[]): void => { 262 | events.forEach(event => { 263 | this._tabs.forEach((tab, name) => { 264 | if (event.target === tab) { 265 | this._tabChanged.emit({ tab: name, changes: event.changes }); 266 | return; 267 | } 268 | }); 269 | }); 270 | 271 | const tabsEvent = events.find(event => event.target === this._tabs) as 272 | | Y.YMapEvent 273 | | undefined; 274 | if (!tabsEvent) { 275 | return; 276 | } 277 | this._tabsChanged.emit({}); 278 | }; 279 | 280 | private _contents: Y.Map; 281 | private _attributes: Y.Map; 282 | private _dataset: Y.Map; 283 | private _links: Y.Map; 284 | private _tabs: Y.Map>; 285 | 286 | private _contentsChanged = new Signal(this); 287 | private _attributesChanged = new Signal(this); 288 | private _datasetChanged = new Signal(this); 289 | private _linksChanged = new Signal(this); 290 | private _tabChanged = new Signal(this); 291 | private _tabsChanged = new Signal(this); 292 | private _localStateChanged = new Signal< 293 | IGlueSessionSharedModel, 294 | { keys: string[] } 295 | >(this); 296 | } 297 | -------------------------------------------------------------------------------- /src/viewPanel/sessionWidget.ts: -------------------------------------------------------------------------------- 1 | import { Dialog, InputDialog, showDialog } from '@jupyterlab/apputils'; 2 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 3 | import { INotebookTracker } from '@jupyterlab/notebook'; 4 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 5 | import { IKernelConnection } from '@jupyterlab/services/lib/kernel/kernel'; 6 | import { CommandRegistry } from '@lumino/commands'; 7 | import { PromiseDelegate } from '@lumino/coreutils'; 8 | import { Message } from '@lumino/messaging'; 9 | import { BoxPanel, TabBar, Widget } from '@lumino/widgets'; 10 | import { IJupyterYWidgetManager } from 'yjs-widgets'; 11 | 12 | import { CommandIDs } from '../commands'; 13 | import { HTabPanel } from '../common/tabPanel'; 14 | import { GlueSessionModel } from '../document/docModel'; 15 | import { LinkEditor } from '../linkPanel/linkEditor'; 16 | import { logKernelError, mockNotebook } from '../tools'; 17 | import { DATASET_MIME, IDict, IGlueSessionSharedModel } from '../types'; 18 | import { TabView } from './tabView'; 19 | 20 | export class SessionWidget extends BoxPanel { 21 | constructor(options: SessionWidget.IOptions) { 22 | super({ direction: 'top-to-bottom' }); 23 | this.addClass('grid-panel'); 24 | this.addClass('glue-Session-panel'); 25 | 26 | this._spinner = document.createElement('div'); 27 | this._spinner.classList.add('glue-Spinner'); 28 | const spinnerContent = document.createElement('div'); 29 | spinnerContent.classList.add('glue-SpinnerContent'); 30 | this._spinner.appendChild(spinnerContent); 31 | this.node.appendChild(this._spinner); 32 | 33 | this._model = options.model; 34 | this._rendermime = options.rendermime.clone(); 35 | this._notebookTracker = options.notebookTracker; 36 | this._context = options.context; 37 | this._commands = options.commands; 38 | this._yWidgetManager = options.yWidgetManager; 39 | 40 | const tabBarClassList = ['glue-Session-tabBar']; 41 | this._tabPanel = new HTabPanel({ 42 | tabBarPosition: 'bottom', 43 | tabBarClassList, 44 | tabBarOption: { 45 | addButtonEnabled: true 46 | } 47 | }); 48 | this._tabPanel.topBar.addRequested.connect(() => { 49 | this._model.addTab(); 50 | }); 51 | this._tabPanel.topBar.tabCloseRequested.connect(async (tab, arg) => { 52 | const confirm = await showDialog({ 53 | title: 'Delete Tab', 54 | body: 'Are you sure you want to delete this tab?', 55 | buttons: [Dialog.cancelButton(), Dialog.okButton({ label: 'Delete' })] 56 | }); 57 | if (confirm.button.accept) { 58 | arg.title.owner.close(); 59 | this._tabPanel.topBar.removeTabAt(arg.index); 60 | this._model.removeTab(arg.title.label); 61 | } 62 | }); 63 | if (this._model) { 64 | this._linkWidget = new LinkEditor({ sharedModel: this._model }); 65 | this._tabPanel.addTab(this._linkWidget, 0); 66 | } 67 | 68 | this.addWidget(this._tabPanel); 69 | BoxPanel.setStretch(this._tabPanel, 1); 70 | 71 | this._model.tabsChanged.connect(this._onTabsChanged, this); 72 | 73 | this._tabPanel.topBar.currentChanged.connect( 74 | this._onFocusedTabChanged, 75 | this 76 | ); 77 | 78 | this._kernel = undefined; 79 | this._startKernel(); 80 | } 81 | 82 | get rendermime(): IRenderMimeRegistry { 83 | return this._rendermime; 84 | } 85 | 86 | get kernel(): IKernelConnection | undefined { 87 | return this._kernel!; 88 | } 89 | 90 | protected onAfterAttach(msg: Message): void { 91 | super.onAfterAttach(msg); 92 | 93 | this.node.addEventListener('dragover', this._ondragover.bind(this)); 94 | this.node.addEventListener('drop', this._ondrop.bind(this)); 95 | } 96 | 97 | protected onBeforeDetach(msg: Message): void { 98 | this.node.removeEventListener('dragover', this._ondragover.bind(this)); 99 | this.node.removeEventListener('drop', this._ondrop.bind(this)); 100 | 101 | super.onBeforeDetach(msg); 102 | } 103 | 104 | private _ondragover(event: DragEvent) { 105 | event.preventDefault(); 106 | } 107 | 108 | private async _ondrop(event: DragEvent) { 109 | const target = event.target as HTMLElement; 110 | const viewer = target.closest('.grid-stack-item.glue-item'); 111 | // No-op if the target is a viewer, it will be managed by the viewer itself. 112 | if (viewer) { 113 | return; 114 | } 115 | 116 | const datasetId = event.dataTransfer?.getData(DATASET_MIME); 117 | const items: IDict = { 118 | Histogram: CommandIDs.new1DHistogram, 119 | '1D Profile': CommandIDs.new1DProfile, 120 | '2D Scatter': CommandIDs.new2DScatter, 121 | '3D Scatter': CommandIDs.new3DScatter, 122 | '2D Image': CommandIDs.new2DImage, 123 | Table: CommandIDs.newTable 124 | }; 125 | 126 | const res = await InputDialog.getItem({ 127 | title: 'Viewer Type', 128 | items: Object.keys(items) 129 | }); 130 | 131 | if (res.button.accept && res.value) { 132 | this._commands.execute(items[res.value], { 133 | position: [event.offsetX, event.offsetY], 134 | dataset: datasetId 135 | }); 136 | } 137 | } 138 | 139 | private async _startKernel() { 140 | const panel = mockNotebook(this._rendermime, this._context); 141 | await this._context?.sessionContext.initialize(); 142 | await this._context?.sessionContext.ready; 143 | // TODO: Make ipywidgets independent from a Notebook context 144 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 145 | // @ts-ignore 146 | this._notebookTracker.widgetAdded.emit(panel); 147 | const kernel = this._context?.sessionContext.session?.kernel; 148 | 149 | if (!kernel) { 150 | void showDialog({ 151 | title: 'Error', 152 | body: 'Failed to start the kernel for the Glue session', 153 | buttons: [Dialog.cancelButton()] 154 | }); 155 | return; 156 | } 157 | this._yWidgetManager.registerKernel(kernel); 158 | 159 | this._kernel = kernel; 160 | 161 | // TODO Handle loading errors and report in the UI? 162 | const code = ` 163 | from glue_jupyterlab.glue_session import SharedGlueSession 164 | GLUE_SESSION = SharedGlueSession("${this._context.localPath}") 165 | `; 166 | const future = kernel.requestExecute({ code }, false); 167 | future.onReply = logKernelError; 168 | await future.done; 169 | this._pythonSessionCreated.resolve(); 170 | 171 | this._spinner.style.display = 'none'; 172 | } 173 | 174 | private async _onTabsChanged() { 175 | await this._pythonSessionCreated.promise; 176 | let newTabIndex: number | undefined = undefined; 177 | const currentIndex = this._tabPanel.topBar.currentIndex; 178 | const tabNames = this._model.getTabNames(); 179 | Object.keys(this._tabViews).forEach(k => { 180 | if (!tabNames.includes(k)) { 181 | this._tabViews[k].dispose(); 182 | delete this._tabViews[k]; 183 | } 184 | }); 185 | tabNames.forEach((tabName, idx) => { 186 | // Tab already exists, we don't do anything 187 | if (tabName in this._tabViews) { 188 | return; 189 | } 190 | newTabIndex = idx; 191 | // Tab does not exist, we create it 192 | const tabWidget = (this._tabViews[tabName] = new TabView({ 193 | tabName, 194 | model: this._model, 195 | rendermime: this._rendermime, 196 | context: this._context, 197 | notebookTracker: this._notebookTracker, 198 | commands: this._commands 199 | })); 200 | tabWidget.title.closable = true; 201 | this._tabPanel.addTab(tabWidget, idx + 1); 202 | }); 203 | 204 | // TODO Remove leftover tabs 205 | // for (const tabName in Object.keys(this._tabViews)) { 206 | // if (!(tabName in tabNames)) { 207 | // todo 208 | // } 209 | // } 210 | if (newTabIndex !== undefined) { 211 | if (currentIndex === 0) { 212 | newTabIndex = 0; 213 | } 214 | this._tabPanel.activateTab(newTabIndex + 1); 215 | } 216 | } 217 | 218 | private _onFocusedTabChanged( 219 | sender: TabBar, 220 | args: TabBar.ICurrentChangedArgs 221 | ) { 222 | this._model.setSelectedTab(args.currentIndex); 223 | this._commands.execute(CommandIDs.closeControlPanel); 224 | } 225 | 226 | private _kernel: IKernelConnection | undefined; 227 | private _spinner: HTMLDivElement; 228 | private _tabViews: { [k: string]: TabView } = {}; 229 | private _pythonSessionCreated: PromiseDelegate = 230 | new PromiseDelegate(); 231 | private _tabPanel: HTabPanel; 232 | private _linkWidget: LinkEditor | undefined = undefined; 233 | private _model: IGlueSessionSharedModel; 234 | private _rendermime: IRenderMimeRegistry; 235 | private _context: DocumentRegistry.IContext; 236 | private _notebookTracker: INotebookTracker; 237 | private _commands: CommandRegistry; 238 | private _yWidgetManager: IJupyterYWidgetManager; 239 | } 240 | 241 | export namespace SessionWidget { 242 | export interface IOptions { 243 | model: IGlueSessionSharedModel; 244 | rendermime: IRenderMimeRegistry; 245 | context: DocumentRegistry.IContext; 246 | notebookTracker: INotebookTracker; 247 | commands: CommandRegistry; 248 | yWidgetManager: IJupyterYWidgetManager; 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/leftPanel/plugin.ts: -------------------------------------------------------------------------------- 1 | import { ControlPanelModel } from './model'; 2 | import { ControlPanelWidget } from './widget'; 3 | import { glueIcon, logKernelError } from '../tools'; 4 | import { 5 | ILayoutRestorer, 6 | JupyterFrontEnd, 7 | JupyterFrontEndPlugin 8 | } from '@jupyterlab/application'; 9 | import { IGlueSessionTracker } from '../token'; 10 | import { CommandIDs, INewViewerArgs } from '../commands'; 11 | import { UUID } from '@lumino/coreutils'; 12 | import { CommandRegistry } from '@lumino/commands'; 13 | import { IRenderMimeRegistry } from '@jupyterlab/rendermime'; 14 | import { GridStackItem } from '../viewPanel/gridStackItem'; 15 | import { DocumentRegistry } from '@jupyterlab/docregistry'; 16 | import { DocumentManager } from '@jupyterlab/docmanager'; 17 | 18 | const NAME_SPACE = 'glue-jupyterlab'; 19 | 20 | function addCommands( 21 | commands: CommandRegistry, 22 | controlModel: ControlPanelModel 23 | ): void { 24 | commands.addCommand(CommandIDs.new1DHistogram, { 25 | label: '1D Histogram', 26 | iconClass: 'fa fa-chart-bar', 27 | execute: (args?: INewViewerArgs) => { 28 | if (!controlModel.sharedModel) { 29 | return; 30 | } 31 | 32 | const tabs = Object.keys(controlModel.sharedModel.tabs); 33 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 34 | const layer = args?.dataset || controlModel.selectedDataset; 35 | 36 | if (focusedTab === 0) { 37 | return; 38 | } 39 | 40 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 41 | _type: 'glue.viewers.histogram.qt.data_viewer.HistogramViewer', 42 | pos: args?.position || [0, 0], 43 | session: 'Session', 44 | size: args?.size || [600, 400], 45 | state: { 46 | values: { 47 | layer 48 | } 49 | } 50 | }); 51 | } 52 | }); 53 | 54 | commands.addCommand(CommandIDs.new1DProfile, { 55 | label: '1D Profile', 56 | iconClass: 'fa fa-chart-line', 57 | execute: (args?: INewViewerArgs) => { 58 | if (!controlModel.sharedModel) { 59 | return; 60 | } 61 | 62 | const tabs = Object.keys(controlModel.sharedModel.tabs); 63 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 64 | const layer = args?.dataset || controlModel.selectedDataset; 65 | 66 | if (focusedTab === 0) { 67 | return; 68 | } 69 | 70 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 71 | _type: 'glue.viewers.profile.state.ProfileLayerState', 72 | pos: args?.position || [0, 0], 73 | session: 'Session', 74 | size: args?.size || [600, 400], 75 | state: { 76 | values: { 77 | layer 78 | } 79 | } 80 | }); 81 | } 82 | }); 83 | 84 | commands.addCommand(CommandIDs.new2DScatter, { 85 | label: '2D Scatter', 86 | iconClass: 'fa fa-chart-line', 87 | execute: (args?: INewViewerArgs) => { 88 | if (!controlModel.sharedModel) { 89 | return; 90 | } 91 | 92 | const tabs = Object.keys(controlModel.sharedModel.tabs); 93 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 94 | const layer = args?.dataset || controlModel.selectedDataset; 95 | 96 | if (focusedTab === 0) { 97 | return; 98 | } 99 | 100 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 101 | _type: 'glue.viewers.scatter.qt.data_viewer.ScatterViewer', 102 | pos: args?.position || [0, 0], 103 | session: 'Session', 104 | size: args?.size || [600, 400], 105 | state: { 106 | values: { 107 | layer 108 | } 109 | } 110 | }); 111 | } 112 | }); 113 | 114 | commands.addCommand(CommandIDs.new3DScatter, { 115 | label: '3D Scatter', 116 | iconClass: 'fa fa-chart-line', 117 | execute: (args?: INewViewerArgs) => { 118 | if (!controlModel.sharedModel) { 119 | return; 120 | } 121 | 122 | const tabs = Object.keys(controlModel.sharedModel.tabs); 123 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 124 | const layer = args?.dataset || controlModel.selectedDataset; 125 | 126 | if (focusedTab === 0) { 127 | return; 128 | } 129 | 130 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 131 | _type: 'glue_vispy_viewers.scatter.scatter_viewer.VispyScatterViewer', 132 | pos: args?.position || [0, 0], 133 | session: 'Session', 134 | size: args?.size || [600, 400], 135 | state: { 136 | values: { 137 | layer 138 | } 139 | } 140 | }); 141 | } 142 | }); 143 | 144 | commands.addCommand(CommandIDs.new2DImage, { 145 | label: '2D Image', 146 | iconClass: 'fa fa-image', 147 | execute: (args?: INewViewerArgs) => { 148 | if (!controlModel.sharedModel) { 149 | return; 150 | } 151 | 152 | const tabs = Object.keys(controlModel.sharedModel.tabs); 153 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 154 | const layer = args?.dataset || controlModel.selectedDataset; 155 | 156 | if (focusedTab === 0) { 157 | return; 158 | } 159 | 160 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 161 | _type: 'glue.viewers.image.qt.data_viewer.ImageViewer', 162 | pos: args?.position || [0, 0], 163 | session: 'Session', 164 | size: args?.size || [600, 400], 165 | state: { 166 | values: { 167 | layer 168 | } 169 | } 170 | }); 171 | } 172 | }); 173 | 174 | commands.addCommand(CommandIDs.newTable, { 175 | label: 'Table', 176 | iconClass: 'fa fa-table', 177 | execute: (args?: INewViewerArgs) => { 178 | if (!controlModel.sharedModel) { 179 | return; 180 | } 181 | 182 | const tabs = Object.keys(controlModel.sharedModel.tabs); 183 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 184 | const layer = args?.dataset || controlModel.selectedDataset; 185 | 186 | if (focusedTab === 0) { 187 | return; 188 | } 189 | 190 | controlModel.sharedModel.setTabItem(tabs[focusedTab - 1], UUID.uuid4(), { 191 | _type: 'glue.viewers.table.qt.data_viewer.TableViewer', 192 | pos: args?.position || [0, 0], 193 | session: 'Session', 194 | size: args?.size || [600, 400], 195 | state: { 196 | values: { 197 | layer 198 | } 199 | } 200 | }); 201 | } 202 | }); 203 | 204 | commands.addCommand(CommandIDs.openControlPanel, { 205 | execute: (args?: { cellId?: string; gridItem?: GridStackItem }) => { 206 | if (!controlModel.sharedModel) { 207 | return; 208 | } 209 | 210 | const tabs = Object.keys(controlModel.sharedModel.tabs); 211 | const focusedTab = controlModel.sharedModel.getSelectedTab() || 1; 212 | 213 | if (focusedTab === 0 || !tabs[focusedTab - 1]) { 214 | return; 215 | } 216 | controlModel.displayConfig({ 217 | tabId: tabs[focusedTab - 1], 218 | cellId: args?.cellId, 219 | gridItem: args?.gridItem 220 | }); 221 | } 222 | }); 223 | 224 | commands.addCommand(CommandIDs.closeControlPanel, { 225 | execute: () => { 226 | if (!controlModel.sharedModel) { 227 | return; 228 | } 229 | controlModel.clearConfig(); 230 | } 231 | }); 232 | 233 | commands.addCommand(CommandIDs.addViewerLayer, { 234 | execute: async args => { 235 | const kernel = controlModel.currentSessionKernel(); 236 | if (kernel === undefined) { 237 | // TODO Show an error dialog 238 | return; 239 | } 240 | 241 | const code = ` 242 | GLUE_SESSION.add_viewer_layer("${args.tab}", "${args.viewer}", "${args.data}") 243 | `; 244 | 245 | const future = kernel.requestExecute({ code }, false); 246 | future.onReply = logKernelError; 247 | await future.done; 248 | } 249 | }); 250 | } 251 | 252 | export const controlPanel: JupyterFrontEndPlugin = { 253 | id: 'glue-jupyterlab:control-panel', 254 | autoStart: true, 255 | requires: [ILayoutRestorer, IGlueSessionTracker, IRenderMimeRegistry], 256 | activate: ( 257 | app: JupyterFrontEnd, 258 | restorer: ILayoutRestorer, 259 | tracker: IGlueSessionTracker, 260 | rendermime: IRenderMimeRegistry 261 | ) => { 262 | const { shell, commands } = app; 263 | 264 | const controlModel = new ControlPanelModel({ tracker }); 265 | 266 | const docRegistry = new DocumentRegistry(); 267 | const docManager = new DocumentManager({ 268 | registry: docRegistry, 269 | manager: app.serviceManager, 270 | opener 271 | }); 272 | 273 | const controlPanel = new ControlPanelWidget({ 274 | model: controlModel, 275 | tracker, 276 | commands, 277 | rendermime, 278 | manager: docManager 279 | }); 280 | 281 | controlPanel.id = 'glue-jupyterlab::controlPanel'; 282 | controlPanel.title.caption = 'glue-jupyterlab'; 283 | controlPanel.title.icon = glueIcon; 284 | 285 | if (restorer) { 286 | restorer.add(controlPanel, NAME_SPACE); 287 | } 288 | 289 | shell.add(controlPanel, 'left', { rank: 2000 }); 290 | 291 | addCommands(commands, controlModel); 292 | } 293 | }; 294 | -------------------------------------------------------------------------------- /src/linkPanel/widgets/summary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | LabIcon, 4 | ReactWidget, 5 | caretRightIcon, 6 | deleteIcon 7 | } from '@jupyterlab/ui-components'; 8 | import { 9 | AccordionLayout, 10 | AccordionPanel, 11 | Title, 12 | Widget 13 | } from '@lumino/widgets'; 14 | import * as React from 'react'; 15 | 16 | import { LinkEditorWidget } from '../linkEditorWidget'; 17 | import { ILink } from '../types'; 18 | 19 | /** 20 | * The widget displaying the links for the selected dataset. 21 | */ 22 | export class Summary extends LinkEditorWidget { 23 | /** 24 | * The widget constructor. 25 | */ 26 | constructor(options: LinkEditorWidget.IOptions) { 27 | super(options); 28 | 29 | this.addClass('glue-LinkEditor-summary'); 30 | 31 | const accordionLayout = Private.createLayout({}); 32 | this._links = new AccordionPanel({ layout: accordionLayout }); 33 | this._links.addClass('glue-LinkEditor-identity'); 34 | 35 | this.header = ReactWidget.create(); 36 | 37 | this.content = this._links; 38 | 39 | this._linkEditorModel.currentDatasetsChanged.connect( 40 | this.onDatasetsChange, 41 | this 42 | ); 43 | this._linkEditorModel.linksChanged.connect(this.linksChanged, this); 44 | 45 | if (this._linkEditorModel.currentDatasets) { 46 | this.updateLinks(); 47 | } 48 | } 49 | 50 | /** 51 | * Triggered when links has changed. 52 | */ 53 | linksChanged(): void { 54 | this.updateLinks(); 55 | } 56 | /** 57 | * Callback when the selected datasets change. 58 | */ 59 | onDatasetsChange(): void { 60 | this.updateLinks(); 61 | } 62 | 63 | /** 64 | * Updates the list of links when the selected dataset changes. 65 | */ 66 | updateLinks(): void { 67 | const datasetLinks = new Map(); 68 | 69 | // Remove all the existing widgets. 70 | while (this._links.widgets.length) { 71 | this._links.widgets[0].dispose(); 72 | } 73 | 74 | this._linkEditorModel.identityLinks.forEach((link, linkName) => { 75 | const sortedDatasets = [link.data1, link.data2].sort(); 76 | const datasetsStr = JSON.stringify(sortedDatasets); 77 | const identityLink: Private.ISummaryLink = { 78 | type: 'identity', 79 | name: linkName, 80 | value: link, 81 | revert: link.data2 === sortedDatasets[0] 82 | }; 83 | if (datasetLinks.get(datasetsStr)) { 84 | datasetLinks.get(datasetsStr)?.push(identityLink); 85 | } else { 86 | datasetLinks.set(datasetsStr, [identityLink]); 87 | } 88 | }); 89 | 90 | // this._linkEditorModel.advancedLinks.forEach((link, linkName) => { 91 | // const sortedDatasets = [link.data1, link.data2].sort(); 92 | // const datasetsStr = JSON.stringify(sortedDatasets); 93 | // const advancedLink: Private.ISummaryLink = { 94 | // type: 'advanced', 95 | // name: linkName, 96 | // value: link 97 | // }; 98 | // if (datasetLinks.get(datasetsStr)) { 99 | // datasetLinks.get(datasetsStr)?.push(advancedLink); 100 | // } else { 101 | // datasetLinks.set(datasetsStr, [advancedLink]); 102 | // } 103 | // }); 104 | 105 | datasetLinks.forEach((links, datasets) => { 106 | const widget = ReactWidget.create( 107 | Private.datasetLinks(links, this.onDeleteLink) 108 | ); 109 | 110 | widget.title.dataset = { 111 | datasets: datasets, 112 | count: links.length.toString() 113 | }; 114 | 115 | this._links.addWidget(widget); 116 | }); 117 | } 118 | 119 | /** 120 | * Called when clicking on the delete icon panel. 121 | */ 122 | onDeleteLink = (linkName: string): void => { 123 | this._sharedModel.removeLink(linkName); 124 | }; 125 | 126 | private _links: AccordionPanel; 127 | } 128 | 129 | export namespace Private { 130 | /** 131 | * Custom renderer for the SidePanel 132 | */ 133 | export class Renderer extends AccordionPanel.Renderer { 134 | /** 135 | * Render the collapse indicator for a section title. 136 | * 137 | * @param data - The data to use for rendering the section title. 138 | * 139 | * @returns A element representing the collapse indicator. 140 | */ 141 | createCollapseIcon(data: Title): HTMLElement { 142 | const iconDiv = document.createElement('div'); 143 | iconDiv.classList.add('glue-LinkEditor-accordionCollapser'); 144 | caretRightIcon.element({ 145 | container: iconDiv 146 | }); 147 | return iconDiv; 148 | } 149 | 150 | /** 151 | * Render the element for a section title. 152 | * 153 | * @param data - The data to use for rendering the section title. 154 | * 155 | * @returns A element representing the section title. 156 | */ 157 | createSectionTitle(data: Title): HTMLElement { 158 | const datasets = JSON.parse(data.dataset.datasets) as string[]; 159 | const handle = document.createElement('div'); 160 | handle.classList.add('glue-LinkEditor-accordionTitle'); 161 | datasets.forEach(dataset => { 162 | const datasetColumn = document.createElement('div'); 163 | datasetColumn.innerText = dataset; 164 | handle.append(datasetColumn); 165 | }); 166 | const count = document.createElement('div'); 167 | count.innerHTML = data.dataset.count; 168 | handle.append(count); 169 | handle.append(this.createCollapseIcon(data)); 170 | return handle; 171 | } 172 | } 173 | 174 | export const defaultRenderer = new Renderer(); 175 | 176 | /** 177 | * Create an accordion layout for accordion panel with toolbar in the title. 178 | * 179 | * @param options Panel options 180 | * @returns Panel layout 181 | * 182 | * #### Note 183 | * 184 | * Default titleSpace is 29 px (default var(--jp-private-toolbar-height) - but not styled) 185 | */ 186 | export function createLayout( 187 | options: AccordionPanel.IOptions 188 | ): AccordionLayout { 189 | return ( 190 | options.layout || 191 | new AccordionLayout({ 192 | renderer: options.renderer || defaultRenderer, 193 | orientation: options.orientation, 194 | alignment: options.alignment, 195 | spacing: options.spacing, 196 | titleSpace: options.titleSpace ?? 29 197 | }) 198 | ); 199 | } 200 | 201 | /** 202 | * Create the header content. 203 | * 204 | * @returns - The React header. 205 | */ 206 | export function header(): JSX.Element { 207 | return ( 208 |
    209 |
    Linked datasets
    210 |
    #
    211 |
    212 | ); 213 | } 214 | 215 | /** 216 | * A React widget with the links. 217 | * 218 | * @param links - List of links to display. 219 | * @param clickCallback - Function to call when clicking on the delete icon. 220 | */ 221 | export function datasetLinks( 222 | links: Private.ISummaryLink[], 223 | clickCallback: (linkName: string) => void 224 | ): JSX.Element { 225 | const identity = links.filter(link => link.type === 'identity'); 226 | // const advanced = links.filter(link => link.type === 'advanced'); 227 | return ( 228 |
    229 | {identity.map(link => ( 230 |
    231 |
    232 | {link.revert 233 | ? link.value.cids2_labels[0] 234 | : link.value.cids1_labels[0]} 235 |
    236 |
    237 | {link.revert 238 | ? link.value.cids1_labels[0] 239 | : link.value.cids2_labels[0]} 240 |
    241 |
    242 | 253 |
    254 |
    255 | ))} 256 | {/* {advanced.map(link => ( 257 |
    258 |
    259 | {link.name.split('.')[link.name.split('.').length - 1]} 260 |
    261 | 262 | 263 | 269 | 275 | 288 | 289 |
    264 |
    {link.value.data1}
    265 |
    266 | {link.value.cids1_labels.join(', ')} 267 |
    268 |
    270 |
    {link.value.data2}
    271 |
    272 | {link.value.cids2_labels.join(', ')} 273 |
    274 |
    276 | 287 |
    290 |
    291 | ))} */} 292 |
    293 | ); 294 | } 295 | 296 | /** 297 | * The summary link description. 298 | */ 299 | export interface ISummaryLink { 300 | type: 'identity' | 'advanced'; 301 | name: string; 302 | value: ILink; 303 | revert?: boolean; 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /glue_jupyterlab/glue_ydoc.py: -------------------------------------------------------------------------------- 1 | import json 2 | from copy import deepcopy 3 | from typing import Dict, List, Any, Callable, Optional 4 | from functools import partial 5 | from jupyter_ydoc.ybasedoc import YBaseDoc 6 | import y_py as Y 7 | 8 | COMPONENT_LINK_TYPE = "glue.core.component_link.ComponentLink" 9 | IDENTITY_LINK_FUNCTION = "glue.core.link_helpers.identity" 10 | 11 | 12 | class YGlue(YBaseDoc): 13 | def __init__(self, *args, **kwargs): 14 | super().__init__(*args, **kwargs) 15 | self._yprivate_messages = self._ydoc.get_map("private_messages") 16 | self._ysource = self._ydoc.get_text("source") 17 | self._ycontents = self._ydoc.get_map("contents") 18 | self._yattributes = self._ydoc.get_map("attributes") 19 | self._ydataset = self._ydoc.get_map("dataset") 20 | self._ylinks = self._ydoc.get_map("links") 21 | self._ytabs = self._ydoc.get_map("tabs") 22 | 23 | self._data_collection_name = "" 24 | 25 | @property 26 | def version(self) -> str: 27 | """ 28 | Returns the version of the document. 29 | :return: Document's version. 30 | :rtype: str 31 | """ 32 | return "1.0.0" 33 | 34 | @property 35 | def contents(self) -> Dict: 36 | return json.loads(self._ycontents.to_json()) 37 | 38 | @property 39 | def attributes(self) -> Dict: 40 | return json.loads(self._yattributes.to_json()) 41 | 42 | @property 43 | def dataset(self) -> Dict: 44 | return json.loads(self._ydataset.to_json()) 45 | 46 | @property 47 | def links(self) -> Dict: 48 | return json.loads(self._ylinks.to_json()) 49 | 50 | @property 51 | def tabs(self) -> Dict: 52 | return json.loads(self._ytabs.to_json()) 53 | 54 | def get(self) -> str: 55 | """ 56 | Returns the content of the document. 57 | :return: Document's content. 58 | :rtype: Any 59 | """ 60 | contents = json.loads(self._ycontents.to_json()) 61 | json.loads(self._yattributes.to_json()) 62 | dataset = json.loads(self._ydataset.to_json()) 63 | links = json.loads(self._ylinks.to_json()) 64 | tabs = json.loads(self._ytabs.to_json()) 65 | contents.setdefault("__main__", {}) 66 | 67 | tab_names = sorted(list(tabs.keys())) 68 | contents["__main__"]["tab_names"] = tab_names 69 | 70 | contents["__main__"].setdefault("viewers", []) 71 | 72 | contents["__main__"]["viewers"] = [] 73 | for tab in tab_names: 74 | viewers = tabs.get(tab, {}) 75 | viewer_names = sorted(list(viewers.keys())) 76 | 77 | contents["__main__"]["viewers"].append(viewer_names) 78 | for viewer in viewer_names: 79 | contents[viewer] = viewers[viewer] 80 | 81 | if self._data_collection_name: 82 | data_names = sorted(list(dataset.keys())) 83 | link_names = sorted(list(links.keys())) 84 | 85 | contents[self._data_collection_name]["data"] = data_names 86 | contents[self._data_collection_name]["links"] = link_names 87 | 88 | for data_name in data_names: 89 | contents[data_name] = dataset[data_name] 90 | 91 | self.add_links_to_contents(links, contents) 92 | return json.dumps(contents, indent=2, sort_keys=True) 93 | 94 | def set(self, value: str) -> None: 95 | """ 96 | Sets the content of the document. 97 | :param value: The content of the document. 98 | :type value: Any 99 | """ 100 | contents = json.loads(value) 101 | 102 | tab_names: List[str] = contents.get("__main__", {}).get("tab_names", []) 103 | viewers = contents.get("__main__", {}).get("viewers", []) 104 | tabs: Dict[str, Y.YMap] = {} 105 | for idx, tab in enumerate(tab_names): 106 | items: Dict[str, Y.YMap] = {} 107 | for viewer in viewers[idx]: 108 | items[viewer] = contents.get(viewer, {}) 109 | tabs[tab] = Y.YMap(items) 110 | 111 | self._data_collection_name: str = contents.get("__main__", {}).get("data", "") 112 | data_names: List[str] = [] 113 | link_names: List[str] = [] 114 | if self._data_collection_name: 115 | data_names = contents.get(self._data_collection_name, {}).get("data", []) 116 | link_names = contents.get(self._data_collection_name, {}).get("links", []) 117 | 118 | dataset: Dict[str, Dict] = {} 119 | attributes: Dict[str, Dict] = {} 120 | for data_name in data_names: 121 | dataset[data_name] = contents.get(data_name, {}) 122 | for attribute in contents.get(data_name, {}).get("primary_owner", []): 123 | attributes[attribute] = contents.get(attribute, {}) 124 | 125 | links = self.extract_links_from_file(link_names, contents, dataset, attributes) 126 | 127 | with self._ydoc.begin_transaction() as t: 128 | self._ycontents.update(t, contents.items()) 129 | self._yattributes.update(t, attributes.items()) 130 | self._ydataset.update(t, dataset.items()) 131 | self._ylinks.update(t, links.items()) 132 | self._ytabs.update(t, tabs.items()) 133 | 134 | def observe(self, callback: Callable[[str, Any], None]): 135 | self.unobserve() 136 | self._subscriptions[self._ystate] = self._ystate.observe( 137 | partial(callback, "state") 138 | ) 139 | self._subscriptions[self._ysource] = self._ysource.observe( 140 | partial(callback, "source") 141 | ) 142 | self._subscriptions[self._ycontents] = self._ycontents.observe( 143 | partial(callback, "contents") 144 | ) 145 | self._subscriptions[self._yattributes] = self._ycontents.observe( 146 | partial(callback, "attributes") 147 | ) 148 | self._subscriptions[self._ydataset] = self._ydataset.observe( 149 | partial(callback, "dataset") 150 | ) 151 | self._subscriptions[self._ylinks] = self._ylinks.observe( 152 | partial(callback, "links") 153 | ) 154 | self._subscriptions[self._ytabs] = self._ytabs.observe_deep( 155 | partial(callback, "tabs") 156 | ) 157 | self._subscriptions[self._yprivate_messages] = ( 158 | self._yprivate_messages.observe_deep(partial(callback, "private_messages")) 159 | ) 160 | 161 | def get_tab_names(self) -> List[str]: 162 | return list(self._ytabs.keys()) 163 | 164 | def get_tab_data(self, tab_name: str) -> Optional[Dict]: 165 | tab = self._ytabs.get(tab_name) 166 | if tab is not None: 167 | return json.loads(tab.to_json()) 168 | 169 | def remove_tab_viewer(self, tab_name: str, viewer_id: str) -> None: 170 | tab = self._ytabs.get(tab_name) 171 | if tab is not None: 172 | with self._ydoc.begin_transaction() as t: 173 | tab.pop(t, viewer_id) 174 | 175 | def extract_links_from_file( 176 | self, 177 | link_names: List[str], 178 | contents: Dict, 179 | dataset: Dict[str, Dict], 180 | attributes: Dict[str, Dict], 181 | ) -> Dict[str, Dict]: 182 | links: Dict[str, Dict] = {} 183 | for link_name in link_names: 184 | link: Dict = deepcopy(contents.get(link_name, {})) 185 | uniform_link = {"_type": link.pop("_type")} 186 | if uniform_link["_type"] == COMPONENT_LINK_TYPE: 187 | uniform_link["data1"] = next( 188 | ( 189 | k 190 | for k, v in dataset.items() 191 | if link["frm"][0] in v["primary_owner"] 192 | ), 193 | None, 194 | ) 195 | uniform_link["data2"] = next( 196 | ( 197 | k 198 | for k, v in dataset.items() 199 | if link["to"][0] in v["primary_owner"] 200 | ), 201 | None, 202 | ) 203 | uniform_link["cids1"] = link.pop("frm") 204 | uniform_link["cids2"] = link.pop("to") 205 | for i in [1, 2]: 206 | uniform_link[f"cids{i}_labels"] = [ 207 | attributes[attribute]["label"] 208 | for attribute in uniform_link[f"cids{i}"] 209 | ] 210 | else: 211 | for i in [1, 2]: 212 | listName = link.pop(f"cids{i}") 213 | uniform_link[f"cids{i}"] = contents.get(listName, {}).get( 214 | "contents" 215 | ) 216 | uniform_link[f"cids{i}_labels"] = [ 217 | attributes[attribute]["label"] 218 | for attribute in uniform_link[f"cids{i}"] 219 | ] 220 | 221 | uniform_link.update(link) 222 | links[link_name] = uniform_link 223 | return links 224 | 225 | def add_links_to_contents(self, links: Dict[str, Dict], contents: Dict): 226 | # Delete former links and attributes lists. 227 | for link_names in contents.get(self._data_collection_name, {}).get("links", []): 228 | link = contents.pop(link_names, {}) 229 | 230 | # Delete the list objects containing the attributes of advanced links. 231 | if link.get("_type", "") != COMPONENT_LINK_TYPE: 232 | contents.pop(link.get("cids1", None), None) 233 | contents.pop(link.get("cids2", None), None) 234 | contents[self._data_collection_name]["links"] = [] 235 | 236 | # Create the new links and attribute lists if necessary. 237 | lists_count = -1 238 | for link_name, link in links.items(): 239 | if link["_type"] == COMPONENT_LINK_TYPE: 240 | link["frm"] = link.pop("cids1", []) 241 | link["to"] = link.pop("cids2", []) 242 | for i in [1, 2]: 243 | link.pop(f"cids{i}_labels", None) 244 | link.pop(f"data{i}", None) 245 | else: 246 | for i in [1, 2]: 247 | list_name = f"list{'' if lists_count < 0 else f'_{lists_count}'}" 248 | lists_count += 1 249 | link.pop(f"cids{i}_labels", None) 250 | attr_list = { 251 | "_type": "builtins.list", 252 | "contents": link.pop(f"cids{i}", []), 253 | } 254 | contents[list_name] = attr_list 255 | link[f"cids{i}"] = list_name 256 | contents[link_name] = link 257 | contents[self._data_collection_name]["links"].append(link_name) 258 | contents[self._data_collection_name]["links"].sort() 259 | --------------------------------------------------------------------------------