diff --git a/.github/labeler.yml b/.github/labeler.yml index 460ef50cda2e..31f1ae2c2cd8 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -443,3 +443,11 @@ pkg:vega: - any-glob-to-any-file: - packages/vega5-extension/**/* - packages/vega5-extension/* + +pkg:workspaces: +- changed-files: + - any-glob-to-any-file: + - packages/workspaces/**/* + - packages/workspaces/* + - packages/workspaces-extension/**/* + - packages/workspaces-extension/* diff --git a/.github/workflows/linuxjs-tests.yml b/.github/workflows/linuxjs-tests.yml index 351309a64d68..1bbdb367fc6d 100644 --- a/.github/workflows/linuxjs-tests.yml +++ b/.github/workflows/linuxjs-tests.yml @@ -61,6 +61,7 @@ jobs: js-translation, js-ui-components, js-vega5-extension, + js-workspaces, ] fail-fast: false runs-on: ubuntu-22.04 diff --git a/.licenserc.yaml b/.licenserc.yaml index f686c878f827..7f1e81908828 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -14,6 +14,7 @@ header: - '**/*.svg' - '**/*.yml' - '**/*.yaml' + - '**/*.jupyterlab-workspace' - '**/build' - '**/lib' - '**/node_modules' diff --git a/buildutils/src/ensure-repo.ts b/buildutils/src/ensure-repo.ts index eb426797f363..63167cb09637 100644 --- a/buildutils/src/ensure-repo.ts +++ b/buildutils/src/ensure-repo.ts @@ -282,7 +282,8 @@ const SKIP_CSS: Dict = { '@jupyterlab/tooltip-extension', '@jupyterlab/translation-extension', '@jupyterlab/ui-components-extension', - '@jupyterlab/vega5-extension' + '@jupyterlab/vega5-extension', + '@jupyterlab/workspaces-extension' ], '@jupyterlab/notebook': ['@jupyterlab/application'], '@jupyterlab/rendermime-interfaces': ['@lumino/widgets'], diff --git a/dev_mode/package.json b/dev_mode/package.json index 77cb5f9e122f..f1cc1f33cf50 100644 --- a/dev_mode/package.json +++ b/dev_mode/package.json @@ -118,6 +118,8 @@ "@jupyterlab/ui-components": "~4.2.0-alpha.1", "@jupyterlab/ui-components-extension": "~4.2.0-alpha.1", "@jupyterlab/vega5-extension": "~4.2.0-alpha.1", + "@jupyterlab/workspaces": "~4.2.0-alpha.1", + "@jupyterlab/workspaces-extension": "~4.2.0-alpha.1", "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0", "@lumino/algorithm": "^2.0.0", @@ -189,7 +191,8 @@ "@jupyterlab/tooltip-extension": "~4.2.0-alpha.1", "@jupyterlab/translation-extension": "~4.2.0-alpha.1", "@jupyterlab/ui-components-extension": "~4.2.0-alpha.1", - "@jupyterlab/vega5-extension": "~4.2.0-alpha.1" + "@jupyterlab/vega5-extension": "~4.2.0-alpha.1", + "@jupyterlab/workspaces-extension": "~4.2.0-alpha.1" }, "devDependencies": { "@jupyterlab/builder": "^4.2.0-alpha.1", @@ -265,7 +268,8 @@ "@jupyterlab/toc-extension": "", "@jupyterlab/tooltip-extension": "", "@jupyterlab/translation-extension": "", - "@jupyterlab/ui-components-extension": "" + "@jupyterlab/ui-components-extension": "", + "@jupyterlab/workspaces-extension": "" }, "mimeExtensions": { "@jupyterlab/javascript-extension": "", @@ -321,6 +325,7 @@ "@jupyterlab/tooltip", "@jupyterlab/translation", "@jupyterlab/ui-components", + "@jupyterlab/workspaces", "@lezer/common", "@lezer/highlight", "@lumino/algorithm", @@ -441,6 +446,8 @@ "@jupyterlab/ui-components": "../packages/ui-components", "@jupyterlab/ui-components-extension": "../packages/ui-components-extension", "@jupyterlab/vega5-extension": "../packages/vega5-extension", + "@jupyterlab/workspaces": "../packages/workspaces", + "@jupyterlab/workspaces-extension": "../packages/workspaces-extension", "@jupyterlab/builder": "../builder", "@jupyterlab/buildutils": "../buildutils", "@jupyterlab/template": "../buildutils/template", diff --git a/dev_mode/style.js b/dev_mode/style.js index 0c4d58fe442a..cc37c2bd1e34 100644 --- a/dev_mode/style.js +++ b/dev_mode/style.js @@ -45,3 +45,4 @@ import '@jupyterlab/tooltip-extension/style/index.js'; import '@jupyterlab/translation-extension/style/index.js'; import '@jupyterlab/ui-components-extension/style/index.js'; import '@jupyterlab/vega5-extension/style/index.js'; +import '@jupyterlab/workspaces-extension/style/index.js'; diff --git a/docs/source/getting_started/starting.rst b/docs/source/getting_started/starting.rst index e0916953ccc7..8d630a31c0b7 100644 --- a/docs/source/getting_started/starting.rst +++ b/docs/source/getting_started/starting.rst @@ -27,7 +27,7 @@ Example: You may access JupyterLab by entering the notebook server's :ref:`URL ` into the browser. JupyterLab sessions always reside in a -:ref:`workspace `. The default workspace is the main ``/lab`` URL: +:ref:`workspace `. The default workspace is the main ``/lab`` URL: .. code-block:: none @@ -37,7 +37,7 @@ Like the classic notebook, JupyterLab provides a way for users to copy URLs that :ref:`open a specific notebook or file `. Additionally, JupyterLab URLs are an advanced part of the user interface that allows for -managing :ref:`workspaces `. To learn more about URLs in +managing :ref:`workspaces `. To learn more about URLs in Jupyterlab, visit :ref:`urls`. JupyterLab runs on top of Jupyter Server, so see the `security diff --git a/docs/source/user/directories.rst b/docs/source/user/directories.rst index f03cf78b67fb..cd50391762c2 100644 --- a/docs/source/user/directories.rst +++ b/docs/source/user/directories.rst @@ -300,6 +300,8 @@ the default values given by extensions, as well as the default overrides from the :ref:`overrides.json ` file in the application's settings directory. +.. _workspaces-directory: + JupyterLab Workspaces Directory ------------------------------- @@ -315,4 +317,4 @@ environments. The location can be modified using the ``JUPYTERLAB_WORKSPACES_DIR`` environment variable. These files can be imported and exported to create default "profiles", using -the :ref:`workspace command line tool `. +the :ref:`workspace command line tool `. diff --git a/docs/source/user/index.md b/docs/source/user/index.md index 509ffcb84d79..f0b5a373fb82 100644 --- a/docs/source/user/index.md +++ b/docs/source/user/index.md @@ -20,6 +20,7 @@ file_formats debugger toc extensions +workspaces jupyterhub export language diff --git a/docs/source/user/interface.rst b/docs/source/user/interface.rst index 2b3496cf55f8..a7e10ea9de9c 100644 --- a/docs/source/user/interface.rst +++ b/docs/source/user/interface.rst @@ -23,11 +23,12 @@ cell tools inspector `, and the :ref:`tabs list `. :class: jp-screenshot :alt: A screenshot of the default JupyterLab interface. The main work area is in the middle. There is also a left sidebar and a top menu bar. -JupyterLab sessions always reside in a :ref:`workspace `. +JupyterLab sessions always reside in a :ref:`workspace `. Workspaces contain the state of JupyterLab: the files that are currently open, the layout of the application areas and tabs, etc. Workspaces can be saved on the server with -:ref:`named workspace URLs `. +:ref:`named workspace URLs ` or +:ref:`using workspace commands ` available in the menu and sidebar. To learn more about URLs in Jupyterlab, visit :ref:`urls`. diff --git a/docs/source/user/urls.rst b/docs/source/user/urls.rst index 87a8a5ffaf74..97e8b3049b5e 100644 --- a/docs/source/user/urls.rst +++ b/docs/source/user/urls.rst @@ -70,10 +70,10 @@ using ``#cell-id=`` Fragment Identification Syntax. The ``cell-id`` fragment locator is not part of a formal Jupyter standard and subject to change. To leave feedback, please comment in the discussion: `nbformat#317 `_. -.. _url-workspaces-ui: +.. _url-workspaces: -Managing Workspaces (UI) ------------------------- +Managing Workspaces (URL) +------------------------- JupyterLab sessions always reside in a workspace. Workspaces contain the state of JupyterLab: the files that are currently open, the layout of the application @@ -168,86 +168,3 @@ To reset the contents of the default workspace and load a notebook: .. code-block:: none http(s):////lab/tree/path/to/notebook.ipynb?reset - -.. _url-workspaces-cli: - -Managing Workspaces (CLI) -------------------------- - -JupyterLab provides a command-line interface for workspace ``import`` and -``export``: - -.. code-block:: bash - - $ # Exports the default JupyterLab workspace - $ jupyter lab workspaces export - {"data": {}, "metadata": {"id": "/lab"}} - $ - $ # Exports the workspaces named `foo` - $ jupyter lab workspaces export foo - {"data": {}, "metadata": {"id": "/lab/workspaces/foo"}} - $ - $ # Exports the workspace named `foo` into a file called `file_name.json` - $ jupyter lab workspaces export foo > file_name.json - $ - $ # Imports the workspace file `file_name.json`. - $ jupyter lab workspaces import file_name.json - Saved workspace: /labworkspacesfoo-54d5.jupyterlab-workspace - -The ``export`` functionality is as friendly as possible: if a workspace does not -exist, it will still generate an empty workspace for export. - -The ``import`` functionality validates the structure of the workspace file and -validates the ``id`` field in the workspace ``metadata`` to make sure its URL is -compatible with either the ``workspaces_url`` configuration or the ``page_url`` -configuration to verify that it is a correctly named workspace or it is the -default workspace. - - -Workspace File Format ---------------------- - -A workspace file in a JSON file with a specific spec. - - -There are two top level keys requires, `data`, and `metadata`. - -The `metadata` must be a mapping with an `id` -key that has the same value as the ID of the workspace. This should also be the relative URL path to access the workspace, -like `/lab/workspaces/foo`. - -The `data` key maps to the initial state of the `IStateDB`. Many plugins look in the State DB for the configuration. -Also any plugins that register with the `ILayoutRestorer` will look up all keys in the State DB -that start with the `namespace` of their tracker before the first `:`. The values of these keys should have a `data` -attribute that maps. - -For example, if your workspace looks like this: - -.. code-block:: json - - { - "data": { - "application-mimedocuments:package.json:JSON": { - "data": { "path": "package.json", "factory": "JSON" } - } - } - } - -It will run the `docmanager:open` with the `{ "path": "package.json", "factory": "JSON" }` args, because the `application-mimedocuments` tracker is registered with the `docmanager:open` command, like this: - - -.. code-block:: typescript - - const namespace = 'application-mimedocuments'; - const tracker = new WidgetTracker({ namespace }); - void restorer.restore(tracker, { - command: 'docmanager:open', - args: widget => ({ - path: widget.context.path, - factory: Private.factoryNameProperty.get(widget) - }), - name: widget => - `${widget.context.path}:${Private.factoryNameProperty.get(widget)}` - }); - -Note the part of the data key after the first `:` (`package.json:JSON`) is dropped and is irrelevant. diff --git a/docs/source/user/workspaces.rst b/docs/source/user/workspaces.rst new file mode 100644 index 000000000000..62a57b18c8f1 --- /dev/null +++ b/docs/source/user/workspaces.rst @@ -0,0 +1,115 @@ +.. Copyright (c) Jupyter Development Team. +.. Distributed under the terms of the Modified BSD License. + +.. _workspaces: + +Workspaces +========== + +A JupyterLab Workspace defines the layout and state of the user interface such as the position of files, notebooks, sidebars, and open/closed state of the panels. + +Workspaces can be managed in three ways: + +- :ref:`via Graphical User Interface ` +- :ref:`via Command Line Interface ` +- :ref:`via URL schema and parameters ` + +.. _workspaces-gui: + +Managing Workspaces (GUI) +------------------------- + +A number of commands is available to manage workspaces from the main menu, sidebar, and command palette: + +- `create-new`, `clone`, `rename`, `reset`, and `delete` act on the workspaces stored by on the server in :ref:`the dedicated location `. +- `save`, `save as`, `import`, and `export` can load and store the workspace to/from the file system (contained within the Jupyter root directory); `save` will save the workspace to the most recently saved file. + +In the sidebar the current workspace is indicated with check mark (✓). A different workspace can be opened by clicking on the corresponding sidebar item. Opening context menu (right click) over the workspace item in the sidebar will present actions available for management of that workspace: + +.. image:: ../images/workspaces-sidebar.png + :align: center + :class: jp-screenshot + :alt: The context menu opened over workspaces sidebar + +.. _workspaces-cli: + +Managing Workspaces (CLI) +------------------------- + +JupyterLab provides a command-line interface for workspace ``import`` and +``export``: + +.. code-block:: bash + + $ # Exports the default JupyterLab workspace + $ jupyter lab workspaces export + {"data": {}, "metadata": {"id": "/lab"}} + $ + $ # Exports the workspaces named `foo` + $ jupyter lab workspaces export foo + {"data": {}, "metadata": {"id": "/lab/workspaces/foo"}} + $ + $ # Exports the workspace named `foo` into a file called `file_name.json` + $ jupyter lab workspaces export foo > file_name.json + $ + $ # Imports the workspace file `file_name.json`. + $ jupyter lab workspaces import file_name.json + Saved workspace: /labworkspacesfoo-54d5.jupyterlab-workspace + +The ``export`` functionality is as friendly as possible: if a workspace does not +exist, it will still generate an empty workspace for export. + +The ``import`` functionality validates the structure of the workspace file and +validates the ``id`` field in the workspace ``metadata`` to make sure its URL is +compatible with either the ``workspaces_url`` configuration or the ``page_url`` +configuration to verify that it is a correctly named workspace or it is the +default workspace. + + +Workspace File Format +--------------------- + +A workspace file in a JSON file with a specific spec. + + +There are two top level keys requires, `data`, and `metadata`. + +The `metadata` must be a mapping with an `id` +key that has the same value as the ID of the workspace. This should also be the relative URL path to access the workspace, +like `/lab/workspaces/foo`. Additionally, `metadata` may contain `created` and `last_modified` fields with date and time creation and most recent modification, respectively. The date and time are encoded using ISO 8601 format. + +The `data` key maps to the initial state of the ``IStateDB``. Many plugins look in the State DB for the configuration. +Also any plugins that register with the ``ILayoutRestorer`` will look up all keys in the State DB +that start with the `namespace` of their tracker before the first ``:``. The values of these keys should have a `data` +attribute that maps. + +For example, if your workspace looks like this: + +.. code-block:: json + + { + "data": { + "application-mimedocuments:package.json:JSON": { + "data": { "path": "package.json", "factory": "JSON" } + } + } + } + +It will run the `docmanager:open` with the ``{ "path": "package.json", "factory": "JSON" }`` args, because the `application-mimedocuments` tracker is registered with the `docmanager:open` command, like this: + + +.. code-block:: typescript + + const namespace = 'application-mimedocuments'; + const tracker = new WidgetTracker({ namespace }); + void restorer.restore(tracker, { + command: 'docmanager:open', + args: widget => ({ + path: widget.context.path, + factory: Private.factoryNameProperty.get(widget) + }), + name: widget => + `${widget.context.path}:${Private.factoryNameProperty.get(widget)}` + }); + +Note the part of the data key after the first ``:`` (``package.json:JSON``) is dropped and is irrelevant. diff --git a/galata/src/galata.ts b/galata/src/galata.ts index de3e25854d50..fbd640c5e9a4 100644 --- a/galata/src/galata.ts +++ b/galata/src/galata.ts @@ -449,8 +449,9 @@ export namespace galata { * The space name can be found in the named group `id`. * * The id will be prefixed by '/'. + * The id will be undefined for workspaces listing route. */ - export const workspaces = /.*\/api\/workspaces(?(\/[-\w]+)+)/; + export const workspaces = /.*\/api\/workspaces(?(\/[-\w]+)+)?/; /** * User API @@ -962,11 +963,25 @@ export namespace galata { ): Promise { return page.route(Routes.workspaces, (route, request) => { switch (request.method()) { - case 'GET': - return route.fulfill({ - status: 200, - body: JSON.stringify(workspace) - }); + case 'GET': { + const id = Routes.workspaces.exec(request.url())?.groups?.id; + if (id) { + return route.fulfill({ + status: 200, + body: JSON.stringify(workspace) + }); + } else { + return route.fulfill({ + status: 200, + body: JSON.stringify({ + workspaces: { + ids: [workspace.metadata.id], + values: [workspace] + } + }) + }); + } + } case 'PUT': { const data = request.postDataJSON(); workspace.data = { ...workspace.data, ...data.data }; diff --git a/galata/test/documentation/data/analysis-space.jupyterlab-workspace b/galata/test/documentation/data/analysis-space.jupyterlab-workspace new file mode 100644 index 000000000000..5430feb71060 --- /dev/null +++ b/galata/test/documentation/data/analysis-space.jupyterlab-workspace @@ -0,0 +1,57 @@ +{ + "data":{ + "layout-restorer:data":{ + "main":{ + "dock":{ + "type":"tab-area", + "currentIndex":0, + "widgets":[] + } + }, + "down":{ + "size":0, + "widgets":[] + }, + "left":{ + "collapsed":false, + "visible":true, + "current":"running-sessions", + "widgets":[ + "filebrowser", + "running-sessions", + "extensionmanager.main-view" + ], + "widgetStates":{ + "jp-running-sessions":{ + "sizes":[ + 0, + 0, + 0, + 1, + 0 + ], + "expansionStates":[ + false, + false, + false, + true, + false + ] + } + } + }, + "right":{ + "collapsed":true, + "visible":true + }, + "relativeSizes":[ + 0.2, + 0.8, + 0 + ] + } + }, + "metadata":{ + "id":"analysis-space" + } +} diff --git a/galata/test/documentation/export_notebook.test.ts-snapshots/exporting-menu-documentation-linux.png b/galata/test/documentation/export_notebook.test.ts-snapshots/exporting-menu-documentation-linux.png index eb4ebdae6f27..48ee5817e7b9 100644 Binary files a/galata/test/documentation/export_notebook.test.ts-snapshots/exporting-menu-documentation-linux.png and b/galata/test/documentation/export_notebook.test.ts-snapshots/exporting-menu-documentation-linux.png differ diff --git a/galata/test/documentation/general.test.ts-snapshots/running-layout-documentation-linux.png b/galata/test/documentation/general.test.ts-snapshots/running-layout-documentation-linux.png index 46864cf4821b..3fb4339d27a2 100644 Binary files a/galata/test/documentation/general.test.ts-snapshots/running-layout-documentation-linux.png and b/galata/test/documentation/general.test.ts-snapshots/running-layout-documentation-linux.png differ diff --git a/galata/test/documentation/overview.test.ts-snapshots/interface-tabs-documentation-linux.png b/galata/test/documentation/overview.test.ts-snapshots/interface-tabs-documentation-linux.png index e19d9937e077..38be169db25d 100644 Binary files a/galata/test/documentation/overview.test.ts-snapshots/interface-tabs-documentation-linux.png and b/galata/test/documentation/overview.test.ts-snapshots/interface-tabs-documentation-linux.png differ diff --git a/galata/test/documentation/workspaces.test.ts b/galata/test/documentation/workspaces.test.ts new file mode 100644 index 000000000000..a6fe049a35eb --- /dev/null +++ b/galata/test/documentation/workspaces.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { expect, galata, test } from '@jupyterlab/galata'; +import * as path from 'path'; + +import { positionMouseOver } from './utils'; + +test.use({ + viewport: { height: 720, width: 1280 }, + mockState: false +}); + +test.describe('Workspaces sidebar', () => { + const workspaceName = 'analysis-space'; + const testWorkspace = `${workspaceName}.jupyterlab-workspace`; + + test.beforeAll(async ({ request, tmpPath }) => { + const contents = galata.newContentsHelper(request); + + await contents.uploadFile( + path.resolve(__dirname, `./data/${testWorkspace}`), + `${tmpPath}/${testWorkspace}` + ); + }); + + test.beforeEach(async ({ page, tmpPath }) => { + await page.filebrowser.openDirectory(tmpPath); + }); + + test.afterAll(async ({ request, tmpPath }) => { + const contents = galata.newContentsHelper(request); + await contents.deleteDirectory(tmpPath); + }); + + test('Workspaces context menu', async ({ page }) => { + // Load the test workspace + await page.dblclick( + `.jp-DirListing-item span:has-text("${testWorkspace}")` + ); + await page + .locator( + `.jp-RunningSessions-item.jp-mod-workspace >> text=${workspaceName}` + ) + .waitFor(); + + // Create additional workspaces for the shot + await page.evaluate(async () => { + for (const workspaceName of ['my-coding-space', 'default']) { + await window.jupyterapp.commands.execute('workspace-ui:create-new', { + workspace: workspaceName + }); + } + }); + + await page.addStyleTag({ + content: `.jp-LabShell.jp-mod-devMode { + border-top: none; + }` + }); + + const workspaceItem = page.locator( + '.jp-RunningSessions-item.jp-mod-workspace >> text=default' + ); + // Open menu for the shot + await workspaceItem.click({ button: 'right' }); + const renameWorkspace = page.locator( + '.lm-Menu-itemLabel:text("Rename Workspace")' + ); + await renameWorkspace.hover(); + // Inject mouse + await page.evaluate( + ([mouse]) => { + document.body.insertAdjacentHTML('beforeend', mouse); + }, + [ + await positionMouseOver(renameWorkspace, { + left: 1, + offsetLeft: 5, + top: 0.25 + }) + ] + ); + + await page.launcher.waitFor(); + + expect( + await page.screenshot({ clip: { y: 0, x: 0, width: 400, height: 420 } }) + ).toMatchSnapshot('workspaces_sidebar.png'); + }); +}); diff --git a/galata/test/documentation/workspaces.test.ts-snapshots/workspaces-sidebar-documentation-linux.png b/galata/test/documentation/workspaces.test.ts-snapshots/workspaces-sidebar-documentation-linux.png new file mode 100644 index 000000000000..5f65309bed6e Binary files /dev/null and b/galata/test/documentation/workspaces.test.ts-snapshots/workspaces-sidebar-documentation-linux.png differ diff --git a/galata/test/jupyterlab/menus.test.ts b/galata/test/jupyterlab/menus.test.ts index cc8e29239fbf..f8648decdcb4 100644 --- a/galata/test/jupyterlab/menus.test.ts +++ b/galata/test/jupyterlab/menus.test.ts @@ -7,6 +7,7 @@ import type { ISettingRegistry } from '@jupyterlab/settingregistry'; const menuPaths = [ 'File', 'File>New', + 'File>Workspaces', 'Edit', 'View', 'View>Appearance', diff --git a/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png b/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png index 091661e2e3d5..46e7374bdadb 100644 Binary files a/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png and b/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png differ diff --git "a/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-workspaces\342\200\246-jupyterlab-linux.png" "b/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-workspaces\342\200\246-jupyterlab-linux.png" new file mode 100644 index 000000000000..5b6dcd020166 Binary files /dev/null and "b/galata/test/jupyterlab/menus.test.ts-snapshots/opened-menu-file-workspaces\342\200\246-jupyterlab-linux.png" differ diff --git a/galata/test/jupyterlab/notebook-create.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-create.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png index 6d11f93ba188..e35a3e6cc8c3 100644 Binary files a/galata/test/jupyterlab/notebook-create.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-create.test.ts-snapshots/opened-menu-file-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/sidebars.test.ts b/galata/test/jupyterlab/sidebars.test.ts index 5220d82fdcac..2a7d574e9a36 100644 --- a/galata/test/jupyterlab/sidebars.test.ts +++ b/galata/test/jupyterlab/sidebars.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { expect, galata, Handle, test } from '@jupyterlab/galata'; +import { expect, galata, test } from '@jupyterlab/galata'; import { Locator } from '@playwright/test'; const sidebarIds: galata.SidebarTabId[] = [ @@ -12,6 +12,10 @@ const sidebarIds: galata.SidebarTabId[] = [ 'extensionmanager.main-view' ]; +test.use({ + mockState: true +}); + /** * Add provided text as label on first tab in given tabbar. * By default we only have icons, but we should test for the @@ -71,7 +75,9 @@ test.describe('Sidebars', () => { // filtering results '.jp-DirListing-content mark', // only added after resizing - 'jp-DirListing-narrow' + 'jp-DirListing-narrow', + // used in "open file" dialog containing a file browser + '.jp-Open-Dialog' ] }); expect(unusedRules.length).toEqual(0); diff --git a/galata/test/jupyterlab/sidebars.test.ts-snapshots/opened-sidebar-jp-running-sessions-jupyterlab-linux.png b/galata/test/jupyterlab/sidebars.test.ts-snapshots/opened-sidebar-jp-running-sessions-jupyterlab-linux.png index ccb81d1fd831..352f3e3275bd 100644 Binary files a/galata/test/jupyterlab/sidebars.test.ts-snapshots/opened-sidebar-jp-running-sessions-jupyterlab-linux.png and b/galata/test/jupyterlab/sidebars.test.ts-snapshots/opened-sidebar-jp-running-sessions-jupyterlab-linux.png differ diff --git a/jupyterlab/galata/__init__.py b/jupyterlab/galata/__init__.py index 480bfaf9d030..ea5e850be03a 100644 --- a/jupyterlab/galata/__init__.py +++ b/jupyterlab/galata/__init__.py @@ -31,6 +31,8 @@ def configure_jupyter_server(c): # c.LabServerApp.extra_labextensions_path = str(Path(jupyterlab.__file__).parent / "galata") c.LabServerApp.extra_labextensions_path = str(Path(__file__).parent) + c.LabApp.workspaces_dir = mkdtemp(prefix="galata-workspaces-") + c.ServerApp.root_dir = os.environ.get( "JUPYTERLAB_GALATA_ROOT_DIR", mkdtemp(prefix="galata-test-") ) diff --git a/packages/apputils-extension/package.json b/packages/apputils-extension/package.json index 70757ccb8b9b..c1e0c7e9e7a4 100644 --- a/packages/apputils-extension/package.json +++ b/packages/apputils-extension/package.json @@ -42,7 +42,6 @@ "@jupyterlab/apputils": "^4.3.0-alpha.1", "@jupyterlab/coreutils": "^6.2.0-alpha.1", "@jupyterlab/docregistry": "^4.2.0-alpha.1", - "@jupyterlab/filebrowser": "^4.2.0-alpha.1", "@jupyterlab/mainmenu": "^4.2.0-alpha.1", "@jupyterlab/rendermime-interfaces": "^3.10.0-alpha.1", "@jupyterlab/services": "^7.2.0-alpha.1", @@ -51,6 +50,7 @@ "@jupyterlab/statusbar": "^4.2.0-alpha.1", "@jupyterlab/translation": "^4.2.0-alpha.1", "@jupyterlab/ui-components": "^4.2.0-alpha.1", + "@jupyterlab/workspaces": "^4.2.0-alpha.1", "@lumino/algorithm": "^2.0.1", "@lumino/commands": "^2.2.0", "@lumino/coreutils": "^2.1.2", diff --git a/packages/apputils-extension/schema/workspaces.json b/packages/apputils-extension/schema/workspaces.json deleted file mode 100644 index 8cf37a58a988..000000000000 --- a/packages/apputils-extension/schema/workspaces.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "title": "Workspaces", - "description": "Workspaces settings.", - "jupyter.lab.menus": { - "main": [ - { - "id": "jp-mainmenu-file", - "items": [ - { - "command": "workspace-ui:save-as", - "rank": 40 - }, - { - "command": "workspace-ui:save", - "rank": 40 - } - ] - } - ] - }, - "additionalProperties": false, - "properties": {}, - "type": "object" -} diff --git a/packages/apputils-extension/src/workspacesplugin.ts b/packages/apputils-extension/src/workspacesplugin.ts index 06f42402515f..6c3a2cb29719 100644 --- a/packages/apputils-extension/src/workspacesplugin.ts +++ b/packages/apputils-extension/src/workspacesplugin.ts @@ -2,66 +2,48 @@ // Distributed under the terms of the Modified BSD License. import { - IRouter, JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { Dialog, IWindowResolver, showDialog } from '@jupyterlab/apputils'; -import { URLExt } from '@jupyterlab/coreutils'; import { ABCWidgetFactory, DocumentRegistry, DocumentWidget, IDocumentWidget } from '@jupyterlab/docregistry'; -import { IDefaultFileBrowser } from '@jupyterlab/filebrowser'; -import { Contents, Workspace, WorkspaceManager } from '@jupyterlab/services'; +import { Workspace, WorkspaceManager } from '@jupyterlab/services'; import { IStateDB } from '@jupyterlab/statedb'; +import { IWorkspaceCommands } from '@jupyterlab/workspaces'; import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { Widget } from '@lumino/widgets'; -namespace CommandIDs { - export const saveWorkspace = 'workspace-ui:save'; - - export const saveWorkspaceAs = 'workspace-ui:save-as'; -} - const WORKSPACE_NAME = 'jupyterlab-workspace'; const WORKSPACE_EXT = '.' + WORKSPACE_NAME; const LAST_SAVE_ID = 'workspace-ui:lastSave'; const ICON_NAME = 'jp-JupyterIcon'; /** - * The workspace MIME renderer and save plugin. + * The workspace MIME renderer. */ export const workspacesPlugin: JupyterFrontEndPlugin = { id: '@jupyterlab/apputils-extension:workspaces', - description: 'Add workspace file type and commands.', + description: 'Add workspace file type.', autoStart: true, - requires: [ - IDefaultFileBrowser, - IWindowResolver, - IStateDB, - ITranslator, - JupyterFrontEnd.IPaths - ], - optional: [IRouter], + requires: [IStateDB, IWorkspaceCommands, ITranslator], activate: ( app: JupyterFrontEnd, - fileBrowser: IDefaultFileBrowser, - resolver: IWindowResolver, state: IStateDB, - translator: ITranslator, - paths: JupyterFrontEnd.IPaths, - router: IRouter | null + commands: IWorkspaceCommands, + translator: ITranslator ): void => { // The workspace factory creates dummy widgets to load a new workspace. const factory = new Private.WorkspaceFactory({ workspaces: app.serviceManager.workspaces, - router, state, translator, - paths + open: async (id: string) => { + await app.commands.execute(commands.open, { workspace: id }); + } }); const trans = translator.load('jupyterlab'); @@ -69,102 +51,16 @@ export const workspacesPlugin: JupyterFrontEndPlugin = { name: WORKSPACE_NAME, contentType: 'file', fileFormat: 'text', - displayName: trans.__('JupyterLab workspace File'), + displayName: trans.__('JupyterLab Workspace File'), extensions: [WORKSPACE_EXT], mimeTypes: ['text/json'], iconClass: ICON_NAME }); app.docRegistry.addWidgetFactory(factory); - app.commands.addCommand(CommandIDs.saveWorkspaceAs, { - label: trans.__('Save Current Workspace As…'), - execute: async () => { - const data = app.serviceManager.workspaces.fetch(resolver.name); - await Private.saveAs( - fileBrowser, - app.serviceManager.contents, - data, - state, - translator - ); - } - }); - - app.commands.addCommand(CommandIDs.saveWorkspace, { - label: trans.__('Save Current Workspace'), - execute: async () => { - const { contents } = app.serviceManager; - const data = app.serviceManager.workspaces.fetch(resolver.name); - const lastSave = (await state.fetch(LAST_SAVE_ID)) as string; - if (lastSave === undefined) { - await Private.saveAs(fileBrowser, contents, data, state, translator); - } else { - await Private.save(lastSave, contents, data, state); - } - } - }); } }; namespace Private { - /** - * Save workspace to a user provided location - */ - export async function save( - userPath: string, - contents: Contents.IManager, - data: Promise, - state: IStateDB - ): Promise { - let name = userPath.split('/').pop(); - - // Add extension if not provided or remove extension from name if it was. - if (name !== undefined && name.includes('.')) { - name = name.split('.')[0]; - } else { - userPath = userPath + WORKSPACE_EXT; - } - - // Save last save location, for save button to work - await state.save(LAST_SAVE_ID, userPath); - - const resolvedData = await data; - resolvedData.metadata.id = `${name}`; - await contents.save(userPath, { - type: 'file', - format: 'text', - content: JSON.stringify(resolvedData) - }); - } - - /** - * Ask user for location, and save workspace. - * Default location is the current directory in the file browser - */ - export async function saveAs( - browser: IDefaultFileBrowser, - contents: Contents.IManager, - data: Promise, - state: IStateDB, - translator?: ITranslator - ): Promise { - translator = translator || nullTranslator; - const lastSave = await state.fetch(LAST_SAVE_ID); - - let defaultName; - if (lastSave === undefined) { - defaultName = 'new-workspace'; - } else { - defaultName = (lastSave as string).split('/').pop()?.split('.')[0]; - } - - const defaultPath = browser.model.path + '/' + defaultName + WORKSPACE_EXT; - const userPath = await getSavePath(defaultPath, translator); - - if (userPath) { - await save(userPath, contents, data, state); - } - } - /** * This widget factory is used to handle double click on workspace */ @@ -183,15 +79,14 @@ namespace Private { defaultFor: [WORKSPACE_NAME], readOnly: true }); - this._application = options.paths.urls.app; - this._router = options.router; this._state = options.state; this._workspaces = options.workspaces; + this._open = options.open; } /** * Loads the workspace into load, and jump to it - * @param context This is used queried to query the workspace content + * @param context This is used to query the workspace content */ protected createNewWidget( context: DocumentRegistry.Context @@ -201,6 +96,7 @@ namespace Private { const file = context.model; const workspace = file.toJSON() as unknown as Workspace.IWorkspace; const path = context.path; + const id = workspace.metadata.id; // Save the file contents as a workspace. @@ -210,22 +106,12 @@ namespace Private { await this._state.save(LAST_SAVE_ID, path); // Navigate to new workspace. - const workspacesBase = URLExt.join(this._application, 'workspaces'); - const url = URLExt.join(workspacesBase, id); - if (!url.startsWith(workspacesBase)) { - throw new Error('Can only be used for workspaces'); - } - if (this._router) { - this._router.navigate(url, { hard: true }); - } else { - document.location.href = url; - } + await this._open(id); }); return dummyWidget(context); } - private _application: string; - private _router: IRouter | null; + private _open: (id: string) => Promise; private _state: IStateDB; private _workspaces: WorkspaceManager; } @@ -238,11 +124,10 @@ namespace Private { * Instantiation options for a `WorkspaceFactory` */ export interface IOptions { - paths: JupyterFrontEnd.IPaths; - router: IRouter | null; state: IStateDB; translator: ITranslator; workspaces: WorkspaceManager; + open: (id: string) => Promise; } } @@ -256,59 +141,4 @@ namespace Private { widget.content.dispose(); return widget; } - - /** - * Ask user for a path to save to. - * @param defaultPath Path already present when the dialog is shown - */ - async function getSavePath( - defaultPath: string, - translator?: ITranslator - ): Promise { - translator = translator || nullTranslator; - const trans = translator.load('jupyterlab'); - const saveBtn = Dialog.okButton({ - label: trans.__('Save'), - ariaLabel: trans.__('Save Current Workspace') - }); - const result = await showDialog({ - title: trans.__('Save Current Workspace As…'), - body: new SaveWidget(defaultPath), - buttons: [Dialog.cancelButton(), saveBtn] - }); - if (result.button.label === trans.__('Save')) { - return result.value; - } else { - return null; - } - } - - /** - * A widget that gets a file path from a user. - */ - class SaveWidget extends Widget { - /** - * Gets a modal node for getting save location. Will have a default to the current opened directory - * @param path Default location - */ - constructor(path: string) { - super({ node: createSaveNode(path) }); - } - - /** - * Gets the save path entered by the user - */ - getValue(): string { - return (this.node as HTMLInputElement).value; - } - } - - /** - * Create the node for a save widget. - */ - function createSaveNode(path: string): HTMLElement { - const input = document.createElement('input'); - input.value = path; - return input; - } } diff --git a/packages/apputils-extension/style/index.css b/packages/apputils-extension/style/index.css index 71c2121a1343..ffde70ab19cb 100644 --- a/packages/apputils-extension/style/index.css +++ b/packages/apputils-extension/style/index.css @@ -10,6 +10,5 @@ @import url('~@jupyterlab/apputils/style/index.css'); @import url('~@jupyterlab/docregistry/style/index.css'); @import url('~@jupyterlab/application/style/index.css'); -@import url('~@jupyterlab/filebrowser/style/index.css'); @import url('~@jupyterlab/mainmenu/style/index.css'); @import url('./base.css'); diff --git a/packages/apputils-extension/style/index.js b/packages/apputils-extension/style/index.js index 8c82ce3eab07..4f2ca9702217 100644 --- a/packages/apputils-extension/style/index.js +++ b/packages/apputils-extension/style/index.js @@ -10,7 +10,6 @@ import '@jupyterlab/statusbar/style/index.js'; import '@jupyterlab/apputils/style/index.js'; import '@jupyterlab/docregistry/style/index.js'; import '@jupyterlab/application/style/index.js'; -import '@jupyterlab/filebrowser/style/index.js'; import '@jupyterlab/mainmenu/style/index.js'; import './base.css'; diff --git a/packages/apputils-extension/tsconfig.json b/packages/apputils-extension/tsconfig.json index 29f665978685..93e450bc2d0c 100644 --- a/packages/apputils-extension/tsconfig.json +++ b/packages/apputils-extension/tsconfig.json @@ -18,9 +18,6 @@ { "path": "../docregistry" }, - { - "path": "../filebrowser" - }, { "path": "../mainmenu" }, @@ -44,6 +41,9 @@ }, { "path": "../ui-components" + }, + { + "path": "../workspaces" } ] } diff --git a/packages/apputils/src/dialog.tsx b/packages/apputils/src/dialog.tsx index 91c87afd34cd..d880dc312dde 100644 --- a/packages/apputils/src/dialog.tsx +++ b/packages/apputils/src/dialog.tsx @@ -245,6 +245,9 @@ export class Dialog extends Widget { case 'click': this._evtClick(event as MouseEvent); break; + case 'input': + this._evtInput(event as InputEvent); + break; case 'focus': this._evtFocus(event as FocusEvent); break; @@ -267,6 +270,7 @@ export class Dialog extends Widget { node.addEventListener('click', this, true); document.addEventListener('mousedown', this, true); document.addEventListener('focus', this, true); + document.addEventListener('input', this, true); this._first = Private.findFirstFocusable(this.node); this._original = document.activeElement as HTMLElement; @@ -309,6 +313,7 @@ export class Dialog extends Widget { node.removeEventListener('click', this, true); document.removeEventListener('focus', this, true); document.removeEventListener('mousedown', this, true); + document.removeEventListener('input', this, true); this._original.focus(); } @@ -321,6 +326,19 @@ export class Dialog extends Widget { } super.onCloseRequest(msg); } + /** + * Handle the `'input'` event for dialog's children. + * + * @param event - The DOM event sent to the widget + */ + protected _evtInput(_event: InputEvent): void { + this._hasValidationErrors = !!this.node.querySelector(':invalid'); + for (let i = 0; i < this._buttons.length; i++) { + if (this._buttons[i].accept) { + this._buttonNodes[i].disabled = this._hasValidationErrors; + } + } + } /** * Handle the `'click'` event for a dialog button. @@ -367,7 +385,7 @@ export class Dialog extends Widget { const activeEl = document.activeElement; if (activeEl instanceof HTMLButtonElement) { - let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) - 1; + let idx = this._buttonNodes.indexOf(activeEl) - 1; // Handle a left arrows on the first button if (idx < 0) { @@ -386,7 +404,7 @@ export class Dialog extends Widget { const activeEl = document.activeElement; if (activeEl instanceof HTMLButtonElement) { - let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) + 1; + let idx = this._buttonNodes.indexOf(activeEl) + 1; // Handle a right arrows on the last button if (idx == this._buttons.length) { @@ -420,7 +438,7 @@ export class Dialog extends Widget { let index: number | undefined; if (activeEl instanceof HTMLButtonElement) { - index = this._buttonNodes.indexOf(activeEl as HTMLElement); + index = this._buttonNodes.indexOf(activeEl); } this.resolve(index); break; @@ -460,6 +478,10 @@ export class Dialog extends Widget { * Resolve a button item. */ private _resolve(button: Dialog.IButton): void { + if (this._hasValidationErrors && button.accept) { + // Do not allow accepting with validation errors + return; + } // Prevent loopback. const promise = this._promise; if (!promise) { @@ -487,8 +509,9 @@ export class Dialog extends Widget { }); } + private _hasValidationErrors: boolean = false; private _ready: PromiseDelegate = new PromiseDelegate(); - private _buttonNodes: ReadonlyArray; + private _buttonNodes: ReadonlyArray; private _buttons: ReadonlyArray; private _checkboxNode: HTMLElement | null; private _original: HTMLElement; @@ -656,7 +679,7 @@ export namespace Dialog { checkbox: Partial | null; /** - * The index of the default button. Defaults to the last button. + * The index of the default button. Defaults to the last button. */ defaultButton: number; @@ -726,7 +749,7 @@ export namespace Dialog { * * @returns A node for the button. */ - createButtonNode(button: IButton): HTMLElement; + createButtonNode(button: IButton): HTMLButtonElement; /** * Create a checkbox node for the dialog. @@ -964,7 +987,7 @@ export namespace Dialog { * * @returns A node for the button. */ - createButtonNode(button: IButton): HTMLElement { + createButtonNode(button: IButton): HTMLButtonElement { const e = document.createElement('button'); e.className = this.createItemClass(button); e.appendChild(this.renderIcon(button)); diff --git a/packages/apputils/src/inputdialog.ts b/packages/apputils/src/inputdialog.ts index fa87352c2790..3db34406cc0c 100644 --- a/packages/apputils/src/inputdialog.ts +++ b/packages/apputils/src/inputdialog.ts @@ -16,7 +16,7 @@ export namespace InputDialog { /** * Common constructor options for input dialogs */ - export interface IOptions { + export interface IOptions extends IBaseOptions { /** * The top level text for the dialog. Defaults to an empty string. */ @@ -27,11 +27,6 @@ export namespace InputDialog { */ host?: HTMLElement; - /** - * Label of the requested input - */ - label?: string; - /** * An optional renderer for dialog items. Defaults to a shared * default renderer. @@ -52,6 +47,11 @@ export namespace InputDialog { * The checkbox to display in the footer. Defaults no checkbox. */ checkbox?: Partial | null; + + /** + * The index of the default button. Defaults to the last button. + */ + defaultButton?: number; } /** @@ -215,6 +215,14 @@ export namespace InputDialog { * Default is to select the whole input text if present. */ selectionRange?: number; + /** + * Pattern used by the browser to validate the input value. + */ + pattern?: string; + /** + * Whether the input is required (has to be non-empty). + */ + required?: boolean; } /** @@ -260,6 +268,26 @@ export namespace InputDialog { } } +/** + * Constructor options for base input dialog body. + */ +interface IBaseOptions { + /** + * Label of the requested input + */ + label?: string; + + /** + * Additional prefix string preceding the input (e.g. £). + */ + prefix?: string; + + /** + * Additional suffix string following the input (e.g. $). + */ + suffix?: string; +} + /** * Base widget for input dialog body */ @@ -269,7 +297,7 @@ class InputDialogBase extends Widget implements Dialog.IBodyWidget { * * @param label Input field label */ - constructor(label?: string) { + constructor(options: IBaseOptions) { super(); this.addClass(INPUT_DIALOG_CLASS); @@ -277,16 +305,38 @@ class InputDialogBase extends Widget implements Dialog.IBodyWidget { this._input.classList.add('jp-mod-styled'); this._input.id = 'jp-dialog-input-id'; - if (label !== undefined) { + if (options.label !== undefined) { const labelElement = document.createElement('label'); - labelElement.textContent = label; + labelElement.textContent = options.label; labelElement.htmlFor = this._input.id; // Initialize the node this.node.appendChild(labelElement); } - this.node.appendChild(this._input); + const wrapper = document.createElement('div'); + wrapper.className = 'jp-InputDialog-inputWrapper'; + + if (options.prefix) { + const prefix = document.createElement('span'); + prefix.className = 'jp-InputDialog-inputPrefix'; + prefix.textContent = options.prefix; + // Both US WDS (https://designsystem.digital.gov/components/input-prefix-suffix/) + // and UK DS (https://design-system.service.gov.uk/components/text-input/) recommend + // hiding prefixes and suffixes from screen readers. + prefix.ariaHidden = 'true'; + wrapper.appendChild(prefix); + } + wrapper.appendChild(this._input); + if (options.suffix) { + const suffix = document.createElement('span'); + suffix.className = 'jp-InputDialog-inputSuffix'; + suffix.textContent = options.suffix; + suffix.ariaHidden = 'true'; + wrapper.appendChild(suffix); + } + + this.node.appendChild(wrapper); } /** Input HTML node */ @@ -303,7 +353,7 @@ class InputBooleanDialog extends InputDialogBase { * @param options Constructor options */ constructor(options: InputDialog.IBooleanOptions) { - super(options.label); + super(options); this.addClass(INPUT_BOOLEAN_DIALOG_CLASS); this._input.type = 'checkbox'; @@ -328,7 +378,7 @@ class InputNumberDialog extends InputDialogBase { * @param options Constructor options */ constructor(options: InputDialog.INumberOptions) { - super(options.label); + super(options); this._input.type = 'number'; this._input.value = options.value ? options.value.toString() : '0'; @@ -347,22 +397,49 @@ class InputNumberDialog extends InputDialogBase { } /** - * Widget body for input text dialog + * Base widget body for input text/password/email dialog */ -class InputTextDialog extends InputDialogBase { +class InputDialogTextualBase extends InputDialogBase { /** - * InputTextDialog constructor + * InputDialogTextualBase constructor * * @param options Constructor options */ - constructor(options: InputDialog.ITextOptions) { - super(options.label); - - this._input.type = 'text'; + constructor(options: Omit) { + super(options); this._input.value = options.text ? options.text : ''; if (options.placeholder) { this._input.placeholder = options.placeholder; } + if (options.pattern) { + this._input.pattern = options.pattern; + } + if (options.required) { + this._input.required = options.required; + } + } + + /** + * Get the text specified by the user + */ + getValue(): string { + return this._input.value; + } +} + +/** + * Widget body for input text dialog + */ +class InputTextDialog extends InputDialogTextualBase { + /** + * InputTextDialog constructor + * + * @param options Constructor options + */ + constructor(options: InputDialog.ITextOptions) { + super(options); + this._input.type = 'text'; + this._initialSelectionRange = Math.min( this._input.value.length, Math.max(0, options.selectionRange ?? this._input.value.length) @@ -379,33 +456,21 @@ class InputTextDialog extends InputDialogBase { } } - /** - * Get the text specified by the user - */ - getValue(): string { - return this._input.value; - } - private _initialSelectionRange: number; } /** * Widget body for input password dialog */ -class InputPasswordDialog extends InputDialogBase { +class InputPasswordDialog extends InputDialogTextualBase { /** * InputPasswordDialog constructor * * @param options Constructor options */ constructor(options: InputDialog.ITextOptions) { - super(options.label); - + super(options); this._input.type = 'password'; - this._input.value = options.text ? options.text : ''; - if (options.placeholder) { - this._input.placeholder = options.placeholder; - } } /** @@ -417,13 +482,6 @@ class InputPasswordDialog extends InputDialogBase { this._input.select(); } } - - /** - * Get the text specified by the user - */ - getValue(): string { - return this._input.value; - } } /** @@ -436,7 +494,7 @@ class InputItemsDialog extends InputDialogBase { * @param options Constructor options */ constructor(options: InputDialog.IItemOptions) { - super(options.label); + super(options); this._editable = options.editable || false; @@ -474,8 +532,7 @@ class InputItemsDialog extends InputDialogBase { this.node.appendChild(data); } else { /* Use select directly */ - this._input.remove(); - this.node.appendChild(this._list); + this._input.parentElement!.replaceChild(this._list, this._input); } } @@ -504,7 +561,7 @@ class InputMultipleItemsDialog extends InputDialogBase { * @param options Constructor options */ constructor(options: InputDialog.IMultipleItemsOptions) { - super(options.label); + super(options); let defaults = options.defaults || []; diff --git a/packages/apputils/style/dialog.css b/packages/apputils/style/dialog.css index 7806de440c43..57b1141fb596 100644 --- a/packages/apputils/style/dialog.css +++ b/packages/apputils/style/dialog.css @@ -50,6 +50,10 @@ overflow: visible; } +button.jp-Dialog-button:disabled { + opacity: 0.6; +} + button.jp-Dialog-button:focus { outline: 1px solid var(--jp-brand-color1); outline-offset: 4px; diff --git a/packages/apputils/style/inputdialog.css b/packages/apputils/style/inputdialog.css index 4603a4d5612c..39bfa36054c8 100644 --- a/packages/apputils/style/inputdialog.css +++ b/packages/apputils/style/inputdialog.css @@ -12,3 +12,20 @@ .jp-Input-Boolean-Dialog > label { flex: 1 1 auto; } + +.jp-InputDialog-inputWrapper { + display: flex; + align-items: baseline; +} + +.jp-InputDialog-inputWrapper > input.jp-mod-styled:invalid { + border-color: var(--jp-error-color0); + background: var(--jp-error-color3); +} + +.jp-InputDialog-inputWrapper + > input[required].jp-mod-styled:invalid:placeholder-shown { + /* Do not show invalid style when placeholder is shown */ + border-color: unset; + background: unset; +} diff --git a/packages/apputils/test/inputdialog.spec.ts b/packages/apputils/test/inputdialog.spec.ts index 961e9c59b7c1..85701a2d618d 100644 --- a/packages/apputils/test/inputdialog.spec.ts +++ b/packages/apputils/test/inputdialog.spec.ts @@ -248,6 +248,71 @@ describe('@jupyterlab/apputils', () => { expect(result.value).toBe('my answer'); document.body.removeChild(node); }); + + it('should prevent accepting when pattern does not match', async () => { + const node = document.createElement('div'); + document.body.appendChild(node); + + const dialog = InputDialog.getText({ + title: 'Give me letters', + pattern: '[a-z]+', + host: node + }); + + await waitForDialog(node); + const input = node.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + + input.value = '123'; + input.dispatchEvent(new Event('input')); + + const acceptButton = node.querySelector( + 'button.jp-mod-accept' + ) as HTMLButtonElement; + const dismissButton = node.querySelector( + 'button.jp-mod-reject' + ) as HTMLButtonElement; + expect(acceptButton.disabled).toBe(true); + expect(dismissButton.disabled).toBe(false); + + await dismissDialog(); + const result = await dialog; + expect(result.button.accept).toBe(false); + }); + + it('should allow accepting when pattern matches', async () => { + const node = document.createElement('div'); + document.body.appendChild(node); + + const dialog = InputDialog.getText({ + title: 'Give me letters', + pattern: '[a-z]+', + host: node + }); + + await waitForDialog(node); + const input = node.querySelector( + 'input[type="text"]' + ) as HTMLInputElement; + + input.value = 'abc'; + input.dispatchEvent(new Event('input')); + + const acceptButton = node.querySelector( + 'button.jp-mod-accept' + ) as HTMLButtonElement; + const dismissButton = node.querySelector( + 'button.jp-mod-reject' + ) as HTMLButtonElement; + expect(acceptButton.disabled).toBe(false); + expect(dismissButton.disabled).toBe(false); + + await acceptDialog(); + const result = await dialog; + expect(result.button.accept).toBe(true); + expect(result.value).toBe('abc'); + }); }); describe('getNumber()', () => { diff --git a/packages/filebrowser/src/opendialog.ts b/packages/filebrowser/src/opendialog.ts index 0e916c63d224..bb6a36c0546f 100644 --- a/packages/filebrowser/src/opendialog.ts +++ b/packages/filebrowser/src/opendialog.ts @@ -18,6 +18,11 @@ import { PromiseDelegate } from '@lumino/coreutils'; */ const OPEN_DIALOG_CLASS = 'jp-Open-Dialog'; +/** + * The class name added to (optional) label in the file dialog + */ +const OPEN_DIALOG_LABEL_CLASS = 'jp-Open-Dialog-label'; + /** * Namespace for file dialog */ @@ -49,6 +54,11 @@ export namespace FileDialog { * Default path to open */ defaultPath?: string; + + /** + * Text to display above the file browser. + */ + label?: string; } /** @@ -85,7 +95,8 @@ export namespace FileDialog { options.manager, options.filter, translator, - options.defaultPath + options.defaultPath, + options.label ); const dialogOptions: Partial> = { title: options.title, @@ -141,6 +152,7 @@ class OpenDialog filter?: (value: Contents.IModel) => Partial | null, translator?: ITranslator, defaultPath?: string, + label?: string, filterDirectories?: boolean ) { super(); @@ -191,6 +203,12 @@ class OpenDialog // Build the sub widgets const layout = new PanelLayout(); + if (label) { + const labelWidget = new Widget(); + labelWidget.addClass(OPEN_DIALOG_LABEL_CLASS); + labelWidget.node.textContent = label; + layout.addWidget(labelWidget); + } layout.addWidget(this._browser); /** diff --git a/packages/filebrowser/style/base.css b/packages/filebrowser/style/base.css index 9247008308ae..4e1289009a32 100644 --- a/packages/filebrowser/style/base.css +++ b/packages/filebrowser/style/base.css @@ -12,11 +12,6 @@ --jp-private-filebrowser-button-width: 48px; } -/*----------------------------------------------------------------------------- -| Copyright (c) Jupyter Development Team. -| Distributed under the terms of the Modified BSD License. -|----------------------------------------------------------------------------*/ - .jp-FileBrowser .jp-SidePanel-content { display: flex; flex-direction: column; @@ -104,6 +99,14 @@ display: none; } +.jp-Open-Dialog > .jp-FileBrowser { + min-height: 200px; +} + +.jp-Open-Dialog-label { + overflow: visible; +} + /*----------------------------------------------------------------------------- | DirListing |----------------------------------------------------------------------------*/ diff --git a/packages/metapackage/package.json b/packages/metapackage/package.json index 3a0ac229ea66..536277d95e63 100644 --- a/packages/metapackage/package.json +++ b/packages/metapackage/package.json @@ -130,7 +130,9 @@ "@jupyterlab/translation-extension": "^4.2.0-alpha.1", "@jupyterlab/ui-components": "^4.2.0-alpha.1", "@jupyterlab/ui-components-extension": "^4.2.0-alpha.1", - "@jupyterlab/vega5-extension": "^4.2.0-alpha.1" + "@jupyterlab/vega5-extension": "^4.2.0-alpha.1", + "@jupyterlab/workspaces": "^4.2.0-alpha.1", + "@jupyterlab/workspaces-extension": "^4.2.0-alpha.1" }, "devDependencies": { "@jupyterlab/testing": "^4.2.0-alpha.1", diff --git a/packages/metapackage/tsconfig.json b/packages/metapackage/tsconfig.json index 3e8aabcef069..59f20ae24b23 100644 --- a/packages/metapackage/tsconfig.json +++ b/packages/metapackage/tsconfig.json @@ -284,6 +284,12 @@ }, { "path": "../vega5-extension" + }, + { + "path": "../workspaces" + }, + { + "path": "../workspaces-extension" } ] } diff --git a/packages/metapackage/tsconfig.test.json b/packages/metapackage/tsconfig.test.json index a96e5316a0d7..deb5daace1c0 100644 --- a/packages/metapackage/tsconfig.test.json +++ b/packages/metapackage/tsconfig.test.json @@ -281,6 +281,12 @@ { "path": "../vega5-extension" }, + { + "path": "../workspaces" + }, + { + "path": "../workspaces-extension" + }, { "path": "." }, diff --git a/packages/running/src/index.tsx b/packages/running/src/index.tsx index 4f3b07efe619..7bf62efacece 100644 --- a/packages/running/src/index.tsx +++ b/packages/running/src/index.tsx @@ -143,7 +143,7 @@ export class RunningSessionManagers implements IRunningSessionManagers { function Item(props: { child?: boolean; runningItem: IRunningSessions.IRunningItem; - shutdownLabel?: string; + shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); shutdownItemIcon?: LabIcon; translator?: ITranslator; }) { @@ -158,7 +158,10 @@ function Item(props: { // Handle shutdown requests. let stopPropagation = false; const shutdownItemIcon = props.shutdownItemIcon || closeIcon; - const shutdownLabel = props.shutdownLabel || trans.__('Shut Down'); + const shutdownLabel = + (typeof props.shutdownLabel === 'function' + ? props.shutdownLabel(runningItem) + : props.shutdownLabel) ?? trans.__('Shut Down'); const shutdown = () => { stopPropagation = true; runningItem.shutdown?.(); @@ -232,7 +235,7 @@ function Item(props: { function List(props: { child?: boolean; runningItems: IRunningSessions.IRunningItem[]; - shutdownLabel?: string; + shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); shutdownAllLabel?: string; shutdownItemIcon?: LabIcon; translator?: ITranslator; @@ -515,7 +518,7 @@ export namespace IRunningSessions { /** * A string used to describe the shutdown action. */ - shutdownLabel?: string; + shutdownLabel?: string | ((item: IRunningSessions.IRunningItem) => string); /** * A string used to describe the shutdown all action. diff --git a/packages/running/style/base.css b/packages/running/style/base.css index 91c9d91b0e32..bcf00651f212 100644 --- a/packages/running/style/base.css +++ b/packages/running/style/base.css @@ -100,6 +100,7 @@ .jp-RunningSessions-item .jp-RunningSessions-itemShutdown { border-radius: 0; + margin: 0; } .jp-RunningSessions-item:not(:hover) .jp-RunningSessions-itemShutdown { diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts index f77c7cbe775a..9a7d8e43736c 100644 --- a/packages/services/src/workspace/index.ts +++ b/packages/services/src/workspace/index.ts @@ -166,6 +166,16 @@ export namespace Workspace { * The workspace ID. */ id: string; + + /** + * The last modification date and time for this workspace (ISO 8601 format). + */ + last_modified?: string; + + /** + * The creation date and time for this workspace (ISO 8601 format). + */ + created?: string; }; } } diff --git a/packages/workspaces-extension/.vscode/launch.json b/packages/workspaces-extension/.vscode/launch.json new file mode 100644 index 000000000000..64126e422f32 --- /dev/null +++ b/packages/workspaces-extension/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to jest", + // Usage: + // Open the parent directory in VSCode + // Run \`jlpm test:debug:watch\` in a terminal + // Run this debugging task + "port": 9229 + } + ] +} diff --git a/packages/workspaces-extension/package.json b/packages/workspaces-extension/package.json new file mode 100644 index 000000000000..e27e219685d1 --- /dev/null +++ b/packages/workspaces-extension/package.json @@ -0,0 +1,62 @@ +{ + "name": "@jupyterlab/workspaces-extension", + "version": "4.2.0-alpha.1", + "description": "JupyterLab Extension providing UI for workspace management", + "homepage": "https://github.com/jupyterlab/jupyterlab", + "bugs": { + "url": "https://github.com/jupyterlab/jupyterlab/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/jupyterlab/jupyterlab.git" + }, + "license": "BSD-3-Clause", + "author": "Project Jupyter", + "sideEffects": [ + "style/**/*" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "style": "style/index.css", + "directories": { + "lib": "lib/" + }, + "files": [ + "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "schema/*.json", + "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", + "src/**/*.{ts,tsx}", + "style/index.js" + ], + "scripts": { + "build": "tsc -b", + "build:test": "tsc --build tsconfig.test.json", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "watch": "tsc -b --watch" + }, + "dependencies": { + "@jupyterlab/application": "^4.2.0-alpha.1", + "@jupyterlab/apputils": "^4.3.0-alpha.1", + "@jupyterlab/coreutils": "^6.2.0-alpha.1", + "@jupyterlab/filebrowser": "^4.2.0-alpha.1", + "@jupyterlab/running": "^4.2.0-alpha.1", + "@jupyterlab/services": "^7.2.0-alpha.1", + "@jupyterlab/statedb": "^4.2.0-alpha.1", + "@jupyterlab/translation": "^4.2.0-alpha.1", + "@jupyterlab/ui-components": "^4.2.0-alpha.1", + "@jupyterlab/workspaces": "^4.2.0-alpha.1" + }, + "devDependencies": { + "@types/jest": "^29.2.0", + "rimraf": "~5.0.5", + "typescript": "~5.1.6" + }, + "publishConfig": { + "access": "public" + }, + "jupyterlab": { + "extension": true, + "schemaDir": "schema" + }, + "styleModule": "style/index.js" +} diff --git a/packages/workspaces-extension/schema/menu.json b/packages/workspaces-extension/schema/menu.json new file mode 100644 index 000000000000..133787723e41 --- /dev/null +++ b/packages/workspaces-extension/schema/menu.json @@ -0,0 +1,70 @@ +{ + "title": "Workspaces Menu", + "description": "Workspaces Menu", + "jupyter.lab.menus": { + "main": [ + { + "id": "jp-mainmenu-file", + "items": [ + { + "type": "submenu", + "rank": 10, + "submenu": { + "id": "jp-mainmenu-file-workspaces", + "label": "Workspaces", + "items": [ + { + "command": "workspace-ui:open", + "rank": 0 + }, + { + "command": "workspace-ui:create-new", + "rank": 1 + }, + { + "command": "workspace-ui:clone", + "rank": 2 + }, + { + "command": "workspace-ui:rename", + "rank": 3 + }, + { + "command": "workspace-ui:save", + "rank": 4 + }, + { + "command": "workspace-ui:save-as", + "rank": 5 + }, + { + "command": "workspace-ui:import", + "rank": 6 + }, + { + "command": "workspace-ui:export", + "rank": 7 + }, + { + "type": "separator", + "rank": 8 + }, + { + "command": "workspace-ui:reset", + "rank": 9 + }, + { + "command": "workspace-ui:delete", + "rank": 10 + } + ] + } + } + ] + } + ] + }, + "properties": {}, + "additionalProperties": false, + "type": "object" +} diff --git a/packages/workspaces-extension/schema/sidebar.json b/packages/workspaces-extension/schema/sidebar.json new file mode 100644 index 000000000000..1656b644199a --- /dev/null +++ b/packages/workspaces-extension/schema/sidebar.json @@ -0,0 +1,51 @@ +{ + "title": "Workspaces Sidebar", + "description": "Workspaces Sidebar", + "jupyter.lab.menus": { + "context": [ + { + "command": "workspace-ui:clone", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 0 + }, + { + "command": "workspace-ui:rename", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 1 + }, + { + "command": "workspace-ui:reset", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 2 + }, + { + "command": "workspace-ui:delete", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 3 + }, + { + "command": "workspace-ui:export", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 4 + }, + { + "type": "separator", + "selector": ".jp-RunningSessions-item.jp-mod-workspace", + "rank": 5 + }, + { + "command": "workspace-ui:import", + "selector": ".jp-RunningSessions:has(.jp-mod-workspace)", + "rank": 6 + }, + { + "command": "workspace-ui:create-new", + "selector": ".jp-RunningSessions:has(.jp-mod-workspace)", + "rank": 7 + } + ] + }, + "properties": {}, + "additionalProperties": false, + "type": "object" +} diff --git a/packages/workspaces-extension/src/commands.ts b/packages/workspaces-extension/src/commands.ts new file mode 100644 index 000000000000..6d9cad0253f2 --- /dev/null +++ b/packages/workspaces-extension/src/commands.ts @@ -0,0 +1,597 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + IRouter, + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { + Dialog, + InputDialog, + IWindowResolver, + showDialog +} from '@jupyterlab/apputils'; + +import { URLExt } from '@jupyterlab/coreutils'; + +import { FileDialog, IDefaultFileBrowser } from '@jupyterlab/filebrowser'; +import { Contents, Workspace } from '@jupyterlab/services'; +import { IStateDB } from '@jupyterlab/statedb'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { ICommandPalette } from '@jupyterlab/apputils'; +import { IWorkspaceCommands, IWorkspacesModel } from '@jupyterlab/workspaces'; + +namespace CommandIDs { + /** + * Open a workspace by identifier. When no identifier is supplied, falls back to a simple choice dialog. + */ + export const open = 'workspace-ui:open'; + /** + * Save the current workspace. + */ + export const save = 'workspace-ui:save'; + /** + * Save the current workspace under a different path. + */ + export const saveAs = 'workspace-ui:save-as'; + /** + * Create a new workspace. + */ + export const createNew = 'workspace-ui:create-new'; + /** + * Delete a workspace. + */ + export const deleteWorkspace = 'workspace-ui:delete'; + /** + * Clone a given workspace. + */ + export const clone = 'workspace-ui:clone'; + /** + * Rename a workspace. + */ + export const rename = 'workspace-ui:rename'; + /** + * Reset a workspace. + */ + export const reset = 'workspace-ui:reset'; + /** + * Import a workspace. + */ + export const importWorkspace = 'workspace-ui:import'; + /** + * Export a workspace. + */ + export const exportWorkspace = 'workspace-ui:export'; +} + +const WORKSPACE_NAME = 'jupyterlab-workspace'; +const WORKSPACE_EXT = '.' + WORKSPACE_NAME; +const LAST_SAVE_ID = 'workspace-ui:lastSave'; + +export const WORKSPACE_ITEM_CLASS = 'jp-mod-workspace'; + +/** + * The workspace commands + */ +export const commandsPlugin: JupyterFrontEndPlugin = { + id: '@jupyterlab/workspaces:commands', + description: 'Add workspace commands.', + autoStart: true, + requires: [ + IWorkspacesModel, + IDefaultFileBrowser, + IWindowResolver, + IStateDB, + ITranslator, + JupyterFrontEnd.IPaths + ], + provides: IWorkspaceCommands, + optional: [IRouter, ICommandPalette], + activate: ( + app: JupyterFrontEnd, + model: IWorkspacesModel, + fileBrowser: IDefaultFileBrowser, + resolver: IWindowResolver, + state: IStateDB, + translator: ITranslator, + paths: JupyterFrontEnd.IPaths, + router: IRouter | null, + palette: ICommandPalette | null + ): IWorkspaceCommands => { + const trans = translator.load('jupyterlab'); + + const namingHintLabel = trans.__( + 'Naming the workspace will create a unique URL. The name may contain letters, numbers, hyphens (-), and underscores (_).' + ); + const workspacesBase = URLExt.join(paths.urls.app, 'workspaces'); + const resourcePrefix = workspacesBase + '/'; + // `-` needs double backslashes to be escaped in `v` mode used for patterns + const namePattern = '[a-zA-Z0-9\\-_]+'; + + const getNameForWorkspace = async ( + options: Omit + ) => { + return InputDialog.getText({ + label: namingHintLabel, + prefix: resourcePrefix, + pattern: namePattern, + required: true, + placeholder: trans.__('workspace-name'), + ...options + }); + }; + + const test = (node: HTMLElement) => + node.classList.contains(WORKSPACE_ITEM_CLASS); + + app.commands.addCommand(CommandIDs.open, { + label: args => { + const workspaceId = args.workspace as string | undefined; + return workspaceId + ? trans.__('Open Workspace') + : trans.__('Open Workspace…'); + }, + execute: async args => { + let workspaceId = args.workspace as string | undefined; + + if (!workspaceId) { + const result = await InputDialog.getItem({ + title: trans.__('Choose Workspace To Open'), + label: trans.__('Choose an existing workspace to open.'), + items: model.identifiers, + okLabel: trans.__('Choose'), + prefix: resourcePrefix + }); + if (!result.value || !result.button.accept) { + return; + } + workspaceId = result.value; + } + + if (!workspaceId) { + return; + } + + const url = URLExt.join(workspacesBase, workspaceId); + if (!url.startsWith(workspacesBase)) { + throw new Error('Can only be used for workspaces'); + } + if (router) { + router.navigate(url, { hard: true }); + } else { + document.location.href = url; + } + } + }); + + app.commands.addCommand(CommandIDs.deleteWorkspace, { + label: trans.__('Delete Workspace…'), + execute: async args => { + const node = app.contextMenuHitTest(test); + let workspaceId = + (args.workspace as string | undefined) ?? node?.dataset['context']; + + if (!workspaceId) { + const result = await InputDialog.getItem({ + title: trans.__('Choose Workspace To Delete'), + label: trans.__('Choose an existing workspace to delete.'), + items: model.identifiers, + okLabel: trans.__('Choose') + }); + if (!result.value || !result.button.accept) { + return; + } + workspaceId = result.value; + } + + if (!workspaceId) { + return; + } + const result = await showDialog({ + title: trans.__('Delete workspace'), + body: trans.__( + 'Deleting workspace "%1" will also delete its URL. A deleted workspace cannot be recovered.', + workspaceId + ), + buttons: [ + Dialog.cancelButton(), + Dialog.warnButton({ label: trans.__('Delete') }) + ], + defaultButton: 0 + }); + + if (result.button.accept) { + await model.remove(workspaceId); + } + } + }); + + app.commands.addCommand(CommandIDs.createNew, { + label: trans.__('Create New Workspace…'), + execute: async args => { + let workspaceId = args.workspace as string | undefined; + + if (!workspaceId) { + const result = await getNameForWorkspace({ + title: trans.__('Create New Workspace'), + okLabel: trans.__('Create') + }); + if (!result.value || !result.button.accept) { + return; + } + workspaceId = result.value; + } + if (!workspaceId) { + return; + } + await model.create(workspaceId); + } + }); + + app.commands.addCommand(CommandIDs.clone, { + label: trans.__('Clone Workspace…'), + execute: async args => { + const node = app.contextMenuHitTest(test); + let workspaceId = + (args.workspace as string | undefined) ?? node?.dataset['context']; + if (!workspaceId) { + const result = await InputDialog.getItem({ + title: trans.__('Choose Workspace To Clone'), + label: trans.__('Choose an existing workspace to clone.'), + items: model.identifiers, + okLabel: trans.__('Choose') + }); + if (!result.value || !result.button.accept) { + return; + } + workspaceId = result.value; + } + + const result = await getNameForWorkspace({ + title: trans.__('Clone Workspace'), + text: trans.__('%1-clone', workspaceId), + okLabel: trans.__('Clone') + }); + + if (!result.button.accept || !result.value) { + return; + } + + let newName = result.value; + + await model.saveAs(workspaceId, newName); + + if (workspaceId === resolver.name) { + // If the current workspace was cloned, open the cloned copy + return app.commands.execute(CommandIDs.open, { workspace: newName }); + } + } + }); + + app.commands.addCommand(CommandIDs.rename, { + label: trans.__('Rename Workspace…'), + execute: async args => { + const node = app.contextMenuHitTest(test); + const workspaceId = + (args.workspace as string | undefined) ?? + node?.dataset['context'] ?? + resolver.name; + + const oldName = workspaceId; + const result = await getNameForWorkspace({ + title: trans.__('Rename Workspace'), + text: oldName, + okLabel: trans.__('Rename') + }); + + if (!result.button.accept || !result.value) { + return; + } + + let newName = result.value; + + await model.rename(workspaceId, newName); + + if (workspaceId === resolver.name) { + // If the current workspace was renamed, reopen it to ensure consistent state + return app.commands.execute(CommandIDs.open, { workspace: newName }); + } + } + }); + + app.commands.addCommand(CommandIDs.reset, { + label: trans.__('Reset Workspace…'), + execute: async args => { + const node = app.contextMenuHitTest(test); + const workspaceId = + (args.workspace as string | undefined) ?? + node?.dataset['context'] ?? + resolver.name; + + const workspace = + await app.serviceManager.workspaces.fetch(workspaceId); + const tabs = (workspace.data['layout-restorer:data'] as any)?.main?.dock + ?.widgets?.length; + + const result = await showDialog({ + title: trans.__('Reset Workspace'), + body: trans._n( + 'Resetting workspace %2 will close its %1 tab and return to default layout.', + 'Resetting workspace %2 will close its %1 tabs and return to default layout.', + tabs, + workspaceId + ), + buttons: [ + Dialog.cancelButton(), + Dialog.warnButton({ label: trans.__('Reset') }) + ], + defaultButton: 0 + }); + + if (!result.button.accept) { + return; + } + + await model.reset(workspaceId); + + if (workspaceId === resolver.name) { + // If the current workspace was reset, refresh it to take effect + return app.commands.execute(CommandIDs.open, { + workspace: workspaceId + }); + } else { + await model.refresh(); + } + } + }); + + /** + * Commands persisting (or reading from) Jupyter file system + */ + + app.commands.addCommand(CommandIDs.importWorkspace, { + label: trans.__('Import Workspace…'), + execute: async () => { + const { contents } = app.serviceManager; + const result = await FileDialog.getOpenFiles({ + manager: fileBrowser.model.manager, + title: trans.__('Select Workspace Files to Import'), + filter: (model: Contents.IModel) => + model.type === 'directory' || model.path.endsWith(WORKSPACE_EXT) + ? {} + : null, + label: trans.__( + 'You choose one or more workspace files to import. A Jupyter Workspace File has %1 extension.', + WORKSPACE_EXT + ), + translator + }); + if (result.button.accept && result.value && result.value.length >= 1) { + for (const fileModel of result.value) { + // Get the file contents too + const fullFile = await contents.get(fileModel.path, { + content: true + }); + const workspace = JSON.parse( + fullFile.content + ) as unknown as Workspace.IWorkspace; + await app.serviceManager.workspaces.save( + workspace.metadata.id, + workspace + ); + } + await model.refresh(); + } + } + }); + + app.commands.addCommand(CommandIDs.exportWorkspace, { + label: trans.__('Export Workspace…'), + execute: async args => { + const { contents } = app.serviceManager; + + const node = app.contextMenuHitTest(test); + let workspaceId = + (args.workspace as string | undefined) ?? + node?.dataset['context'] ?? + resolver.name; + + if (!workspaceId) { + // When invoked from main menu or command palette we need to ask which workspace to export + const result = await InputDialog.getItem({ + title: trans.__('Choose Workspace To Export'), + label: trans.__('Choose an existing workspace to export.'), + items: model.identifiers, + okLabel: trans.__('Choose') + }); + if (!result.value || !result.button.accept) { + return; + } + workspaceId = result.value; + } + + const data = app.serviceManager.workspaces.fetch(workspaceId); + + const result = await FileDialog.getExistingDirectory({ + title: trans.__('Choose Workspace Export Directory'), + defaultPath: fileBrowser.model.path, + manager: fileBrowser.model.manager, + label: trans.__( + 'The "%1" workspace will be saved in the chosen directory as "%1%2".', + workspaceId, + WORKSPACE_EXT + ), + translator + }); + if ( + !result.button.accept || + !result.value || + result.value.length === 0 + ) { + return; + } + if (result.value.length > 1) { + console.warn( + 'More than one directory was selected; the workspace will be exported to the first directory only' + ); + } + const exportPath = + result.value[0].path + '/' + workspaceId + WORKSPACE_EXT; + + if (exportPath) { + await Private.save(exportPath, contents, data, state, false); + } + } + }); + + app.commands.addCommand(CommandIDs.saveAs, { + label: trans.__('Save Current Workspace As…'), + execute: async () => { + const { contents } = app.serviceManager; + const data = app.serviceManager.workspaces.fetch(resolver.name); + await Private.saveAs(fileBrowser, contents, data, state, translator); + } + }); + + app.commands.addCommand(CommandIDs.save, { + label: trans.__('Save Current Workspace…'), + execute: async () => { + const { contents } = app.serviceManager; + const data = app.serviceManager.workspaces.fetch(resolver.name); + const lastSave = (await state.fetch(LAST_SAVE_ID)) as string; + if (lastSave === undefined) { + await Private.saveAs(fileBrowser, contents, data, state, translator); + } else { + await Private.save(lastSave, contents, data, state); + } + } + }); + + if (palette) { + const category = trans.__('Workspaces'); + const commands = [ + CommandIDs.open, + CommandIDs.save, + CommandIDs.saveAs, + CommandIDs.createNew, + CommandIDs.rename, + CommandIDs.clone, + CommandIDs.exportWorkspace, + CommandIDs.importWorkspace, + CommandIDs.reset, + CommandIDs.deleteWorkspace + ]; + for (const command of commands) { + palette.addItem({ + command, + category + }); + } + } + + return { + open: CommandIDs.open, + deleteWorkspace: CommandIDs.deleteWorkspace + }; + } +}; + +namespace Private { + export function createNameFromPath(path: string): string { + let name = path.split('/').pop(); + if (name === undefined) { + return 'unnamed-workspace'; + } + // Remove the workspace suffix if present + if (name.endsWith(WORKSPACE_EXT)) { + name = name.slice(0, -WORKSPACE_EXT.length); + } + return name; + } + + /** + * Save workspace to a user provided location + */ + export async function save( + userPath: string, + contents: Contents.IManager, + data: Promise, + state: IStateDB, + rememberAsLastSave = true + ): Promise { + const name = createNameFromPath(userPath); + + // Add extension if not provided + if (!userPath.endsWith(WORKSPACE_EXT)) { + userPath = userPath + WORKSPACE_EXT; + } + + if (rememberAsLastSave) { + // Save last save location, for save button to work + await state.save(LAST_SAVE_ID, userPath); + } + + const resolvedData = await data; + resolvedData.metadata.id = `${name}`; + await contents.save(userPath, { + type: 'file', + format: 'text', + content: JSON.stringify(resolvedData) + }); + } + + /** + * Ask user for location, and save workspace. + * Default location is the current directory in the file browser + */ + export async function saveAs( + browser: IDefaultFileBrowser, + contents: Contents.IManager, + data: Promise, + state: IStateDB, + translator?: ITranslator + ): Promise { + translator = translator || nullTranslator; + const lastSave = await state.fetch(LAST_SAVE_ID); + + let defaultName; + if (lastSave === undefined) { + defaultName = 'new-workspace'; + } else { + defaultName = (lastSave as string).split('/').pop()?.split('.')[0]; + } + + const defaultPath = browser.model.path + '/' + defaultName + WORKSPACE_EXT; + const userPath = await getSavePath(defaultPath, translator); + + if (userPath) { + await save(userPath, contents, data, state); + } + } + + /** + * Ask user for a path to save to. + * @param defaultPath Path already present when the dialog is shown + */ + async function getSavePath( + defaultPath: string, + translator?: ITranslator + ): Promise { + translator = translator || nullTranslator; + const trans = translator.load('jupyterlab'); + const result = await InputDialog.getText({ + title: trans.__('Save Current Workspace As…'), + text: defaultPath, + placeholder: trans.__('Path to save the workspace in'), + okLabel: trans.__('Save'), + // select the path without extension + selectionRange: defaultPath.length - WORKSPACE_EXT.length + }); + if (result.button.accept) { + return result.value; + } else { + return null; + } + } +} diff --git a/packages/workspaces-extension/src/index.ts b/packages/workspaces-extension/src/index.ts new file mode 100644 index 000000000000..f03557b29ad9 --- /dev/null +++ b/packages/workspaces-extension/src/index.ts @@ -0,0 +1,58 @@ +/* ----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ +/** + * @packageDocumentation + * @module workspaces-extension + */ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { + IWorkspaceCommands, + IWorkspacesModel, + WorkspacesModel +} from '@jupyterlab/workspaces'; +import { commandsPlugin } from './commands'; +import { workspacesSidebar } from './sidebar'; + +/** + * The extension populating sidebar with workspaces list. + */ +const workspacesModel: JupyterFrontEndPlugin = { + id: '@jupyterlab/workspaces-extension:model', + description: 'Provides a model for available workspaces.', + provides: IWorkspacesModel, + autoStart: true, + activate: (app: JupyterFrontEnd) => { + return new WorkspacesModel({ + manager: app.serviceManager.workspaces + }); + } +}; + +/** + * The extension providing workspace sub-menu in the "File" main menu. + */ +const workspacesMenu: JupyterFrontEndPlugin = { + id: '@jupyterlab/workspaces-extension:menu', + description: 'Populates "File" main menu with Workspaces submenu.', + requires: [IWorkspaceCommands], + autoStart: true, + activate: () => { + // no-op - the menu items come from schema matching the name of the plugin + } +}; + +/** + * Export the plugins as default. + */ +const plugins: JupyterFrontEndPlugin[] = [ + workspacesModel, + commandsPlugin, + workspacesSidebar, + workspacesMenu +]; +export default plugins; diff --git a/packages/workspaces-extension/src/sidebar.ts b/packages/workspaces-extension/src/sidebar.ts new file mode 100644 index 000000000000..7d6ce26ae139 --- /dev/null +++ b/packages/workspaces-extension/src/sidebar.ts @@ -0,0 +1,106 @@ +/* ----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ +import { + JupyterFrontEnd, + JupyterFrontEndPlugin +} from '@jupyterlab/application'; +import { IWindowResolver } from '@jupyterlab/apputils'; +import { IWorkspaceCommands, IWorkspacesModel } from '@jupyterlab/workspaces'; +import { IRunningSessionManagers, IRunningSessions } from '@jupyterlab/running'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; +import { Workspace } from '@jupyterlab/services'; +import { WORKSPACE_ITEM_CLASS } from './commands'; +import { blankIcon, checkIcon, deleteIcon } from '@jupyterlab/ui-components'; + +/** + * The extension populating sidebar with workspaces list. + */ +export const workspacesSidebar: JupyterFrontEndPlugin = { + id: '@jupyterlab/workspaces-extension:sidebar', + description: 'Populates running sidebar with workspaces.', + requires: [ + IWorkspaceCommands, + IWorkspacesModel, + IRunningSessionManagers, + IWindowResolver + ], + optional: [ITranslator], + autoStart: true, + activate: async ( + app: JupyterFrontEnd, + commands: IWorkspaceCommands, + model: IWorkspacesModel, + managers: IRunningSessionManagers, + resolver: IWindowResolver, + translator: ITranslator | null + ) => { + const trans = (translator ?? nullTranslator).load('jupyterlab'); + + class WorkspaceItem implements IRunningSessions.IRunningItem { + constructor(workspace: Workspace.IWorkspace) { + this._workspace = workspace; + this.context = workspace.metadata.id; + this.className = WORKSPACE_ITEM_CLASS; + } + readonly className: string; + readonly context: string; + open() { + return app.commands.execute(commands.open, { + workspace: this._workspace.metadata.id + }); + } + async shutdown() { + await app.commands.execute(commands.deleteWorkspace, { + workspace: this._workspace.metadata.id + }); + await model.refresh(); + } + icon() { + return resolver.name === this._workspace.metadata.id + ? checkIcon + : blankIcon; + } + label() { + return this._workspace.metadata.id; + } + labelTitle() { + return trans.__( + '%1 workspace with %2 tabs, last modified on %3', + this._workspace.metadata.id, + (this._workspace.data['layout-restorer:data'] as any)?.main?.dock + ?.widgets?.length, + this._workspace.metadata['last_modified'] + ); + } + + private _workspace: Workspace.IWorkspace; + } + managers.add({ + name: trans.__('Workspaces'), + running: () => { + return model.workspaces.map((workspace: Workspace.IWorkspace) => { + return new WorkspaceItem(workspace); + }); + }, + shutdownAll: async () => { + await Promise.all( + model.workspaces.map(workspace => model.remove(workspace.metadata.id)) + ); + await model.refresh(); + }, + shutdownItemIcon: deleteIcon, + refreshRunning: async () => { + await model.refresh(); + }, + runningChanged: model.refreshed, + shutdownLabel: (item: IRunningSessions.IRunningItem) => + trans.__('Delete %1', item.label() as string), + shutdownAllLabel: trans.__('Delete All'), + shutdownAllConfirmationText: trans.__( + 'Are you sure you want to delete all workspaces? Deleted workspaces cannot be recovered.' + ) + }); + } +}; diff --git a/packages/workspaces-extension/style/index.css b/packages/workspaces-extension/style/index.css new file mode 100644 index 000000000000..ba29095a4d79 --- /dev/null +++ b/packages/workspaces-extension/style/index.css @@ -0,0 +1,11 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +@import url('~@jupyterlab/ui-components/style/index.css'); +@import url('~@jupyterlab/apputils/style/index.css'); +@import url('~@jupyterlab/application/style/index.css'); +@import url('~@jupyterlab/filebrowser/style/index.css'); +@import url('~@jupyterlab/running/style/index.css'); diff --git a/packages/workspaces-extension/style/index.js b/packages/workspaces-extension/style/index.js new file mode 100644 index 000000000000..be7aaa35e896 --- /dev/null +++ b/packages/workspaces-extension/style/index.js @@ -0,0 +1,11 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) Jupyter Development Team. +| Distributed under the terms of the Modified BSD License. +|----------------------------------------------------------------------------*/ + +/* This file was auto-generated by ensurePackage() in @jupyterlab/buildutils */ +import '@jupyterlab/ui-components/style/index.js'; +import '@jupyterlab/apputils/style/index.js'; +import '@jupyterlab/application/style/index.js'; +import '@jupyterlab/filebrowser/style/index.js'; +import '@jupyterlab/running/style/index.js'; diff --git a/packages/workspaces-extension/tsconfig.json b/packages/workspaces-extension/tsconfig.json new file mode 100644 index 000000000000..41afbba29bcc --- /dev/null +++ b/packages/workspaces-extension/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src/*"], + "references": [ + { + "path": "../application" + }, + { + "path": "../apputils" + }, + { + "path": "../coreutils" + }, + { + "path": "../filebrowser" + }, + { + "path": "../running" + }, + { + "path": "../services" + }, + { + "path": "../statedb" + }, + { + "path": "../translation" + }, + { + "path": "../ui-components" + }, + { + "path": "../workspaces" + } + ] +} diff --git a/packages/workspaces/.vscode/launch.json b/packages/workspaces/.vscode/launch.json new file mode 100644 index 000000000000..64126e422f32 --- /dev/null +++ b/packages/workspaces/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "attach", + "name": "Attach to jest", + // Usage: + // Open the parent directory in VSCode + // Run \`jlpm test:debug:watch\` in a terminal + // Run this debugging task + "port": 9229 + } + ] +} diff --git a/packages/workspaces/babel.config.js b/packages/workspaces/babel.config.js new file mode 100644 index 000000000000..eb2198a956e7 --- /dev/null +++ b/packages/workspaces/babel.config.js @@ -0,0 +1,6 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +module.exports = require('@jupyterlab/testing/lib/babel-config'); diff --git a/packages/workspaces/jest.config.js b/packages/workspaces/jest.config.js new file mode 100644 index 000000000000..cd234acbbdc0 --- /dev/null +++ b/packages/workspaces/jest.config.js @@ -0,0 +1,7 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +const func = require('@jupyterlab/testing/lib/jest-config'); +module.exports = func(__dirname); diff --git a/packages/workspaces/package.json b/packages/workspaces/package.json new file mode 100644 index 000000000000..1ff455a5a6e3 --- /dev/null +++ b/packages/workspaces/package.json @@ -0,0 +1,59 @@ +{ + "name": "@jupyterlab/workspaces", + "version": "4.2.0-alpha.1", + "description": "JupyterLab workspaces management", + "homepage": "https://github.com/jupyterlab/jupyterlab", + "bugs": { + "url": "https://github.com/jupyterlab/jupyterlab/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/jupyterlab/jupyterlab.git" + }, + "license": "BSD-3-Clause", + "author": "Project Jupyter", + "sideEffects": [ + "style/**/*" + ], + "main": "lib/index.js", + "types": "lib/index.d.ts", + "directories": { + "lib": "lib/" + }, + "files": [ + "lib/**/*.{d.ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}", + "schema/*.json", + "style/**/*.{css,eot,gif,html,jpg,json,png,svg,woff2,ttf}", + "src/**/*.{ts,tsx}", + "style/index.js" + ], + "scripts": { + "build": "tsc -b", + "build:test": "tsc --build tsconfig.test.json", + "clean": "rimraf lib tsconfig.tsbuildinfo", + "docs": "typedoc src", + "test": "jest", + "test:cov": "jest --collect-coverage", + "test:debug": "node --inspect-brk ../../node_modules/.bin/jest --runInBand", + "test:debug:watch": "node --inspect-brk ../../node_modules/.bin/jest --runInBand --watch", + "watch": "tsc -b --watch" + }, + "dependencies": { + "@jupyterlab/services": "^7.2.0-alpha.1", + "@lumino/coreutils": "^2.1.2", + "@lumino/disposable": "^2.1.2", + "@lumino/polling": "^2.1.2", + "@lumino/signaling": "^2.1.2" + }, + "devDependencies": { + "@jupyterlab/testing": "^4.2.0-alpha.1", + "@types/jest": "^29.2.0", + "jest": "^29.2.0", + "rimraf": "~5.0.5", + "typedoc": "~0.24.7", + "typescript": "~5.1.6" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/workspaces/src/index.ts b/packages/workspaces/src/index.ts new file mode 100644 index 000000000000..f1591abaa600 --- /dev/null +++ b/packages/workspaces/src/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +/** + * @packageDocumentation + * @module workspaces + */ + +export * from './model'; +export * from './tokens'; diff --git a/packages/workspaces/src/model.ts b/packages/workspaces/src/model.ts new file mode 100644 index 000000000000..51dc804b3afb --- /dev/null +++ b/packages/workspaces/src/model.ts @@ -0,0 +1,179 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { Poll } from '@lumino/polling'; +import { ISignal, Signal } from '@lumino/signaling'; +import { Workspace } from '@jupyterlab/services'; +import { IWorkspacesModel } from './tokens'; + +/** + * The default duration of the auto-refresh in ms + */ +const DEFAULT_REFRESH_INTERVAL = 10000; + +/** + * An implementation of a workspaces model. + */ +export class WorkspacesModel implements IWorkspacesModel { + constructor(options: WorkspacesModel.IOptions) { + this._manager = options.manager; + const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL; + this._poll = new Poll({ + auto: options.auto ?? true, + name: '@jupyterlab/workspaces:Model', + factory: () => this._fetchList(), + frequency: { + interval: refreshInterval, + backoff: true, + max: 300 * 1000 + }, + standby: options.refreshStandby || 'when-hidden' + }); + } + + /** + * The list of available workspaces. + */ + get workspaces(): Workspace.IWorkspace[] { + return this._workspaceData.values; + } + + /** + * The list of workspace identifiers. + */ + get identifiers(): string[] { + return this._workspaceData.ids; + } + + /** + * Create an empty workspace. + */ + async create(workspaceId: string): Promise { + await this._manager.save(workspaceId, { + metadata: { id: workspaceId }, + data: {} + }); + await this.refresh(); + } + + /** + * A signal emitted when the workspaces list is refreshed. + */ + get refreshed(): ISignal { + return this._refreshed; + } + + /** + * Force a refresh of the workspaces list. + */ + async refresh(): Promise { + await this._poll.refresh(); + await this._poll.tick; + } + + /** + * Rename a workspace. + */ + async rename(workspaceId: string, newName: string): Promise { + const workspace = await this._manager.fetch(workspaceId); + workspace.metadata.id = newName; + + await this._manager.save(newName, workspace); + await this._manager.remove(workspaceId); + await this.refresh(); + } + + /** + * Reset a workspace. + */ + async reset(workspaceId: string): Promise { + const workspace = await this._manager.fetch(workspaceId); + workspace.data = {}; + await this._manager.save(workspaceId, workspace); + await this.refresh(); + } + + /** + * Remove a workspace. + */ + async remove(workspaceId: string): Promise { + await this._manager.remove(workspaceId); + await this.refresh(); + } + + /** + * Save workspace under a different name. + */ + async saveAs(workspaceId: string, newName: string): Promise { + const data = await this._manager.fetch(workspaceId); + data.metadata.id = newName; + await this._manager.save(newName, data); + await this.refresh(); + } + + /** + * Get whether the model is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Dispose of the resources held by the model. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + this._poll.dispose(); + Signal.clearData(this); + } + + private async _fetchList() { + this._workspaceData = await this._manager.list(); + this._refreshed.emit(void 0); + } + + private _refreshed = new Signal(this); + private _isDisposed = false; + private _poll: Poll; + private _manager: Workspace.IManager; + private _workspaceData: { ids: string[]; values: Workspace.IWorkspace[] } = { + ids: [], + values: [] + }; +} + +/** + * The namespace for the `WorkspacesModel` class statics. + */ +export namespace WorkspacesModel { + /** + * An options object for initializing a the workspaces model. + */ + export interface IOptions { + /** + * The workspaces manager. + */ + manager: Workspace.IManager; + + /** + * Whether a to automatically loads initial list of workspaces. + * The default is `true`. + */ + auto?: boolean; + + /** + * The time interval for browser refreshing, in ms. + */ + refreshInterval?: number; + + /** + * When the model stops polling the API. Defaults to `when-hidden`. + */ + refreshStandby?: Poll.Standby | (() => boolean | Poll.Standby); + } +} diff --git a/packages/workspaces/src/tokens.ts b/packages/workspaces/src/tokens.ts new file mode 100644 index 000000000000..c5fc6f328dc7 --- /dev/null +++ b/packages/workspaces/src/tokens.ts @@ -0,0 +1,86 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { Token } from '@lumino/coreutils'; +import { IDisposable } from '@lumino/disposable'; +import { ISignal } from '@lumino/signaling'; +import type { Workspace } from '@jupyterlab/services'; + +/** + * The token that provides the identifiers of workspace commands for reuse. + */ +export const IWorkspaceCommands = new Token( + '@jupyterlab/workspaces:IWorkspaceCommands', + 'Provides identifiers of workspace commands.' +); + +/** + * The identifiers of loaded commands exposed for reuse. + */ +export interface IWorkspaceCommands { + /** + * Command for opening a workspace by identifier. + */ + open: string; + /** + * Command for deleting a workspace. + */ + deleteWorkspace: string; +} + +/** + * The token that provides the identifiers of workspace commands for reuse. + */ +export const IWorkspacesModel = new Token( + '@jupyterlab/workspaces:IWorkspacesModel', + 'Provides a model for available workspaces.' +); + +/** + * The model for listing available workspaces. + */ +export interface IWorkspacesModel extends IDisposable { + /** + * The list of available workspaces. + */ + readonly workspaces: Workspace.IWorkspace[]; + + /** + * The list of workspace identifiers. + */ + readonly identifiers: string[]; + + /** + * Create an empty workspace. + */ + create(workspaceId: string): Promise; + + /** + * Rename a workspace. + */ + rename(workspaceId: string, newName: string): Promise; + + /** + * Refresh the listing of workspaces. + */ + refresh(): Promise; + + /** + * Signal emitted when the listing is refreshed. + */ + refreshed: ISignal; + + /** + * Reset a workspace. + */ + reset(workspaceId: string): Promise; + + /** + * Remove a workspace. + */ + remove(workspaceId: string): Promise; + + /** + * Save workspace under a different name. + */ + saveAs(workspaceId: string, newName: string): Promise; +} diff --git a/packages/workspaces/test/model.spec.ts b/packages/workspaces/test/model.spec.ts new file mode 100644 index 000000000000..a1547f0b71d2 --- /dev/null +++ b/packages/workspaces/test/model.spec.ts @@ -0,0 +1,178 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { signalToPromise } from '@jupyterlab/coreutils'; +import { ServerConnection, WorkspaceManager } from '@jupyterlab/services'; +import { JupyterServer } from '@jupyterlab/testing'; +import { WorkspacesModel } from '@jupyterlab/workspaces'; + +const server = new JupyterServer(); + +beforeAll(async () => { + await server.start(); +}, 30000); + +afterAll(async () => { + await server.shutdown(); +}); + +const WORKSPACE_DATA = { + 'layout-restorer:data': { + main: { + dock: { + type: 'tab-area', + currentIndex: 0, + widgets: ['editor:README.md'] + }, + current: 'editor:README.md' + }, + down: { size: 0, widgets: [] }, + left: { + collapsed: false, + visible: true, + current: 'running-sessions', + widgets: ['filebrowser', 'running-sessions', 'extensionmanager.main-view'] + }, + right: { + collapsed: true, + visible: true, + widgets: [ + 'jp-property-inspector', + '@jupyterlab/toc:plugin', + 'debugger-sidebar' + ] + }, + relativeSizes: [0.3, 0.7, 0], + top: { simpleVisibility: true } + }, + 'editor:README.md': { data: { path: 'README.md', factory: 'Editor' } } +}; + +describe('@jupyterlab/workspaces', () => { + describe('WorkspacesModel', () => { + let model: WorkspacesModel; + let manager: WorkspaceManager; + + beforeEach(async () => { + manager = new WorkspaceManager({ + serverSettings: ServerConnection.makeSettings({ appUrl: 'lab' }) + }); + model = new WorkspacesModel({ manager }); + + await manager.save('foo', { + metadata: { id: 'foo' }, + data: WORKSPACE_DATA + }); + await manager.save('bar', { + metadata: { id: 'bar' }, + data: WORKSPACE_DATA + }); + await model.refresh(); + }); + + afterEach(async () => { + const workspaces = await manager.list(); + await Promise.all(workspaces.ids.map(id => manager.remove(id))); + }); + + describe('#constructor()', () => { + it('should allow changing refresh options', async () => { + model = new WorkspacesModel({ + manager, + refreshInterval: 50, + refreshStandby: () => false + }); + await expect( + signalToPromise(model.refreshed) + ).resolves.not.toBeUndefined(); + }, 1000); + }); + + describe('#workspaces', () => { + it('should list saved workspaces', async () => { + expect(model.workspaces).toHaveLength(2); + const ids = model.workspaces.map(w => w.metadata.id); + expect(ids).toContain('foo'); + expect(ids).toContain('bar'); + }); + }); + + describe('#identifiers', () => { + it('should list identifiers of existing workspaces', async () => { + expect(model.identifiers).toHaveLength(2); + expect(model.identifiers).toContain('foo'); + expect(model.identifiers).toContain('bar'); + }); + }); + + describe('#create', () => { + it('should create an empty workspace', async () => { + expect(model.workspaces).toHaveLength(2); + await model.create('foobar'); + expect(model.workspaces).toHaveLength(3); + expect(model.identifiers).toContain('foobar'); + }); + }); + + describe('#refresh()', () => { + it('should update the list of workspaces and identifiers', async () => { + await manager.save('foobar', { + metadata: { id: 'foobar' }, + data: WORKSPACE_DATA + }); + expect(model.identifiers).toHaveLength(2); + await model.refresh(); + expect(model.identifiers).toHaveLength(3); + }); + + it('should emit `refreshed` signal', async () => { + let emitted = false; + model.refreshed.connect(() => (emitted = true)); + await model.refresh(); + expect(emitted).toBe(true); + }); + }); + + describe('#rename()', () => { + it('should rename the given workspace', async () => { + await model.rename('foo', 'FOO'); + expect(model.identifiers).toContain('FOO'); + expect(model.identifiers).not.toContain('foo'); + }); + }); + + describe('#remove()', () => { + it('should remove the given workspace', async () => { + expect(model.workspaces).toHaveLength(2); + await model.remove('foo'); + expect(model.workspaces).toHaveLength(1); + expect(model.identifiers).not.toContain('foo'); + }); + }); + + describe('#reset()', () => { + it('should clear the data of the workspace', async () => { + let foo = model.workspaces.filter( + workspace => workspace.metadata.id == 'foo' + )[0]; + expect(foo.data).toHaveProperty('layout-restorer:data'); + await model.reset('foo'); + foo = model.workspaces.filter( + workspace => workspace.metadata.id == 'foo' + )[0]; + expect(foo.data).not.toHaveProperty('layout-restorer:data'); + }); + }); + + describe('#saveAs()', () => { + it('should save the workspace under a different name', async () => { + expect(model.workspaces).toHaveLength(2); + await model.saveAs('foo', 'fooBar'); + expect(model.workspaces).toHaveLength(3); + expect(model.identifiers).toContain('fooBar'); + }); + }); + }); +}); diff --git a/packages/workspaces/tsconfig.json b/packages/workspaces/tsconfig.json new file mode 100644 index 000000000000..ef92f80511f5 --- /dev/null +++ b/packages/workspaces/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfigbase", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src/*"], + "references": [ + { + "path": "../services" + } + ] +} diff --git a/packages/workspaces/tsconfig.test.json b/packages/workspaces/tsconfig.test.json new file mode 100644 index 000000000000..8c726a504064 --- /dev/null +++ b/packages/workspaces/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfigbase.test", + "include": ["src/*", "test/*"], + "references": [ + { + "path": "../services" + }, + { + "path": "." + }, + { + "path": "../testing" + } + ] +} diff --git a/tsconfigdoc.json b/tsconfigdoc.json index 23f2e3353b55..5d0e5af65d6e 100644 --- a/tsconfigdoc.json +++ b/tsconfigdoc.json @@ -300,6 +300,12 @@ }, { "path": "./packages/vega5-extension" + }, + { + "path": "./packages/workspaces" + }, + { + "path": "./packages/workspaces-extension" } ] } diff --git a/yarn.lock b/yarn.lock index da6c791617eb..c1fdf7bb3c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2175,6 +2175,7 @@ __metadata: "@jupyterlab/translation-extension": ~4.2.0-alpha.1 "@jupyterlab/ui-components-extension": ~4.2.0-alpha.1 "@jupyterlab/vega5-extension": ~4.2.0-alpha.1 + "@jupyterlab/workspaces-extension": ~4.2.0-alpha.1 chokidar: ^3.4.0 css-loader: ^6.7.1 duplicate-package-checker-webpack-plugin: ^3.0.0 @@ -2241,7 +2242,6 @@ __metadata: "@jupyterlab/apputils": ^4.3.0-alpha.1 "@jupyterlab/coreutils": ^6.2.0-alpha.1 "@jupyterlab/docregistry": ^4.2.0-alpha.1 - "@jupyterlab/filebrowser": ^4.2.0-alpha.1 "@jupyterlab/mainmenu": ^4.2.0-alpha.1 "@jupyterlab/rendermime-interfaces": ^3.10.0-alpha.1 "@jupyterlab/services": ^7.2.0-alpha.1 @@ -2250,6 +2250,7 @@ __metadata: "@jupyterlab/statusbar": ^4.2.0-alpha.1 "@jupyterlab/translation": ^4.2.0-alpha.1 "@jupyterlab/ui-components": ^4.2.0-alpha.1 + "@jupyterlab/workspaces": ^4.2.0-alpha.1 "@lumino/algorithm": ^2.0.1 "@lumino/commands": ^2.2.0 "@lumino/coreutils": ^2.1.2 @@ -4154,6 +4155,8 @@ __metadata: "@jupyterlab/ui-components": ^4.2.0-alpha.1 "@jupyterlab/ui-components-extension": ^4.2.0-alpha.1 "@jupyterlab/vega5-extension": ^4.2.0-alpha.1 + "@jupyterlab/workspaces": ^4.2.0-alpha.1 + "@jupyterlab/workspaces-extension": ^4.2.0-alpha.1 "@types/jest": ^29.2.0 fs-extra: ^10.1.0 jest: ^29.2.0 @@ -5064,6 +5067,44 @@ __metadata: languageName: unknown linkType: soft +"@jupyterlab/workspaces-extension@^4.2.0-alpha.1, @jupyterlab/workspaces-extension@workspace:packages/workspaces-extension, @jupyterlab/workspaces-extension@~4.2.0-alpha.1": + version: 0.0.0-use.local + resolution: "@jupyterlab/workspaces-extension@workspace:packages/workspaces-extension" + dependencies: + "@jupyterlab/application": ^4.2.0-alpha.1 + "@jupyterlab/apputils": ^4.3.0-alpha.1 + "@jupyterlab/coreutils": ^6.2.0-alpha.1 + "@jupyterlab/filebrowser": ^4.2.0-alpha.1 + "@jupyterlab/running": ^4.2.0-alpha.1 + "@jupyterlab/services": ^7.2.0-alpha.1 + "@jupyterlab/statedb": ^4.2.0-alpha.1 + "@jupyterlab/translation": ^4.2.0-alpha.1 + "@jupyterlab/ui-components": ^4.2.0-alpha.1 + "@jupyterlab/workspaces": ^4.2.0-alpha.1 + "@types/jest": ^29.2.0 + rimraf: ~5.0.5 + typescript: ~5.1.6 + languageName: unknown + linkType: soft + +"@jupyterlab/workspaces@^4.2.0-alpha.1, @jupyterlab/workspaces@workspace:packages/workspaces": + version: 0.0.0-use.local + resolution: "@jupyterlab/workspaces@workspace:packages/workspaces" + dependencies: + "@jupyterlab/services": ^7.2.0-alpha.1 + "@jupyterlab/testing": ^4.2.0-alpha.1 + "@lumino/coreutils": ^2.1.2 + "@lumino/disposable": ^2.1.2 + "@lumino/polling": ^2.1.2 + "@lumino/signaling": ^2.1.2 + "@types/jest": ^29.2.0 + jest: ^29.2.0 + rimraf: ~5.0.5 + typedoc: ~0.24.7 + typescript: ~5.1.6 + languageName: unknown + linkType: soft + "@lerna/child-process@npm:7.1.4": version: 7.1.4 resolution: "@lerna/child-process@npm:7.1.4"