diff --git a/.gitignore b/.gitignore index a3d8974b5a59..d12c3f89ffbc 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,7 @@ __pycache__ pip-wheel-metadata Pipfile Pipfile.lock +venv/ # xeus-python debug logs xpython_debug_logs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3c4c59f06ede..1be3f364df82 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,18 +27,18 @@ repos: exclude: (.bumpversion.cfg|yarn.js) - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.26.3 + rev: 0.27.0 hooks: - id: check-github-workflows - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black # Check ruff version is aligned with the one in pyproject.toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.291 + rev: v0.0.292 hooks: - id: ruff args: ["--fix"] diff --git a/builder/src/build.ts b/builder/src/build.ts index ae965a0ddcb1..23060c2d15f6 100644 --- a/builder/src/build.ts +++ b/builder/src/build.ts @@ -205,7 +205,7 @@ export namespace Build { rules: [ { test: /\.css$/, - use: [MiniCssExtractPlugin.loader, 'css-loader'] + use: [MiniCssExtractPlugin.loader, require.resolve('css-loader')] }, { test: /\.svg/, diff --git a/builder/src/extensionConfig.ts b/builder/src/extensionConfig.ts index 49f733305b28..389f59374b1b 100644 --- a/builder/src/extensionConfig.ts +++ b/builder/src/extensionConfig.ts @@ -257,7 +257,7 @@ function generateConfig({ rules.push({ test: /\.js$/, enforce: 'pre', - use: ['source-map-loader'] + use: [require.resolve('source-map-loader')] }); } diff --git a/builder/src/webpack.config.base.ts b/builder/src/webpack.config.base.ts index 6bd8738d20db..7d598a64f56b 100644 --- a/builder/src/webpack.config.base.ts +++ b/builder/src/webpack.config.base.ts @@ -5,7 +5,10 @@ import miniSVGDataURI from 'mini-svg-data-uri'; const rules = [ { test: /\.raw\.css$/, type: 'asset/source' }, - { test: /(? None: "use_edit_page_button": True, "navbar_align": "left", "navbar_start": ["navbar-logo", "version-switcher"], + "navigation_with_keys": False, "footer_start": ["copyright.html"], "switcher": { # Trick to get the documentation version switcher to always points to the latest version without being corrected by the integrity check; diff --git a/docs/source/developer/contributing.rst b/docs/source/developer/contributing.rst index 3de7e0d99030..6d7923254d43 100644 --- a/docs/source/developer/contributing.rst +++ b/docs/source/developer/contributing.rst @@ -378,7 +378,7 @@ Other available commands: .. code:: bash - bash docker/start.sh dev # Same as calling bash docker/start.sh + bash docker/start.sh dev 4567 # Start JupyterLab dev container at port 4567 bash docker/start.sh stop # Stop the running container bash docker/start.sh clean # Remove the docker image bash docker/start.sh build # Rebuild the docker image diff --git a/docs/source/developer/repo.rst b/docs/source/developer/repo.rst index 9394a7647f7c..c4da9b19b2f2 100644 --- a/docs/source/developer/repo.rst +++ b/docs/source/developer/repo.rst @@ -61,7 +61,7 @@ The ``lab-dev`` endpoint is the equivalent of checking out the repo locally and The ``lab-spliced`` endpoint is the equivalent of building JupyterLab in spliced mode and running ``jupyter lab``. See the `Development workflow for source extensions <../extension/extension_dev.html#development-workflow-for-source-extensions>`__ for more information on spliced mode. -Build utilities: ``builtutils/`` +Build utilities: ``buildutils/`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ An ``npm`` package that contains several utility scripts for managing diff --git a/docs/source/extension/extension_points.rst b/docs/source/extension/extension_points.rst index 4907c8c1571d..fd60609b975f 100644 --- a/docs/source/extension/extension_points.rst +++ b/docs/source/extension/extension_points.rst @@ -888,6 +888,103 @@ If you are adding your own activities to JupyterLab, you might consider providin a ``WidgetTracker`` token of your own, so that other extensions can make use of it. +Completion Providers +-------------------- + +Both code completer and inline completer can be extended by registering +an (inline) completion provider on the completion manager provided by +the ``ICompletionProviderManager`` token. + + +Code Completer +^^^^^^^^^^^^^^ + +A minimal code completion provider needs to implement the `fetch` and `isApplicable` +methods, and define a unique `identifier` property, but the ``ICompletionProvider`` +interface allows for much more extensive customization of the completer. + +.. code:: typescript + + import { ICompletionProviderManager, ICompletionProvider } from '@jupyterlab/completer'; + + class MyProvider implements ICompletionProvider { + readonly identifier = 'my-provider'; + + async isApplicable(context) { + return true; + } + + async fetch(request, context) { + return { + start: request.offset, + end: request.offset, + items: [ + { label: 'option 1' }, + { label: 'option 2' } + ] + }; + } + } + + const plugin: JupyterFrontEndPlugin = { + id: 'my-completer-extension:provider', + autoStart: true, + requires: [ICompletionProviderManager], + activate: (app: JupyterFrontEnd, manager: ICompletionProviderManager): void => { + const provider = new MyProvider(); + manager.registerProvider(provider); + } + }; + + +A more detailed example is provided in the `extension-examples `__ repository. + +For an example of an extensively customised completion provider, see the +`jupyterlab-lsp `__ extension. + +Inline Completer +^^^^^^^^^^^^^^^^ + +.. versionadded::4.1 + Experimental Inline Completion API was added in JupyterLab 4.1. + We welcome feedback on making it better for extension authors. + +A minimal inline completion provider extension would only implement the +required method `fetch` and define `identifier` and `name` properties, +but a number of additional fields can be used for enhanced functionality, +such as streaming, see the ``IInlineCompletionProvider`` documentation. + +.. code:: typescript + + import { ICompletionProviderManager, IInlineCompletionProvider } from '@jupyterlab/completer'; + + class MyInlineProvider implements IInlineCompletionProvider { + readonly identifier = 'my-provider'; + readonly name = 'My provider'; + + async fetch(request, context) { + return { + items: [ + { insertText: 'suggestion 1' }, + { insertText: 'suggestion 2' } + ] + }; + } + } + + const plugin: JupyterFrontEndPlugin = { + id: 'my-completer-extension:inline-provider', + autoStart: true, + requires: [ICompletionProviderManager], + activate: (app: JupyterFrontEnd, manager: ICompletionProviderManager): void => { + const provider = new MyInlineProvider(); + manager.registerInlineProvider(provider); + } + }; + +For an example of an inline completion provider with streaming support, see +`jupyterlab-transformers-completer `__. + State Database -------------- diff --git a/docs/source/index.rst b/docs/source/index.rst index 50b513b0b4a4..40590ac914ae 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -25,7 +25,7 @@ ideas with others. JupyterLab is a sibling to other notebook authoring applications under the `Project Jupyter `_ umbrella, like -`Jupyter Notebook `_ and +`Jupyter Notebook `_ and `Jupyter Desktop `_. JupyterLab offers a more advanced, feature rich, customizable experience compared to Jupyter Notebook. diff --git a/docs/source/user/completer.rst b/docs/source/user/completer.rst new file mode 100644 index 000000000000..e0eba25c3912 --- /dev/null +++ b/docs/source/user/completer.rst @@ -0,0 +1,51 @@ +.. Copyright (c) Jupyter Development Team. +.. Distributed under the terms of the Modified BSD License. + +.. _completer: + +Completer +========= + +Two completer implementations are available in JupyterLab: code completer for tab-completion, +and inline completer for inline (as-you-type) suggestions. + +Both the code completer and inline completer can present completions from third-party +providers when extensions with relevant (inline) completion providers are installed. + +Code completer widget +--------------------- + +The code completer widget can be activated by pressing :kbd:`Tab` in a non-empty line of a code cell. + +To cycle completion candidates use: +- :kbd:`Up`/:kbd:`Down` arrow keys or :kbd:`Tab`/:kbd:`Shift`+:kbd:`Shift` for cycling one item at a time +- :kbd:`Page Up`/:kbd:`Page Down` keys for jumping over multiple items at once + +To accept the active completion candidate pressing :kbd:`Enter`, or click on it with your mouse/pointer. + +By default the completions will include the symbols ("tokens") from the current editor ("context"), +and any suggestions returned by the active kernel in response to ``complete_request`` message. +You may be able to improve the relevance of completion suggestions by adjusting the configuration +of the kernel of your choice. + +Documentation panel +^^^^^^^^^^^^^^^^^^^ + +The documentation panel presents additional information about the completion candidate. +It can be enabled in Code Completer settings. By default this panel sends ``inspect_request`` +to the active kernel and is therefore only available in notebooks and other documents +with active session connected to a kernel that supports inspections. + +Inline completer +---------------- + +JupyterLab 4.1+ includes an experimental inline completer, showing the suggestions +as greyed out "ghost" text. Compared to the completer widget, the inline completer: + +- can present multi-line completions +- is automatically invoked as you type +- does not offer additional information such as type of documentation for the suggestions +- can provide completions in both code and markdown cells (the default history provider only suggests in code cells) + +The inline completer is disabled by default and can be enabled in the Settings Editor +by enabling the History Provider. diff --git a/docs/source/user/index.md b/docs/source/user/index.md index a2dba5d9f52d..509ffcb84d79 100644 --- a/docs/source/user/index.md +++ b/docs/source/user/index.md @@ -11,6 +11,7 @@ files file_editor notebook code_console +completer terminal running commands diff --git a/docs/source/user/notebook.rst b/docs/source/user/notebook.rst index 0090716de719..641162d57b33 100644 --- a/docs/source/user/notebook.rst +++ b/docs/source/user/notebook.rst @@ -151,6 +151,10 @@ and select “New Console for Notebook”: +.. _kernel_history: + +You can iterate through the kernel history in a document cell using ``Alt Up-Arrow`` and ``Alt Down-Arrow``. To use this feature, enable kernel history access in the notebook settings. + .. _cell-toolbar: Cell Toolbar diff --git a/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json b/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json index b7690f294ad8..624bd1fba612 100644 --- a/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json +++ b/galata/test/documentation/commands.test.ts-snapshots/commandsList-documentation-linux.json @@ -1374,6 +1374,22 @@ "caption": "", "shortcuts": [] }, + { + "id": "notebook:access-next-history-entry", + "label": "Access Next Kernel History Entry", + "caption": "", + "shortcuts": [ + "Alt ArrowDown" + ] + }, + { + "id": "notebook:access-previous-history-entry", + "label": "Access Previous Kernel History Entry", + "caption": "", + "shortcuts": [ + "Alt ArrowUp" + ] + }, { "id": "notebook:change-cell-to-code", "label": "Change to Code Cell Type", diff --git a/galata/test/documentation/general.test.ts-snapshots/jupyterlab-documentation-linux.png b/galata/test/documentation/general.test.ts-snapshots/jupyterlab-documentation-linux.png index bbd20712f85b..9c6aab81d9b7 100644 Binary files a/galata/test/documentation/general.test.ts-snapshots/jupyterlab-documentation-linux.png and b/galata/test/documentation/general.test.ts-snapshots/jupyterlab-documentation-linux.png differ diff --git a/galata/test/documentation/general.test.ts-snapshots/shortcuts-help-documentation-linux.png b/galata/test/documentation/general.test.ts-snapshots/shortcuts-help-documentation-linux.png index b2c06560031a..a0e9b9782d03 100644 Binary files a/galata/test/documentation/general.test.ts-snapshots/shortcuts-help-documentation-linux.png and b/galata/test/documentation/general.test.ts-snapshots/shortcuts-help-documentation-linux.png differ diff --git a/galata/test/galata/benchmarkReporter.spec.ts-snapshots/customized-test-galata-linux.svg b/galata/test/galata/benchmarkReporter.spec.ts-snapshots/customized-test-galata-linux.svg index eff517054b61..26c84ae858b0 100644 --- a/galata/test/galata/benchmarkReporter.spec.ts-snapshots/customized-test-galata-linux.svg +++ b/galata/test/galata/benchmarkReporter.spec.ts-snapshots/customized-test-galata-linux.svg @@ -1 +1 @@ -Duration of common actions \ No newline at end of file +Duration of common actions \ No newline at end of file diff --git a/galata/test/galata/benchmarkReporter.spec.ts-snapshots/test-galata-linux.svg b/galata/test/galata/benchmarkReporter.spec.ts-snapshots/test-galata-linux.svg index af670ab6435b..878638393276 100644 --- a/galata/test/galata/benchmarkReporter.spec.ts-snapshots/test-galata-linux.svg +++ b/galata/test/galata/benchmarkReporter.spec.ts-snapshots/test-galata-linux.svg @@ -1 +1 @@ -browseractualexpectedreferencechromium18,204Time (ms)openlarge_code_notebookDuration of common actions \ No newline at end of file +browseractualexpectedreferencechromium18,204Time (ms)openlarge_code_notebookDuration of common actions \ No newline at end of file diff --git a/galata/test/jupyterlab/cells.test.ts b/galata/test/jupyterlab/cells.test.ts new file mode 100644 index 000000000000..a6bf3d581116 --- /dev/null +++ b/galata/test/jupyterlab/cells.test.ts @@ -0,0 +1,34 @@ +// 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'; + +const fileName = 'simple_notebook.ipynb'; + +test.describe('Cells', () => { + test.beforeEach(async ({ page, request, tmpPath }) => { + const contents = galata.newContentsHelper(request); + await contents.uploadFile( + path.resolve(__dirname, `./notebooks/${fileName}`), + `${tmpPath}/${fileName}` + ); + await contents.uploadFile( + path.resolve(__dirname, './notebooks/WidgetArch.png'), + `${tmpPath}/WidgetArch.png` + ); + + await page.notebook.openByPath(`${tmpPath}/${fileName}`); + await page.notebook.activate(fileName); + }); + + test('should collapse a cell', async ({ page }) => { + await page.notebook.run(); + + await page.locator('.jp-Cell-inputCollapser').nth(2).click(); + + expect(await page.locator('.jp-Cell').nth(2).screenshot()).toMatchSnapshot( + 'collapsed-cell.png' + ); + }); +}); diff --git a/galata/test/jupyterlab/cells.test.ts-snapshots/collapsed-cell-jupyterlab-linux.png b/galata/test/jupyterlab/cells.test.ts-snapshots/collapsed-cell-jupyterlab-linux.png new file mode 100644 index 000000000000..5108feb78814 Binary files /dev/null and b/galata/test/jupyterlab/cells.test.ts-snapshots/collapsed-cell-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/codemirror.test.ts b/galata/test/jupyterlab/codemirror.test.ts new file mode 100644 index 000000000000..ae38a7a25f8a --- /dev/null +++ b/galata/test/jupyterlab/codemirror.test.ts @@ -0,0 +1,43 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { expect, galata, test } from '@jupyterlab/galata'; + +const DEFAULT_NAME = 'untitled.txt'; + +const RULERS_CONTENT = `0123456789 + 0123456789 + 0123456789 + 0123456789 + 0123456789 + 0123456789 +0123456789 + 0123456789 + 0123456789 + 0123456789 + 0123456789 + 0123456789`; + +test.describe('CodeMirror extensions', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + '@jupyterlab/codemirror-extension:plugin': { + defaultConfig: { + rulers: [10, 20, 30, 40, 50, 60] + } + } + } + }); + + test('Should display rulers', async ({ page }) => { + await page.menu.clickMenuItem('File>New>Text File'); + + await page.getByRole('tab', { name: DEFAULT_NAME }).waitFor(); + + const editor = page.getByRole('region', { name: 'notebook content' }); + await editor.getByRole('textbox').fill(RULERS_CONTENT); + + expect(await editor.screenshot()).toMatchSnapshot('codemirror-rulers.png'); + }); +}); diff --git a/galata/test/jupyterlab/codemirror.test.ts-snapshots/codemirror-rulers-jupyterlab-linux.png b/galata/test/jupyterlab/codemirror.test.ts-snapshots/codemirror-rulers-jupyterlab-linux.png new file mode 100644 index 000000000000..e6a6bef5115b Binary files /dev/null and b/galata/test/jupyterlab/codemirror.test.ts-snapshots/codemirror-rulers-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/history.test.ts b/galata/test/jupyterlab/history.test.ts new file mode 100644 index 000000000000..5492fb76a7f4 --- /dev/null +++ b/galata/test/jupyterlab/history.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { expect, galata, test } from '@jupyterlab/galata'; + +test.describe('test kernel history keybindings', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + '@jupyterlab/notebook-extension:tracker': { + accessKernelHistory: true + } + } + }); + test('Use history keybindings', async ({ page }) => { + await page.notebook.createNew('notebook.ipynb'); + await page.notebook.setCell(0, 'code', '1 + 2'); + await page.notebook.addCell('code', '2 + 3'); + await page.notebook.addCell('code', '3 + 4'); + await page.notebook.run(); + + await page.notebook.enterCellEditingMode(2); + await page.keyboard.press('Alt+ArrowUp'); + // input: 2+3 + await page.keyboard.press('End'); + await page.notebook.enterCellEditingMode(1); + await page.keyboard.press('End'); + await page.notebook.enterCellEditingMode(2); + await page.keyboard.press('Alt+ArrowUp'); + // test fails without this wait + await page.waitForTimeout(100); + // input: 3+4 + await page.keyboard.press('Alt+ArrowDown'); + // input 2+3 + await page.keyboard.press('Alt+ArrowUp'); + // input 3+4 + await page.keyboard.press('Alt+ArrowUp'); + // input 1+2 + + const imageName = 'history.png'; + const nbPanel = await page.notebook.getNotebookInPanel(); + + expect(await nbPanel.screenshot()).toMatchSnapshot(imageName); + }); +}); diff --git a/galata/test/jupyterlab/history.test.ts-snapshots/history-jupyterlab-linux.png b/galata/test/jupyterlab/history.test.ts-snapshots/history-jupyterlab-linux.png new file mode 100644 index 000000000000..e72afa84a75e Binary files /dev/null and b/galata/test/jupyterlab/history.test.ts-snapshots/history-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/inline-completer.test.ts b/galata/test/jupyterlab/inline-completer.test.ts new file mode 100644 index 000000000000..119882377358 --- /dev/null +++ b/galata/test/jupyterlab/inline-completer.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { expect, galata, test } from '@jupyterlab/galata'; + +const fileName = 'notebook.ipynb'; +const COMPLETER_SELECTOR = '.jp-InlineCompleter'; +const GHOST_SELECTOR = '.jp-GhostText'; +const PLUGIN_ID = '@jupyterlab/completer-extension:inline-completer'; + +const SHARED_SETTINGS = { + providers: { + '@jupyterlab/inline-completer:history': { + enabled: true + } + } +}; + +test.describe('Inline Completer', () => { + test.beforeEach(async ({ page }) => { + await page.notebook.createNew(fileName); + await page.notebook.setCell(0, 'code', 'suggestion_1 = 1'); + await page.notebook.addCell('code', 'suggestion_2 = 2'); + await page.notebook.addCell('code', 's'); + await page.notebook.runCell(0, true); + await page.notebook.runCell(1, true); + await page.notebook.enterCellEditingMode(2); + // we need to wait until the completer gets bound to the cell after entering it + await page.waitForTimeout(50); + }); + + test.describe('Widget "onHover", shortcuts on', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + [PLUGIN_ID]: { + showWidget: 'onHover', + showShortcuts: true, + ...SHARED_SETTINGS + } + } + }); + + test('Widget shows up on hover', async ({ page }) => { + await page.keyboard.press('u'); + + // Hover + const ghostText = page.locator(GHOST_SELECTOR); + await ghostText.waitFor(); + await ghostText.hover(); + + // Widget shows up + const completer = page.locator(COMPLETER_SELECTOR); + await completer.waitFor(); + + // Wait for full opacity + await page.waitForTimeout(100); + + const imageName = 'inline-completer-shortcuts-on.png'; + expect(await completer.screenshot()).toMatchSnapshot(imageName); + + // Should hide on moving cursor away + const toolbar = await page.notebook.getToolbar(); + await toolbar.hover(); + await completer.waitFor({ state: 'hidden' }); + }); + }); + + test.describe('Widget "always", shortcuts off', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + [PLUGIN_ID]: { + showWidget: 'always', + showShortcuts: false, + ...SHARED_SETTINGS + } + } + }); + + test('Widget shows up on typing, hides on blur', async ({ page }) => { + await page.keyboard.press('u'); + + // Widget shows up + const completer = page.locator(COMPLETER_SELECTOR); + await completer.waitFor(); + + const imageName = 'inline-completer-shortcuts-off.png'; + expect(await completer.screenshot()).toMatchSnapshot(imageName); + + // Should hide on blur + await page.keyboard.press('Escape'); + await page.waitForTimeout(50); + await expect(completer).toBeHidden(); + }); + + test('Focusing on widget does not hide it', async ({ page }) => { + await page.keyboard.press('u'); + const completer = page.locator(COMPLETER_SELECTOR); + await completer.waitFor(); + + // Focusing or clicking should not hide + await completer.focus(); + await completer.click(); + await page.waitForTimeout(100); + await expect(completer).toBeVisible(); + }); + + test('Shows up on invoke command', async ({ page }) => { + await page.evaluate(async () => { + await window.jupyterapp.commands.execute('inline-completer:invoke'); + }); + + // Widget shows up + const completer = page.locator(COMPLETER_SELECTOR); + await completer.waitFor(); + }); + }); + + test.describe('Ghost text', () => { + test.use({ + mockSettings: { + ...galata.DEFAULT_SETTINGS, + [PLUGIN_ID]: { + showWidget: 'never', + ...SHARED_SETTINGS + } + } + }); + + test('Ghost text updates on typing', async ({ page }) => { + await page.keyboard.press('u'); + + // Ghost text shows up + const ghostText = page.locator(GHOST_SELECTOR); + await ghostText.waitFor(); + + // Ghost text should be updated from "ggestion" to "estion" + await page.keyboard.type('gg'); + await expect(ghostText).toHaveText(/estion.*/); + + const cellEditor = await page.notebook.getCellInput(2); + const imageName = 'editor-with-ghost-text.png'; + expect(await cellEditor.screenshot()).toMatchSnapshot(imageName); + + // Ghost text should hide + await page.keyboard.press('Escape'); + await page.waitForTimeout(50); + await expect(ghostText).toBeHidden(); + }); + }); +}); diff --git a/galata/test/jupyterlab/inline-completer.test.ts-snapshots/editor-with-ghost-text-jupyterlab-linux.png b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/editor-with-ghost-text-jupyterlab-linux.png new file mode 100644 index 000000000000..584b5b75a61d Binary files /dev/null and b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/editor-with-ghost-text-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-off-jupyterlab-linux.png b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-off-jupyterlab-linux.png new file mode 100644 index 000000000000..4fa2d4f11c63 Binary files /dev/null and b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-off-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-on-jupyterlab-linux.png b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-on-jupyterlab-linux.png new file mode 100644 index 000000000000..acc4356a01fc Binary files /dev/null and b/galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-on-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-mobile.test.ts b/galata/test/jupyterlab/notebook-mobile.test.ts index 2581899ebee8..a8bcd8e32972 100644 --- a/galata/test/jupyterlab/notebook-mobile.test.ts +++ b/galata/test/jupyterlab/notebook-mobile.test.ts @@ -14,6 +14,9 @@ test.describe('Notebook Layout on Mobile', () => { test('Execute code cell', async ({ page }) => { await page.sidebar.close('left'); + // TODO: calling `setCell` just once leads to very flaky test + // See https://github.com/jupyterlab/jupyterlab/issues/15252 for more information + await page.notebook.setCell(0, 'code', 'print("hello")'); await page.notebook.setCell(0, 'code', 'print("hello")'); await page.notebook.addCell('code', '2 * 3'); await page.notebook.runCellByCell(); diff --git a/galata/test/jupyterlab/notebook-mobile.test.ts-snapshots/mobile-layout-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-mobile.test.ts-snapshots/mobile-layout-jupyterlab-linux.png index 23c9b84819fb..1c94b10c405f 100644 Binary files a/galata/test/jupyterlab/notebook-mobile.test.ts-snapshots/mobile-layout-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-mobile.test.ts-snapshots/mobile-layout-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-all-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-all-jupyterlab-linux.png index 081cf02c8b90..573555930227 100644 Binary files a/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-all-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-all-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-in-cell-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-in-cell-jupyterlab-linux.png index 75748029562e..b005de022cf9 100644 Binary files a/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-in-cell-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-replace.test.ts-snapshots/replace-in-cell-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-dark-vega-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-dark-vega-jupyterlab-linux.png index 6f02c977ae21..6a28fa9c1b00 100644 Binary files a/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-dark-vega-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-dark-vega-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-vega-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-vega-jupyterlab-linux.png index be1779542fbc..f3bc34a3164d 100644 Binary files a/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-vega-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-run-vega.test.ts-snapshots/run-cells-vega-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-scroll.test.ts b/galata/test/jupyterlab/notebook-scroll.test.ts index a02f4ef7817e..fb71109551bf 100644 --- a/galata/test/jupyterlab/notebook-scroll.test.ts +++ b/galata/test/jupyterlab/notebook-scroll.test.ts @@ -42,16 +42,13 @@ test.describe('Notebook Scroll', () => { test(`Scroll to ${link}`, async ({ page }) => { const firstCell = await page.notebook.getCell(0); await firstCell.scrollIntoViewIfNeeded(); - expect(await firstCell.boundingBox()).toBeTruthy(); await page.click(`a:has-text("${link}")`); await firstCell.waitForElementState('hidden'); - expect(await firstCell.boundingBox()).toBeFalsy(); const lastCell = await page.notebook.getCell(cellIdx); await lastCell.waitForElementState('visible'); - expect(await lastCell.boundingBox()).toBeTruthy(); }); } }); diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-next-in-editor-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-next-in-editor-jupyterlab-linux.png index 04f8747f2fde..2934725a4561 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-next-in-editor-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-next-in-editor-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-previous-element-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-previous-element-jupyterlab-linux.png index 094c6daed2da..b7cd99643070 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-previous-element-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-previous-element-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-visible-under-selection-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-visible-under-selection-jupyterlab-linux.png index 34669343e9fa..e638fec7d18d 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-visible-under-selection-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/highlight-visible-under-selection-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/multi-line-search-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/multi-line-search-jupyterlab-linux.png index 1988e562da71..30d9e9f3fbec 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/multi-line-search-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/multi-line-search-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/regexp-parsing-failure-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/regexp-parsing-failure-jupyterlab-linux.png index bbc370fe82a4..f32da74eff95 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/regexp-parsing-failure-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/regexp-parsing-failure-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-cells-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-cells-jupyterlab-linux.png index 0dc4928c926b..c4f9e2513506 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-cells-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-cells-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-text-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-text-jupyterlab-linux.png index f5887e0d5e1c..379c2cee5f88 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-text-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-selected-text-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-two-selected-cells-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-two-selected-cells-jupyterlab-linux.png index 3675c0be971d..5d0d182731cf 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-two-selected-cells-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-in-two-selected-cells-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-jupyterlab-linux.png index bf4142f25fa8..1dc949edbaf2 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-deleted-cell-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-deleted-cell-jupyterlab-linux.png index 7f381c60769c..f3a8acbdee19 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-deleted-cell-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-deleted-cell-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-new-cell-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-new-cell-jupyterlab-linux.png index 3a638063eb83..96324586de1a 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-new-cell-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-on-new-cell-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-within-outputs-jupyterlab-linux.png b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-within-outputs-jupyterlab-linux.png index c2b6d964f516..ecb9f58c3e22 100644 Binary files a/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-within-outputs-jupyterlab-linux.png and b/galata/test/jupyterlab/notebook-search.test.ts-snapshots/search-within-outputs-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/print.test.ts b/galata/test/jupyterlab/print.test.ts new file mode 100644 index 000000000000..02b04c0fde33 --- /dev/null +++ b/galata/test/jupyterlab/print.test.ts @@ -0,0 +1,58 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { expect, test } from '@jupyterlab/galata'; +import * as path from 'path'; + +test.use({ autoGoto: false }); + +const fileName = 'simple_notebook.ipynb'; + +test.describe('Print layout', () => { + test('Notebook', async ({ page, tmpPath }) => { + await page.emulateMedia({ media: 'print' }); + await page.contents.uploadFile( + path.resolve(__dirname, `./notebooks/${fileName}`), + `${tmpPath}/${fileName}` + ); + await page.contents.uploadFile( + path.resolve(__dirname, './notebooks/WidgetArch.png'), + `${tmpPath}/WidgetArch.png` + ); + + await page.goto(); + + await page.notebook.openByPath(`${tmpPath}/${fileName}`); + + await page.getByText('Python 3 (ipykernel) | Idle').waitFor(); + + await page.notebook.run(); + + let printedNotebookURL = ''; + await Promise.all([ + page.waitForRequest( + async request => { + const url = request.url(); + if (url.match(/\/nbconvert\//) !== null) { + printedNotebookURL = url; + return true; + } + return false; + }, + { timeout: 1000 } + ), + page.keyboard.press('Control+P') + ]); + + const newPage = await page.context().newPage(); + + await newPage.goto(printedNotebookURL, { waitUntil: 'networkidle' }); + + // Wait until MathJax loading message disappears + const mathJaxMessage = newPage.locator('#MathJax_Message'); + await expect(mathJaxMessage).toHaveCount(1); + await mathJaxMessage.waitFor({ state: 'hidden' }); + + expect(await newPage.screenshot()).toMatchSnapshot('printed-notebook.png'); + }); +}); diff --git a/galata/test/jupyterlab/print.test.ts-snapshots/printed-notebook-jupyterlab-linux.png b/galata/test/jupyterlab/print.test.ts-snapshots/printed-notebook-jupyterlab-linux.png new file mode 100644 index 000000000000..e513969fb390 Binary files /dev/null and b/galata/test/jupyterlab/print.test.ts-snapshots/printed-notebook-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/search.test.ts-snapshots/text-editor-search-from-selection-jupyterlab-linux.png b/galata/test/jupyterlab/search.test.ts-snapshots/text-editor-search-from-selection-jupyterlab-linux.png index b8eae1f1bffb..5daaf0614eae 100644 Binary files a/galata/test/jupyterlab/search.test.ts-snapshots/text-editor-search-from-selection-jupyterlab-linux.png and b/galata/test/jupyterlab/search.test.ts-snapshots/text-editor-search-from-selection-jupyterlab-linux.png differ diff --git a/galata/test/jupyterlab/windowed-notebook.test.ts b/galata/test/jupyterlab/windowed-notebook.test.ts index ed21c0fe135a..9c8a3164a3c5 100644 --- a/galata/test/jupyterlab/windowed-notebook.test.ts +++ b/galata/test/jupyterlab/windowed-notebook.test.ts @@ -55,12 +55,15 @@ test('should not update height when hiding', async ({ page, tmpPath }) => { expect(parseInt(innerHeight, 10)).toEqual(initialHeight); }); -test('should hide first code cell when scrolling down', async ({ +test('should hide first inactive code cell when scrolling down', async ({ page, tmpPath }) => { await page.notebook.openByPath(`${tmpPath}/${fileName}`); + // Activate >second< cell + await page.notebook.selectCells(1); + // Test if the >first< (now inactive) cell gets detached const h = await page.notebook.getNotebookInPanel(); const firstCellSelector = '.jp-Cell[data-windowed-list-index="0"]'; const firstCell = await h!.waitForSelector(firstCellSelector); @@ -76,12 +79,15 @@ test('should hide first code cell when scrolling down', async ({ expect(await firstCell.textContent()).toEqual('[16]:local link\n'); }); -test('should reattached a code code cell when scrolling back into the viewport', async ({ +test('should reattached inactive code cell when scrolling back into the viewport', async ({ page, tmpPath }) => { await page.notebook.openByPath(`${tmpPath}/${fileName}`); + // Activate >second< cell + await page.notebook.selectCells(1); + // Test if the >first< (now inactive) cell gets re-attached const h = await page.notebook.getNotebookInPanel(); const firstCellSelector = '.jp-Cell[data-windowed-list-index="0"]'; const firstCell = await h!.waitForSelector(firstCellSelector); @@ -103,6 +109,91 @@ test('should reattached a code code cell when scrolling back into the viewport', expect(await firstCell.waitForSelector('.jp-InputArea')).toBeDefined(); }); +test('should not detach active code cell input when scrolling down', async ({ + page, + tmpPath +}) => { + await page.notebook.openByPath(`${tmpPath}/${fileName}`); + + await page.notebook.selectCells(0); + const h = await page.notebook.getNotebookInPanel(); + const firstCellSelector = '.jp-Cell[data-windowed-list-index="0"]'; + const firstCell = await h!.waitForSelector(firstCellSelector); + + const bbox = await h!.boundingBox(); + await page.mouse.move(bbox!.x, bbox!.y); + await Promise.all([ + firstCell.waitForElementState('hidden'), + page.mouse.wheel(0, 1200) + ]); + + // Check the input is still defined + expect(await firstCell.waitForSelector('.jp-InputArea')).toBeDefined(); +}); + +for (const cellType of ['code', 'markdown']) { + test(`should scroll back to the active ${cellType} cell on typing`, async ({ + page, + tmpPath + }) => { + await page.notebook.openByPath(`${tmpPath}/${fileName}`); + + await page.notebook.setCellType(0, cellType); + await page.notebook.enterCellEditingMode(0); + const h = await page.notebook.getNotebookInPanel(); + const firstCellSelector = '.jp-Cell[data-windowed-list-index="0"]'; + const firstCell = await h!.waitForSelector(firstCellSelector); + + const bbox = await h!.boundingBox(); + await page.mouse.move(bbox!.x, bbox!.y); + await Promise.all([ + firstCell.waitForElementState('hidden'), + page.mouse.wheel(0, 1200) + ]); + + // Type in the cell + await page.keyboard.type('TEST', { delay: 150 }); + + // Expect the cell to become visible again + await firstCell.waitForElementState('visible'); + + // Expect the text to populate the cell editor + const firstCellInput = await page.notebook.getCellInput(0); + expect(await firstCellInput.textContent()).toContain('TEST'); + }); +} + +test('should scroll back to the cell below the active cell on arrow down key', async ({ + page, + tmpPath +}) => { + await page.notebook.openByPath(`${tmpPath}/${fileName}`); + + // Activate the first cell. + await page.notebook.selectCells(0); + const h = await page.notebook.getNotebookInPanel(); + const firstCell = await h!.waitForSelector( + '.jp-Cell[data-windowed-list-index="0"]' + ); + const secondCell = await h!.waitForSelector( + '.jp-Cell[data-windowed-list-index="1"]' + ); + + const bbox = await h!.boundingBox(); + await page.mouse.move(bbox!.x, bbox!.y); + await Promise.all([ + firstCell.waitForElementState('hidden'), + secondCell.waitForElementState('hidden'), + page.mouse.wheel(0, 1200) + ]); + + // Select cell below the active cell + await page.keyboard.press('ArrowDown'); + + // Expect the second cell to become visible again. + await secondCell.waitForElementState('visible'); +}); + test('should detach a markdown code cell when scrolling out of the viewport', async ({ page, tmpPath diff --git a/galata/test/jupyterlab/workspace.test.ts b/galata/test/jupyterlab/workspace.test.ts index 9f8a2e9a9b32..5c124446e75a 100644 --- a/galata/test/jupyterlab/workspace.test.ts +++ b/galata/test/jupyterlab/workspace.test.ts @@ -272,12 +272,30 @@ test.describe('Workspace in doc mode', () => { 'running-sessions', '@jupyterlab/toc:plugin', 'extensionmanager.main-view' - ] + ], + widgetStates: { + ['jp-running-sessions']: { + sizes: [0.25, 0.25, 0.25, 0.25], + expansionStates: [false, false, false, false] + }, + ['extensionmanager.main-view']: { + sizes: [ + 0.3333333333333333, 0.3333333333333333, 0.3333333333333333 + ], + expansionStates: [false, false, false] + } + } }, right: { collapsed: true, visible: true, - widgets: [] + widgets: ['jp-property-inspector', 'debugger-sidebar'], + widgetStates: { + ['jp-debugger-sidebar']: { + sizes: [0.2, 0.2, 0.2, 0.2, 0.2], + expansionStates: [false, false, false, false, false] + } + } }, relativeSizes: [0.4, 0.6, 0], top: { diff --git a/jupyterlab/extensions/pypi.py b/jupyterlab/extensions/pypi.py index 359ccb3a2d0f..950c0c59cd3d 100644 --- a/jupyterlab/extensions/pypi.py +++ b/jupyterlab/extensions/pypi.py @@ -4,6 +4,7 @@ """Extension manager using pip as package manager and PyPi.org as packages source.""" import asyncio +import http.client import io import json import math @@ -13,12 +14,15 @@ from datetime import datetime, timedelta, timezone from functools import partial from itertools import groupby +from os import environ from pathlib import Path from subprocess import CalledProcessError, run from tarfile import TarFile from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import urlparse from zipfile import ZipFile +import httpx import tornado from async_lru import alru_cache from traitlets import CFloat, CInt, Unicode, config, observe @@ -31,30 +35,62 @@ ) -async def _fetch_package_metadata(name: str, latest_version: str, base_url: str) -> dict: - http_client = tornado.httpclient.AsyncHTTPClient() - response = await http_client.fetch( - base_url + f"/{name}/{latest_version}/json", - headers={"Content-Type": "application/json"}, - ) - data = json.loads(response.body).get("info") - - # Keep minimal information to limit cache size - return { - k: data.get(k) - for k in [ - "author", - "bugtrack_url", - "docs_url", - "home_page", - "license", - "package_url", - "project_url", - "project_urls", - "summary", - ] +class ProxiedTransport(xmlrpc.client.Transport): + def set_proxy(self, host, port=None, headers=None): + self.proxy = host, port + self.proxy_headers = headers + + def make_connection(self, host): + connection = http.client.HTTPConnection(*self.proxy) + connection.set_tunnel(host, headers=self.proxy_headers) + self._connection = host, connection + return connection + + +xmlrpc_transport_override = None + +all_proxy_url = environ.get("ALL_PROXY") +http_proxy_url = environ.get("HTTP_PROXY") or all_proxy_url +https_proxy_url = environ.get("HTTPS_PROXY") or all_proxy_url or http_proxy_url +proxies = None + +if http_proxy_url: + http_proxy = urlparse(http_proxy_url) + proxy_host, _, proxy_port = http_proxy.netloc.partition(":") + + proxies = { + "http://": http_proxy_url, + "https://": https_proxy_url, } + xmlrpc_transport_override = ProxiedTransport() + xmlrpc_transport_override.set_proxy(proxy_host, proxy_port) + + +async def _fetch_package_metadata(name: str, latest_version: str, base_url: str) -> dict: + async with httpx.AsyncClient(proxies=proxies) as httpx_client: + response = await httpx_client.get( + base_url + f"/{name}/{latest_version}/json", + headers={"Content-Type": "application/json"}, + ) + data = json.loads(response.text).get("info") + + # Keep minimal information to limit cache size + return { + k: data.get(k) + for k in [ + "author", + "bugtrack_url", + "docs_url", + "home_page", + "license", + "package_url", + "project_url", + "project_urls", + "summary", + ] + } + class PyPIExtensionManager(ExtensionManager): """Extension manager using pip as package manager and PyPi.org as packages source.""" @@ -86,14 +122,19 @@ def __init__( self._fetch_package_metadata = _fetch_package_metadata self._observe_package_metadata_cache_size({"new": self.package_metadata_cache_size}) # Combine XML RPC API and JSON API to reduce throttling by PyPI.org - self._http_client = tornado.httpclient.AsyncHTTPClient() - self._rpc_client = xmlrpc.client.ServerProxy(self.base_url) + self._rpc_client = xmlrpc.client.ServerProxy( + self.base_url, transport=xmlrpc_transport_override + ) self.__last_all_packages_request_time = datetime.now(tz=timezone.utc) - timedelta( seconds=self.cache_timeout * 1.01 ) self.__all_packages_cache = None self.log.debug(f"Extensions list will be fetched from {self.base_url}.") + if xmlrpc_transport_override: + self.log.info( + f"Extensions will be fetched using proxy, proxy host and port: {xmlrpc_transport_override.proxy}" + ) @property def metadata(self) -> ExtensionManagerMetadata: @@ -109,12 +150,11 @@ async def get_latest_version(self, pkg: str) -> Optional[str]: The latest available version """ try: - http_client = tornado.httpclient.AsyncHTTPClient() - response = await http_client.fetch( - self.base_url + f"/{pkg}/json", - headers={"Content-Type": "application/json"}, - ) - data = json.loads(response.body).get("info") + async with httpx.AsyncClient(proxies=proxies) as httpx_client: + response = await httpx_client.get( + self.base_url + f"/{pkg}/json", headers={"Content-Type": "application/json"} + ) + data = json.loads(response.content).get("info") except Exception: return None else: @@ -332,9 +372,10 @@ async def install(self, name: str, version: Optional[str] = None) -> ActionResul try: download_url: str = pkg_action.get("download_info", {}).get("url") if download_url is not None: - response = await self._http_client.fetch(download_url) + async with httpx.AsyncClient(proxies=proxies) as httpx_client: + response = await httpx_client.get(download_url, proxies=proxies) if download_url.endswith(".whl"): - with ZipFile(io.BytesIO(response.body)) as wheel: + with ZipFile(io.BytesIO(response.content)) as wheel: for name in filter( lambda f: Path(f).name == "package.json", wheel.namelist(), @@ -344,7 +385,7 @@ async def install(self, name: str, version: Optional[str] = None) -> ActionResul if jlab_metadata is not None: break elif download_url.endswith("tar.gz"): - with TarFile(io.BytesIO(response.body)) as sdist: + with TarFile(io.BytesIO(response.content)) as sdist: for name in filter( lambda f: Path(f).name == "package.json", sdist.getnames(), diff --git a/packages/application/src/shell.ts b/packages/application/src/shell.ts index 54566a9831a8..b8be79154d5e 100644 --- a/packages/application/src/shell.ts +++ b/packages/application/src/shell.ts @@ -2067,7 +2067,7 @@ namespace Private { if (data.widgetStates) { this._stackedPanel.widgets.forEach((w: SidePanel) => { if (w.id && w.content instanceof SplitPanel) { - const state = data.widgetStates[w.id]; + const state = data.widgetStates[w.id] ?? {}; w.content.widgets.forEach((wi, widx) => { const expansion = (state.expansionStates ?? [])[widx]; if ( diff --git a/packages/cells/src/widget.ts b/packages/cells/src/widget.ts index 3da38e8ad8cc..c585d3791db7 100644 --- a/packages/cells/src/widget.ts +++ b/packages/cells/src/widget.ts @@ -1632,6 +1632,15 @@ export namespace CodeCell { // execution, clear the prompt. if (future && !cell.isDisposed && cell.outputArea.future === future) { cell.setPrompt(''); + if (recordTiming && future.isDisposed) { + // Record the time when the cell execution was aborted + const timingInfo: any = Object.assign( + {}, + model.getMetadata('execution') + ); + timingInfo['execution_failed'] = new Date().toISOString(); + model.setMetadata('execution', timingInfo); + } } throw e; } diff --git a/packages/cells/style/inputarea.css b/packages/cells/style/inputarea.css index bc44cbbd0d93..3495e96a7f49 100644 --- a/packages/cells/style/inputarea.css +++ b/packages/cells/style/inputarea.css @@ -9,16 +9,15 @@ /* All input areas */ .jp-InputArea { - display: table; - table-layout: fixed; + display: flex; + flex-direction: row; width: 100%; overflow: hidden; } .jp-InputArea-editor { - display: table-cell; + flex: 1 1 auto; overflow: hidden; - vertical-align: top; /* This is the non-active, default styling */ border: var(--jp-border-width) solid var(--jp-cell-editor-border-color); @@ -27,8 +26,7 @@ } .jp-InputPrompt { - display: table-cell; - vertical-align: top; + flex: 0 0 var(--jp-cell-prompt-width); width: var(--jp-cell-prompt-width); color: var(--jp-cell-inprompt-font-color); font-family: var(--jp-cell-prompt-font-family); @@ -52,17 +50,40 @@ user-select: none; } +/*----------------------------------------------------------------------------- +| Print +|----------------------------------------------------------------------------*/ +@media print { + .jp-InputArea { + display: table; + table-layout: fixed; + } + + .jp-InputArea-editor { + display: table-cell; + vertical-align: top; + } + + .jp-InputPrompt { + display: table-cell; + vertical-align: top; + } +} + /*----------------------------------------------------------------------------- | Mobile |----------------------------------------------------------------------------*/ @media only screen and (width <= 760px) { + .jp-InputArea { + flex-direction: column; + } + .jp-InputArea-editor { - display: table-row; - margin-left: var(--jp-notebook-padding); + margin-left: var(--jp-code-padding); } .jp-InputPrompt { - display: table-row; + flex: 0 0 auto; text-align: left; } } diff --git a/packages/cells/style/placeholder.css b/packages/cells/style/placeholder.css index 15ed9751f551..0f1357c139a6 100644 --- a/packages/cells/style/placeholder.css +++ b/packages/cells/style/placeholder.css @@ -8,18 +8,18 @@ |----------------------------------------------------------------------------*/ .jp-Placeholder { - display: table; - table-layout: fixed; + display: flex; + flex-direction: row; width: 100%; } .jp-Placeholder-prompt { - display: table-cell; + flex: 0 0 var(--jp-cell-prompt-width); box-sizing: border-box; } .jp-Placeholder-content { - display: table-cell; + flex: 1 1 auto; padding: 4px 6px; border: 1px solid transparent; border-radius: 0; @@ -61,3 +61,21 @@ border-color: var(--jp-cell-editor-border-color); background: var(--jp-cell-editor-background); } + +/*----------------------------------------------------------------------------- +| Print +|----------------------------------------------------------------------------*/ +@media print { + .jp-Placeholder { + display: table; + table-layout: fixed; + } + + .jp-Placeholder-content { + display: table-cell; + } + + .jp-Placeholder-prompt { + display: table-cell; + } +} diff --git a/packages/cells/style/widget.css b/packages/cells/style/widget.css index da9e183a8d74..771adb77f85c 100644 --- a/packages/cells/style/widget.css +++ b/packages/cells/style/widget.css @@ -102,6 +102,11 @@ width: calc( var(--jp-cell-prompt-width) - var(--jp-private-cell-scrolling-output-offset) ); + flex: 0 0 + calc( + var(--jp-cell-prompt-width) - + var(--jp-private-cell-scrolling-output-offset) + ); } .jp-CodeCell.jp-mod-outputsScrolled .jp-OutputArea-promptOverlay { @@ -117,7 +122,7 @@ |----------------------------------------------------------------------------*/ .jp-MarkdownOutput { - display: table-cell; + flex: 1 1 auto; width: 100%; margin-top: 0; margin-bottom: 0; @@ -235,4 +240,8 @@ cell outputs. .jp-Cell-outputWrapper { display: block; } + + .jp-MarkdownOutput { + display: table-cell; + } } diff --git a/packages/celltags-extension/package.json b/packages/celltags-extension/package.json index e0a96efed9ec..8e915a94bd02 100644 --- a/packages/celltags-extension/package.json +++ b/packages/celltags-extension/package.json @@ -45,7 +45,7 @@ "@jupyterlab/translation": "^4.1.0-alpha.2", "@jupyterlab/ui-components": "^4.1.0-alpha.2", "@lumino/algorithm": "^2.0.1", - "@rjsf/utils": "^5.1.0", + "@rjsf/utils": "^5.13.2", "react": "^18.2.0" }, "devDependencies": { diff --git a/packages/codeeditor/src/editor.ts b/packages/codeeditor/src/editor.ts index 04f6d9219d18..286da0404e66 100644 --- a/packages/codeeditor/src/editor.ts +++ b/packages/codeeditor/src/editor.ts @@ -9,6 +9,7 @@ import { ITranslator } from '@jupyterlab/translation'; import { JSONObject } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { ISignal, Signal } from '@lumino/signaling'; +import { IEditorMimeTypeService } from './mimetype'; /** * A namespace for code editors. @@ -163,7 +164,8 @@ export namespace CodeEditor { // Track if we need to dispose the model or not. this.standaloneModel = typeof options.sharedModel === 'undefined'; this.sharedModel = options.sharedModel ?? new YFile(); - this._mimeType = options.mimeType ?? 'text/plain'; + this._mimeType = + options.mimeType ?? IEditorMimeTypeService.defaultMimeType; } /** @@ -233,7 +235,7 @@ export namespace CodeEditor { private _isDisposed = false; private _selections = new ObservableMap(); - private _mimeType = 'text/plain'; + private _mimeType = IEditorMimeTypeService.defaultMimeType; private _mimeTypeChanged = new Signal>(this); } diff --git a/packages/codemirror-extension/package.json b/packages/codemirror-extension/package.json index aebfa8e2293f..47f02d5e1812 100644 --- a/packages/codemirror-extension/package.json +++ b/packages/codemirror-extension/package.json @@ -51,8 +51,8 @@ "@jupyterlab/ui-components": "^4.1.0-alpha.2", "@lumino/coreutils": "^2.1.2", "@lumino/widgets": "^2.3.1-alpha.0", - "@rjsf/utils": "^5.1.0", - "@rjsf/validator-ajv8": "^5.1.0", + "@rjsf/utils": "^5.13.2", + "@rjsf/validator-ajv8": "^5.13.2", "react": "^18.2.0" }, "devDependencies": { diff --git a/packages/codemirror/src/extension.ts b/packages/codemirror/src/extension.ts index 44278fca350b..f8e12ab1bc7a 100644 --- a/packages/codemirror/src/extension.ts +++ b/packages/codemirror/src/extension.ts @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { closeBrackets } from '@codemirror/autocomplete'; +import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; import { defaultKeymap, indentLess } from '@codemirror/commands'; import { bracketMatching, @@ -13,6 +13,7 @@ import { Compartment, EditorState, Extension, + Prec, StateEffect } from '@codemirror/state'; import { @@ -20,6 +21,7 @@ import { drawSelection, EditorView, highlightActiveLine, + highlightSpecialChars, highlightTrailingWhitespace, highlightWhitespace, KeyBinding, @@ -642,6 +644,15 @@ export namespace EditorExtensionRegistry { title: trans.__('Highlight the active line') } }), + Object.freeze({ + name: 'highlightSpecialCharacters', + default: true, + factory: () => createConditionalExtension(highlightSpecialChars()), + schema: { + type: 'boolean', + title: trans.__('Highlight special characters') + } + }), Object.freeze({ name: 'highlightTrailingWhitespace', default: false, @@ -715,7 +726,12 @@ export namespace EditorExtensionRegistry { Object.freeze({ name: 'matchBrackets', default: true, - factory: () => createConditionalExtension(bracketMatching()), + factory: () => + createConditionalExtension([ + bracketMatching(), + // closeBracketsKeymap must have higher precedence over defaultKeymap + Prec.high(keymap.of(closeBracketsKeymap)) + ]), schema: { type: 'boolean', title: trans.__('Match Brackets') diff --git a/packages/codemirror/src/extensions/rulers.ts b/packages/codemirror/src/extensions/rulers.ts index 4e09f6f529c4..927c0f08ba1e 100644 --- a/packages/codemirror/src/extensions/rulers.ts +++ b/packages/codemirror/src/extensions/rulers.ts @@ -57,6 +57,8 @@ const plugin = ViewPlugin.fromClass( const defaultCharacterWidth = view.defaultCharacterWidth; const widths = view.state.facet(rulerConfig); + const guttersWidths = + view.scrollDOM.querySelector('.cm-gutters')?.clientWidth ?? 0; this.rulers = widths.map(width => { const ruler = this.rulersContainer.appendChild( document.createElement('div') @@ -64,7 +66,7 @@ const plugin = ViewPlugin.fromClass( ruler.classList.add(RULERS_CLASSNAME); ruler.style.cssText = ` position: absolute; - left: ${width * defaultCharacterWidth}px; + left: ${guttersWidths + width * defaultCharacterWidth}px; height: 100%; `; // FIXME: This should be equal to the amount of padding on a line. @@ -80,11 +82,16 @@ const plugin = ViewPlugin.fromClass( if ( update.viewportChanged || + update.geometryChanged || !JSONExt.deepEqual(widths, update.startState.facet(rulerConfig)) ) { + const guttersWidth = + update.view.scrollDOM.querySelector('.cm-gutters')?.clientWidth ?? 0; const defaultCharacterWidth = update.view.defaultCharacterWidth; this.rulers.forEach((ruler, rulerIdx) => { - ruler.style.left = `${widths[rulerIdx] * defaultCharacterWidth}px`; + ruler.style.left = `${ + guttersWidth + widths[rulerIdx] * defaultCharacterWidth + }px`; }); } } diff --git a/packages/completer-extension/package.json b/packages/completer-extension/package.json index eb834462dc38..aed212a7f508 100644 --- a/packages/completer-extension/package.json +++ b/packages/completer-extension/package.json @@ -41,9 +41,11 @@ "@jupyterlab/application": "^4.1.0-alpha.2", "@jupyterlab/completer": "^4.1.0-alpha.2", "@jupyterlab/settingregistry": "^4.1.0-alpha.2", + "@jupyterlab/translation": "^4.1.0-alpha.2", "@jupyterlab/ui-components": "^4.1.0-alpha.2", + "@lumino/commands": "^2.1.3", "@lumino/coreutils": "^2.1.2", - "@rjsf/utils": "^5.1.0", + "@rjsf/utils": "^5.13.2", "react": "^18.2.0" }, "devDependencies": { diff --git a/packages/completer-extension/schema/inline-completer.json b/packages/completer-extension/schema/inline-completer.json new file mode 100644 index 000000000000..c4e3401057ed --- /dev/null +++ b/packages/completer-extension/schema/inline-completer.json @@ -0,0 +1,64 @@ +{ + "title": "Inline Completer", + "description": "Inline completer settings.", + "jupyter.lab.setting-icon": "completer:inline", + "jupyter.lab.setting-icon-label": "Inline Completer", + "jupyter.lab.transform": true, + "jupyter.lab.shortcuts": [ + { + "command": "inline-completer:next", + "keys": ["Alt ]"], + "selector": ".jp-mod-completer-enabled" + }, + { + "command": "inline-completer:previous", + "keys": ["Alt ["], + "selector": ".jp-mod-completer-enabled" + }, + { + "command": "inline-completer:accept", + "keys": ["Alt End"], + "selector": ".jp-mod-completer-enabled" + }, + { + "command": "inline-completer:invoke", + "keys": ["Alt \\"], + "selector": ".jp-mod-completer-enabled" + } + ], + "properties": { + "providers": { + "title": "Inline completion providers", + "type": "object" + }, + "showWidget": { + "title": "Show widget", + "description": "When to show the inline completer widget.", + "type": "string", + "oneOf": [ + { "const": "always", "title": "Always" }, + { "const": "onHover", "title": "On hover" }, + { "const": "never", "title": "Never" } + ], + "default": "onHover" + }, + "showShortcuts": { + "title": "Show shortcuts in the widget", + "description": "Whether to show shortcuts in the inline completer widget.", + "type": "boolean", + "default": true + }, + "streamingAnimation": { + "title": "Streaming animation", + "description": "Transition effect used when streaming tokens from model.", + "type": "string", + "oneOf": [ + { "const": "none", "title": "None" }, + { "const": "uncover", "title": "Uncover" } + ], + "default": "uncover" + } + }, + "additionalProperties": false, + "type": "object" +} diff --git a/packages/completer-extension/schema/manager.json b/packages/completer-extension/schema/manager.json index 672a2c5b0335..8ff2a7176285 100644 --- a/packages/completer-extension/schema/manager.json +++ b/packages/completer-extension/schema/manager.json @@ -1,7 +1,8 @@ { "title": "Code Completion", "description": "Code Completion settings.", - "jupyter.lab.setting-icon-label": "Code Completion settings", + "jupyter.lab.setting-icon": "completer:widget", + "jupyter.lab.setting-icon-label": "Code Completer", "jupyter.lab.transform": true, "properties": { "availableProviders": { diff --git a/packages/completer-extension/src/index.ts b/packages/completer-extension/src/index.ts index 282550e82144..bd18d6db4033 100644 --- a/packages/completer-extension/src/index.ts +++ b/packages/completer-extension/src/index.ts @@ -9,24 +9,44 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; +import { CommandToolbarButton } from '@jupyterlab/ui-components'; import { CompletionProviderManager, ContextCompleterProvider, + HistoryInlineCompletionProvider, ICompletionProviderManager, + IInlineCompleterFactory, + IInlineCompleterSettings, + IInlineCompletionProviderInfo, + InlineCompleter, KernelCompleterProvider } from '@jupyterlab/completer'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { + caretLeftIcon, + caretRightIcon, + checkIcon, IFormRenderer, IFormRendererRegistry } from '@jupyterlab/ui-components'; +import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import type { FieldProps } from '@rjsf/utils'; +import { CommandRegistry } from '@lumino/commands'; import { renderAvailableProviders } from './renderer'; const COMPLETION_MANAGER_PLUGIN = '@jupyterlab/completer-extension:manager'; +const INLINE_COMPLETER_PLUGIN = + '@jupyterlab/completer-extension:inline-completer'; -const defaultProvider: JupyterFrontEndPlugin = { +namespace CommandIDs { + export const nextInline = 'inline-completer:next'; + export const previousInline = 'inline-completer:previous'; + export const acceptInline = 'inline-completer:accept'; + export const invokeInline = 'inline-completer:invoke'; +} + +const defaultProviders: JupyterFrontEndPlugin = { id: '@jupyterlab/completer-extension:base-service', description: 'Adds context and kernel completion providers.', requires: [ICompletionProviderManager], @@ -40,6 +60,238 @@ const defaultProvider: JupyterFrontEndPlugin = { } }; +const inlineHistoryProvider: JupyterFrontEndPlugin = { + id: '@jupyterlab/completer-extension:inline-history', + description: + 'Adds inline completion provider suggesting code from execution history.', + requires: [ICompletionProviderManager], + optional: [ITranslator], + autoStart: true, + activate: ( + app: JupyterFrontEnd, + completionManager: ICompletionProviderManager, + translator: ITranslator | null + ): void => { + completionManager.registerInlineProvider( + new HistoryInlineCompletionProvider({ + translator: translator ?? nullTranslator + }) + ); + } +}; + +const inlineCompleterFactory: JupyterFrontEndPlugin = { + id: '@jupyterlab/completer-extension:inline-completer-factory', + description: 'Provides a factory for inline completer.', + provides: IInlineCompleterFactory, + optional: [ITranslator], + autoStart: true, + activate: ( + app: JupyterFrontEnd, + translator: ITranslator | null + ): IInlineCompleterFactory => { + const trans = (translator || nullTranslator).load('jupyterlab'); + return { + factory: options => { + const inlineCompleter = new InlineCompleter({ + ...options, + trans: trans + }); + const describeShortcut = (commandID: string): string => { + const binding = app.commands.keyBindings.find( + binding => binding.command === commandID + ); + const keys = binding + ? CommandRegistry.formatKeystroke(binding.keys) + : ''; + return keys ? `${keys}` : ''; + }; + inlineCompleter.toolbar.addItem( + 'previous-inline-completion', + new CommandToolbarButton({ + commands: app.commands, + icon: caretLeftIcon, + id: CommandIDs.previousInline, + label: describeShortcut(CommandIDs.previousInline), + caption: trans.__('Previous') + }) + ); + inlineCompleter.toolbar.addItem( + 'next-inline-completion', + new CommandToolbarButton({ + commands: app.commands, + icon: caretRightIcon, + id: CommandIDs.nextInline, + label: describeShortcut(CommandIDs.nextInline), + caption: trans.__('Next') + }) + ); + inlineCompleter.toolbar.addItem( + 'accept-inline-completion', + new CommandToolbarButton({ + commands: app.commands, + icon: checkIcon, + id: CommandIDs.acceptInline, + label: describeShortcut(CommandIDs.acceptInline), + caption: trans.__('Accept') + }) + ); + return inlineCompleter; + } + }; + } +}; + +const inlineCompleter: JupyterFrontEndPlugin = { + id: INLINE_COMPLETER_PLUGIN, + description: 'Provides a factory for inline completer.', + requires: [ + ICompletionProviderManager, + IInlineCompleterFactory, + ISettingRegistry + ], + optional: [ITranslator], + autoStart: true, + activate: ( + app: JupyterFrontEnd, + completionManager: ICompletionProviderManager, + factory: IInlineCompleterFactory, + settings: ISettingRegistry, + translator: ITranslator | null + ): void => { + completionManager.setInlineCompleterFactory(factory); + const trans = (translator || nullTranslator).load('jupyterlab'); + const isEnabled = () => + !!app.shell.currentWidget && !!completionManager.inline; + app.commands.addCommand(CommandIDs.nextInline, { + execute: () => { + completionManager.inline?.cycle(app.shell.currentWidget!.id!, 'next'); + }, + label: trans.__('Next Inline Completion'), + isEnabled + }); + app.commands.addCommand(CommandIDs.previousInline, { + execute: () => { + completionManager.inline?.cycle( + app.shell.currentWidget!.id!, + 'previous' + ); + }, + label: trans.__('Previous Inline Completion'), + isEnabled + }); + app.commands.addCommand(CommandIDs.acceptInline, { + execute: () => { + completionManager.inline?.accept(app.shell.currentWidget!.id!); + }, + label: trans.__('Accept Inline Completion'), + isEnabled + }); + app.commands.addCommand(CommandIDs.invokeInline, { + execute: () => { + completionManager.inline?.invoke(app.shell.currentWidget!.id!); + }, + label: trans.__('Invoke Inline Completer'), + isEnabled + }); + + const updateSettings = (settings: ISettingRegistry.ISettings) => { + completionManager.inline?.configure( + settings.composite as unknown as IInlineCompleterSettings + ); + }; + + app.restored + .then(() => { + const availableProviders = completionManager.inlineProviders ?? []; + const composeDefaults = (provider: IInlineCompletionProviderInfo) => { + return { + // By default all providers are opt-out, but + // any provider can configure itself to be opt-in. + enabled: true, + timeout: 5000, + debouncerDelay: 0, + ...((provider.schema?.default as object) ?? {}) + }; + }; + settings.transform(INLINE_COMPLETER_PLUGIN, { + compose: plugin => { + const providers: IInlineCompleterSettings['providers'] = + (plugin.data.composite[ + 'providers' + ] as IInlineCompleterSettings['providers']) ?? {}; + for (const provider of availableProviders) { + const defaults = composeDefaults(provider); + providers[provider.identifier] = { + ...defaults, + ...(providers[provider.identifier] ?? {}) + }; + } + // Add fallback defaults in composite settings values + plugin.data['composite']['providers'] = providers; + return plugin; + }, + fetch: plugin => { + const schema = plugin.schema.properties!; + const providersSchema: { + [property: string]: ISettingRegistry.IProperty; + } = {}; + for (const provider of availableProviders) { + providersSchema[provider.identifier] = { + title: trans.__('%1 provider', provider.name), + properties: { + ...(provider.schema?.properties ?? {}), + timeout: { + title: trans.__('Timeout'), + description: trans.__( + 'Timeout for %1 provider (in milliseconds).', + provider.name + ), + type: 'number', + minimum: 0 + }, + debouncerDelay: { + title: trans.__('Debouncer delay'), + minimum: 0, + description: trans.__( + 'Time since the last key press to wait before requesting completions from %1 provider (in milliseconds).', + provider.name + ), + type: 'number' + }, + enabled: { + title: trans.__('Enabled'), + description: trans.__( + 'Whether to fetch completions %1 provider.', + provider.name + ), + type: 'boolean' + } + }, + default: composeDefaults(provider), + type: 'object' + }; + } + // Populate schema for providers settings + schema['providers']['properties'] = providersSchema; + return plugin; + } + }); + + const settingsPromise = settings.load(INLINE_COMPLETER_PLUGIN); + settingsPromise + .then(settingValues => { + updateSettings(settingValues); + settingValues.changed.connect(newSettings => { + updateSettings(newSettings); + }); + }) + .catch(console.error); + }) + .catch(console.error); + } +}; + const manager: JupyterFrontEndPlugin = { id: COMPLETION_MANAGER_PLUGIN, description: 'Provides the completion provider manager.', @@ -124,5 +376,11 @@ const manager: JupyterFrontEndPlugin = { /** * Export the plugins as default. */ -const plugins: JupyterFrontEndPlugin[] = [manager, defaultProvider]; +const plugins: JupyterFrontEndPlugin[] = [ + manager, + defaultProviders, + inlineHistoryProvider, + inlineCompleterFactory, + inlineCompleter +]; export default plugins; diff --git a/packages/completer-extension/tsconfig.json b/packages/completer-extension/tsconfig.json index 85537029157c..0dd9ef5b8475 100644 --- a/packages/completer-extension/tsconfig.json +++ b/packages/completer-extension/tsconfig.json @@ -15,6 +15,9 @@ { "path": "../settingregistry" }, + { + "path": "../translation" + }, { "path": "../ui-components" } diff --git a/packages/completer/package.json b/packages/completer/package.json index f5a606cc412f..9a53781e534a 100644 --- a/packages/completer/package.json +++ b/packages/completer/package.json @@ -30,6 +30,7 @@ "lib/default/*.js.map", "lib/default/*.js", "style/*.css", + "style/**/*.svg", "style/index.js", "src/**/*.{ts,tsx}" ], @@ -45,13 +46,18 @@ "watch": "tsc -b --watch" }, "dependencies": { + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.9.6", "@jupyter/ydoc": "^1.1.1", "@jupyterlab/apputils": "^4.2.0-alpha.2", "@jupyterlab/codeeditor": "^4.1.0-alpha.2", + "@jupyterlab/codemirror": "^4.1.0-alpha.2", "@jupyterlab/coreutils": "^6.1.0-alpha.2", "@jupyterlab/rendermime": "^4.1.0-alpha.2", "@jupyterlab/services": "^7.1.0-alpha.2", + "@jupyterlab/settingregistry": "^4.1.0-alpha.2", "@jupyterlab/statedb": "^4.1.0-alpha.2", + "@jupyterlab/translation": "^4.1.0-alpha.2", "@jupyterlab/ui-components": "^4.1.0-alpha.2", "@lumino/algorithm": "^2.0.1", "@lumino/coreutils": "^2.1.2", diff --git a/packages/completer/src/default/inlinehistoryprovider.ts b/packages/completer/src/default/inlinehistoryprovider.ts new file mode 100644 index 000000000000..af954c6bf1c7 --- /dev/null +++ b/packages/completer/src/default/inlinehistoryprovider.ts @@ -0,0 +1,120 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import type { + IInlineCompletionContext, + IInlineCompletionProvider, + InlineCompletionTriggerKind +} from '../tokens'; +import type { CompletionHandler } from '../handler'; +import type { ISettingRegistry } from '@jupyterlab/settingregistry'; +import { KernelMessage } from '@jupyterlab/services'; +import { + ITranslator, + nullTranslator, + TranslationBundle +} from '@jupyterlab/translation'; +import { historyIcon, LabIcon } from '@jupyterlab/ui-components'; + +/** + * An example inline completion provider using history to populate suggestions. + */ +export class HistoryInlineCompletionProvider + implements IInlineCompletionProvider +{ + readonly identifier = '@jupyterlab/inline-completer:history'; + + constructor(protected options: HistoryInlineCompletionProvider.IOptions) { + const translator = options.translator || nullTranslator; + this._trans = translator.load('jupyterlab'); + } + + get name(): string { + return this._trans.__('History'); + } + + get icon(): LabIcon.ILabIcon { + return historyIcon; + } + + get schema(): ISettingRegistry.IProperty { + return { + properties: { + maxSuggestions: { + title: this._trans.__('Maximum number of suggestions'), + description: this._trans.__( + 'The maximum number of suggestions to retrieve from history.' + ), + type: 'number' + } + }, + default: { + // make this provider opt-in + enabled: false, + maxSuggestions: 100 + } + }; + } + + configure(settings: { maxSuggestions: number }): void { + this._maxSuggestions = settings.maxSuggestions ?? 100; + } + + async fetch( + request: CompletionHandler.IRequest, + context: IInlineCompletionContext, + trigger?: InlineCompletionTriggerKind + ) { + const kernel = context.session?.kernel; + + if (!kernel) { + throw new Error('No kernel for completion request.'); + } + + const multiLinePrefix = request.text.slice(0, request.offset); + const linePrefix = multiLinePrefix.split('\n').slice(-1)[0]; + + const historyRequest: KernelMessage.IHistoryRequestMsg['content'] = { + output: false, + raw: true, + hist_access_type: 'search', + pattern: linePrefix + '*', + unique: true, + n: this._maxSuggestions + }; + + const reply = await kernel.requestHistory(historyRequest); + + const items = []; + if (linePrefix === '') { + return { items: [] }; + } + if (reply.content.status === 'ok') { + for (const entry of reply.content.history) { + const sourceLines = (entry[2] as string).split('\n'); + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i]; + if (line.startsWith(linePrefix)) { + const followingLines = + line.slice(linePrefix.length, line.length) + + '\n' + + sourceLines.slice(i + 1).join('\n'); + items.push({ + insertText: followingLines + }); + } + } + } + } + + return { items }; + } + + private _trans: TranslationBundle; + private _maxSuggestions: number = 100; +} + +export namespace HistoryInlineCompletionProvider { + export interface IOptions { + translator?: ITranslator; + } +} diff --git a/packages/completer/src/ghost.ts b/packages/completer/src/ghost.ts new file mode 100644 index 000000000000..12eff3106bb2 --- /dev/null +++ b/packages/completer/src/ghost.ts @@ -0,0 +1,435 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { + Decoration, + DecorationSet, + EditorView, + WidgetType +} from '@codemirror/view'; +import { StateEffect, StateField, Text, Transaction } from '@codemirror/state'; + +const TRANSIENT_LINE_SPACER_CLASS = 'jp-GhostText-lineSpacer'; +const TRANSIENT_LETTER_SPACER_CLASS = 'jp-GhostText-letterSpacer'; +const GHOST_TEXT_CLASS = 'jp-GhostText'; +const STREAMED_TOKEN_CLASS = 'jp-GhostText-streamedToken'; +const STREAMING_INDICATOR_CLASS = 'jp-GhostText-streamingIndicator'; + +/** + * Ghost text content and placement. + */ +export interface IGhostText { + /** + * Offset in the editor where the ghost text should be placed + */ + from: number; + /** + * The content of the ghost text. + */ + content: string; + /** + * The identifier of the completion provider. + */ + providerId: string; + /** + * The tokens added in the last stream update, must be a suffix of the content. + */ + addedPart?: string; + /** + * Whether streaming is in progress. + */ + streaming?: boolean; + /** + * Callback to execute when pointer enters the boundary of the ghost text. + */ + onPointerOver?: () => void; + /** + * Callback to execute when pointer leaves the boundary of the ghost text. + */ + onPointerLeave?: () => void; +} + +export class GhostTextManager { + constructor(protected options: GhostTextManager.IOptions) { + // no-op + } + + /** + * Typing animation. + */ + static streamingAnimation: 'none' | 'uncover' = 'uncover'; + + /** + * Place ghost text in an editor. + */ + placeGhost(view: EditorView, text: IGhostText): void { + const effects: StateEffect[] = [Private.addMark.of(text)]; + + if (!view.state.field(Private.markField, false)) { + effects.push(StateEffect.appendConfig.of([Private.markField])); + effects.push( + StateEffect.appendConfig.of([ + EditorView.domEventHandlers({ + blur: (event: FocusEvent) => { + if (this.options.onBlur(event) === false) { + return true; + } + const effects: StateEffect[] = [ + Private.removeMark.of(null) + ]; + // Only execute it after editor update has completed. + setTimeout(() => { + view.dispatch({ effects }); + }, 0); + } + }) + ]) + ); + } + view.dispatch({ effects }); + } + /** + * Clear all ghost texts from the editor. + */ + clearGhosts(view: EditorView) { + const effects: StateEffect[] = [Private.removeMark.of(null)]; + view.dispatch({ effects }); + } +} + +class GhostTextWidget extends WidgetType { + constructor(protected readonly options: Omit) { + super(); + } + + isSpacer = false; + + eq(other: GhostTextWidget) { + return ( + other.content == this.content && + other.options.streaming === this.options.streaming + ); + } + + get lineBreaks() { + return (this.content.match(/\n/g) || '').length; + } + + updateDOM(dom: HTMLElement, _view: EditorView): boolean { + this._updateDOM(dom); + return true; + } + + get content() { + return this.options.content; + } + + toDOM() { + let wrap = document.createElement('span'); + if (this.options.onPointerOver) { + wrap.addEventListener('pointerover', this.options.onPointerOver); + } + if (this.options.onPointerLeave) { + wrap.addEventListener('pointerleave', this.options.onPointerLeave); + } + wrap.classList.add(GHOST_TEXT_CLASS); + wrap.dataset.animation = GhostTextManager.streamingAnimation; + wrap.dataset.providedBy = this.options.providerId; + this._updateDOM(wrap); + return wrap; + } + + private _updateDOM(dom: HTMLElement) { + const content = this.content; + let addition = this.options.addedPart; + if (addition && !this.isSpacer) { + if (addition.startsWith('\n')) { + // Show the new line straight away to ensure proper positioning. + addition = addition.substring(1); + } + dom.innerText = content.substring(0, content.length - addition.length); + const addedPart = document.createElement('span'); + addedPart.className = STREAMED_TOKEN_CLASS; + addedPart.innerText = addition; + dom.appendChild(addedPart); + } else { + // just set text + dom.innerText = content; + } + // Add "streaming-in-progress" indicator + if (!this.isSpacer && this.options.streaming) { + const streamingIndicator = document.createElement('span'); + streamingIndicator.className = STREAMING_INDICATOR_CLASS; + dom.appendChild(streamingIndicator); + } + } + destroy(dom: HTMLElement) { + if (this.options.onPointerOver) { + dom.removeEventListener('pointerover', this.options.onPointerOver); + } + if (this.options.onPointerLeave) { + dom.removeEventListener('pointerleave', this.options.onPointerLeave); + } + super.destroy(dom); + } +} + +export namespace GhostTextManager { + /** + * The initialization options for ghost text manager. + */ + export interface IOptions { + /** + * Callback for editor `blur` event. + * Returning true will prevent the default action of removing current ghost. + */ + onBlur(event: FocusEvent): boolean; + } +} + +/** + * Spacers are used to reduce height jitter in the transition between multi-line inline suggestions. + * In particular, when user removes a letter they will often get a new suggestion in split-second, + * but without spacer they would see the editor collapse in height and then elongate again. + */ +class TransientSpacerWidget extends GhostTextWidget { + isSpacer = true; +} + +class TransientLineSpacerWidget extends TransientSpacerWidget { + toDOM() { + const wrap = super.toDOM(); + wrap.classList.add(TRANSIENT_LINE_SPACER_CLASS); + return wrap; + } +} + +class TransientLetterSpacerWidget extends TransientSpacerWidget { + get content() { + return this.options.content[0]; + } + + toDOM() { + const wrap = super.toDOM(); + wrap.classList.add(TRANSIENT_LETTER_SPACER_CLASS); + return wrap; + } +} + +namespace Private { + enum GhostAction { + Set, + Remove, + FilterAndUpdate + } + + interface IGhostActionData { + /* Action to perform on editor transaction */ + action: GhostAction; + /* Spec of the ghost text to set on transaction */ + spec?: IGhostText; + } + + export const addMark = StateEffect.define({ + map: (old, change) => ({ + ...old, + from: change.mapPos(old.from), + to: change.mapPos(old.from + old.content.length) + }) + }); + + export const removeMark = StateEffect.define(); + + /** + * Decide what should be done for transaction effects. + */ + function chooseAction(tr: Transaction): IGhostActionData | null { + // This function can short-circuit because at any time there is no more than one ghost text. + for (let e of tr.effects) { + if (e.is(addMark)) { + return { + action: GhostAction.Set, + spec: e.value + }; + } else if (e.is(removeMark)) { + return { + action: GhostAction.Remove + }; + } + } + if (tr.docChanged || tr.selection) { + return { + action: GhostAction.FilterAndUpdate + }; + } + return null; + } + + function createWidget(spec: IGhostText, tr: Transaction) { + const ghost = Decoration.widget({ + widget: new GhostTextWidget(spec), + side: 1, + ghostSpec: spec + }); + // Widget decorations can only have zero-length ranges + return ghost.range( + Math.min(spec.from, tr.newDoc.length), + Math.min(spec.from, tr.newDoc.length) + ); + } + + function createSpacer( + spec: IGhostText, + tr: Transaction, + timeout: number = 1000 + ) { + // no spacer needed if content is only one character long. + if (spec.content.length < 2) { + return []; + } + + const timeoutInfo = { + elapsed: false + }; + setTimeout(() => { + timeoutInfo.elapsed = true; + }, timeout); + + const characterSpacer = Decoration.widget({ + widget: new TransientLetterSpacerWidget(spec), + side: 1, + timeoutInfo + }); + const lineSpacer = Decoration.widget({ + widget: new TransientLineSpacerWidget(spec), + side: 1, + timeoutInfo + }); + // We add two different spacers: one to temporarily preserve height of as many lines + // as there were in the content, and the other (character spacer) to ensure that + // cursor is not malformed by the presence of the line spacer. + return [ + characterSpacer.range( + Math.min(spec.from, tr.newDoc.length), + Math.min(spec.from, tr.newDoc.length) + ), + lineSpacer.range( + Math.min(spec.from, tr.newDoc.length), + Math.min(spec.from, tr.newDoc.length) + ) + ]; + } + + export const markField = StateField.define({ + create() { + return Decoration.none; + }, + update(marks, tr) { + const data = chooseAction(tr); + // remove spacers after timeout + marks = marks.update({ + filter: (_from, _to, value) => { + if (value.spec.widget instanceof TransientSpacerWidget) { + return !value.spec.timeoutInfo.elapsed; + } + return true; + } + }); + if (!data) { + return marks.map(tr.changes); + } + switch (data.action) { + case GhostAction.Set: { + const spec = data.spec!; + const newWidget = createWidget(spec, tr); + return marks.update({ + add: [newWidget], + filter: (_from, _to, value) => value === newWidget.value + }); + } + case GhostAction.Remove: + return marks.update({ + filter: () => false + }); + case GhostAction.FilterAndUpdate: { + let cursor = marks.iter(); + // skip over spacer if any + while ( + cursor.value && + cursor.value.spec.widget instanceof TransientSpacerWidget + ) { + cursor.next(); + } + if (!cursor.value) { + // short-circuit if no widgets are present, or if only spacer was present + return marks.map(tr.changes); + } + const originalSpec = cursor.value!.spec.ghostSpec as IGhostText; + const spec = { ...originalSpec }; + let shouldRemoveGhost = false; + tr.changes.iterChanges( + ( + fromA: number, + toA: number, + fromB: number, + toB: number, + inserted: Text + ) => { + if (shouldRemoveGhost) { + return; + } + if (fromA === toA && fromB !== toB) { + // text was inserted without modifying old text + for ( + let lineNumber = 0; + lineNumber < inserted.lines; + lineNumber++ + ) { + const lineContent = inserted.lineAt(lineNumber).text; + const line = + lineNumber > 0 ? '\n' + lineContent : lineContent; + if (spec.content.startsWith(line)) { + spec.content = spec.content.slice(line.length); + spec.from += line.length; + } else { + shouldRemoveGhost = true; + break; + } + } + } else if (fromB === toB && fromA !== toA) { + // text was removed + shouldRemoveGhost = true; + } else { + // text was replaced + shouldRemoveGhost = true; + // TODO: could check if the previous spec matches + } + } + ); + // removing multi-line widget would cause the code cell to jump; instead + // we add a temporary spacer widget(s) which will be removed in a future update + // allowing a slight delay between getting a new suggestion and reducing cell height + const newWidgets = shouldRemoveGhost + ? createSpacer(originalSpec, tr) + : [createWidget(spec, tr)]; + const newValues = newWidgets.map(widget => widget.value); + marks = marks.update({ + add: newWidgets, + filter: (_from, _to, value) => newValues.includes(value) + }); + if (shouldRemoveGhost) { + // TODO this can error out when deleting text, ideally a clean solution would be used. + try { + marks = marks.map(tr.changes); + } catch (e) { + console.warn(e); + return Decoration.none; + } + } + return marks; + } + } + }, + provide: f => EditorView.decorations.from(f) + }); +} diff --git a/packages/completer/src/handler.ts b/packages/completer/src/handler.ts index 6833ce097c55..037227d4783d 100644 --- a/packages/completer/src/handler.ts +++ b/packages/completer/src/handler.ts @@ -8,10 +8,18 @@ import { IDataConnector } from '@jupyterlab/statedb'; import { LabIcon } from '@jupyterlab/ui-components'; import { IDisposable } from '@lumino/disposable'; import { Message, MessageLoop } from '@lumino/messaging'; -import { Signal } from '@lumino/signaling'; - -import { CompletionTriggerKind, IProviderReconciliator } from './tokens'; +import { ISignal, Signal } from '@lumino/signaling'; + +import { + CompletionTriggerKind, + IInlineCompletionItem, + IInlineCompletionList, + IInlineCompletionProviderInfo, + InlineCompletionTriggerKind, + IProviderReconciliator +} from './tokens'; import { Completer } from './widget'; +import { InlineCompleter } from './inline'; /** * A class added to editors that can host a completer. @@ -32,6 +40,7 @@ export class CompletionHandler implements IDisposable { */ constructor(options: CompletionHandler.IOptions) { this.completer = options.completer; + this.inlineCompleter = options.inlineCompleter; this.completer.selected.connect(this.onCompletionSelected, this); this.completer.visibilityChanged.connect(this.onVisibilityChanged, this); this._reconciliator = options.reconciliator; @@ -41,6 +50,7 @@ export class CompletionHandler implements IDisposable { * The completer widget managed by the handler. */ readonly completer: Completer; + readonly inlineCompleter: InlineCompleter | undefined; set reconciliator(reconciliator: IProviderReconciliator) { this._reconciliator = reconciliator; @@ -83,6 +93,9 @@ export class CompletionHandler implements IDisposable { model.sharedModel.changed.connect(this.onTextChanged, this); // On initial load, manually check the cursor position. this.onSelectionsChanged(); + if (this.inlineCompleter) { + this.inlineCompleter.editor = editor; + } } } @@ -115,6 +128,21 @@ export class CompletionHandler implements IDisposable { Signal.clearData(this); } + /** + * Invoke the inline completer on explicit user request. + */ + invokeInline(): void { + const editor = this._editor; + if (editor) { + this._makeInlineRequest( + editor.getCursorPosition(), + InlineCompletionTriggerKind.Invoke + ).catch(reason => { + console.warn('Inline invoke request bailed', reason); + }); + } + } + /** * Invoke the handler and launch a completer. */ @@ -229,6 +257,12 @@ export class CompletionHandler implements IDisposable { return; } + const inlineModel = this.inlineCompleter?.model; + if (inlineModel) { + // Dispatch selection change. + inlineModel.handleSelectionChange(editor.getSelection()); + } + const host = editor.host; // If there is no model, return. @@ -287,17 +321,17 @@ export class CompletionHandler implements IDisposable { str: ISharedText, changed: SourceChange ): Promise { - const model = this.completer.model; - if (!model || !this._enabled) { + if (!this._enabled) { return; } - // If there is a text selection, no completion is allowed. + const model = this.completer.model; const editor = this.editor; if (!editor) { return; } if ( + model && this._autoCompletion && this._reconciliator.shouldShowContinuousHint && (await this._reconciliator.shouldShowContinuousHint( @@ -310,13 +344,29 @@ export class CompletionHandler implements IDisposable { CompletionTriggerKind.TriggerCharacter ); } - const { start, end } = editor.getSelection(); - if (start.column !== end.column || start.line !== end.line) { - return; + + const inlineModel = this.inlineCompleter?.model; + if (inlineModel) { + // Dispatch the text change to inline completer + // (this happens before request is sent) + inlineModel.handleTextChange(changed); + if (this._continuousInline) { + void this._makeInlineRequest( + editor.getCursorPosition(), + InlineCompletionTriggerKind.Automatic + ); + } } - // Dispatch the text change. - model.handleTextChange(this.getState(editor, editor.getCursorPosition())); + if (model) { + // If there is a text selection, no completion is allowed. + const { start, end } = editor.getSelection(); + if (start.column !== end.column || start.line !== end.line) { + return; + } + // Dispatch the text change. + model.handleTextChange(this.getState(editor, editor.getCursorPosition())); + } } /** @@ -351,10 +401,8 @@ export class CompletionHandler implements IDisposable { return Promise.reject(new Error('No active editor')); } - const text = editor.model.sharedModel.getSource(); - const offset = Text.jsIndexToCharIndex(editor.getOffsetAt(position), text); + const request = this._composeRequest(editor, position); const state = this.getState(editor, position); - const request: CompletionHandler.IRequest = { text, offset }; return this._reconciliator .fetch(request, trigger) .then(reply => { @@ -376,6 +424,83 @@ export class CompletionHandler implements IDisposable { }); } + private async _makeInlineRequest( + position: CodeEditor.IPosition, + trigger: InlineCompletionTriggerKind + ) { + const editor = this.editor; + + if (!editor) { + return Promise.reject(new Error('No active editor')); + } + if (!this.inlineCompleter) { + return Promise.reject(new Error('No inline completer')); + } + + const line = editor.getLine(position.line); + if (typeof line === 'undefined' || position.column < line.length) { + // only auto-trigger on end of line + return; + } + + const request = this._composeRequest(editor, position); + + const model = this.inlineCompleter.model; + if (!model) { + return; + } + model.cursor = position; + + const current = ++this._fetchingInline; + const promises = this._reconciliator.fetchInline(request, trigger); + + const completed = new Set< + Promise | null> + >(); + for (const promise of promises) { + promise + .then(result => { + if (!result || !result.items) { + return; + } + if (current !== this._fetchingInline) { + return; + } + completed.add(promise); + if (completed.size === 1) { + model.setCompletions(result); + } else { + model.appendCompletions(result); + } + }) + .catch(e => { + // Emit warning for debugging. + console.warn(e); + }) + .finally(() => { + // Mark the provider promise as completed. + completed.add(promise); + // Let the model know that we are awaiting for fewer providers now. + const remaining = promises.length - completed.size; + model.notifyProgress({ + pendingProviders: remaining, + totalProviders: promises.length + }); + }); + } + } + private _fetchingInline = 0; + + private _composeRequest( + editor: CodeEditor.IEditor, + position: CodeEditor.IPosition + ): CompletionHandler.IRequest { + const text = editor.model.sharedModel.getSource(); + const mimeType = editor.model.mimeType; + const offset = Text.jsIndexToCharIndex(editor.getOffsetAt(position), text); + return { text, offset, mimeType }; + } + /** * Updates model with text state and current cursor position. */ @@ -406,6 +531,7 @@ export class CompletionHandler implements IDisposable { private _enabled = false; private _isDisposed = false; private _autoCompletion = false; + private _continuousInline = true; } /** @@ -421,6 +547,11 @@ export namespace CompletionHandler { */ completer: Completer; + /** + * The inline completer widget; when absent inline completion is disabled. + */ + inlineCompleter?: InlineCompleter; + /** * The reconciliator that will fetch and merge completions from active providers. */ @@ -517,6 +648,34 @@ export namespace CompletionHandler { items: Array; } + /** + * Stream event type. + */ + export enum StraemEvent { + opened, + update, + closed + } + + export interface IInlineItem extends IInlineCompletionItem { + /** + * The source provider information. + */ + provider: IInlineCompletionProviderInfo; + /** + * Signal emitted when the item gets updated by streaming. + */ + stream: ISignal; + /** + * Most recent streamed token if any. + */ + lastStreamed?: string; + /** + * Whether streaming is in progress. + */ + streaming: boolean; + } + /** * The details of a completion request. */ @@ -530,6 +689,11 @@ export namespace CompletionHandler { * The text being completed. */ text: string; + + /** + * The MIME type under the cursor. + */ + mimeType?: string; } /** diff --git a/packages/completer/src/icons.ts b/packages/completer/src/icons.ts new file mode 100644 index 000000000000..85053261a979 --- /dev/null +++ b/packages/completer/src/icons.ts @@ -0,0 +1,17 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { LabIcon } from '@jupyterlab/ui-components'; + +import inlineSvgStr from '../style/icons/inline.svg'; +import widgetSvgStr from '../style/icons/widget.svg'; + +export const inlineCompleterIcon = new LabIcon({ + name: 'completer:inline', + svgstr: inlineSvgStr +}); + +export const completerWidgetIcon = new LabIcon({ + name: 'completer:widget', + svgstr: widgetSvgStr +}); diff --git a/packages/completer/src/index.ts b/packages/completer/src/index.ts index 5efae102699d..189b6e607b9a 100644 --- a/packages/completer/src/index.ts +++ b/packages/completer/src/index.ts @@ -11,5 +11,8 @@ export * from './widget'; export * from './tokens'; export * from './manager'; export * from './reconciliator'; +export * from './icons'; +export * from './inline'; export * from './default/contextprovider'; export * from './default/kernelprovider'; +export * from './default/inlinehistoryprovider'; diff --git a/packages/completer/src/inline.ts b/packages/completer/src/inline.ts new file mode 100644 index 000000000000..641e2d4c6c5e --- /dev/null +++ b/packages/completer/src/inline.ts @@ -0,0 +1,760 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { PanelLayout, Widget } from '@lumino/widgets'; +import { IDisposable } from '@lumino/disposable'; +import { ISignal, Signal } from '@lumino/signaling'; +import { Message } from '@lumino/messaging'; +import { CodeEditor } from '@jupyterlab/codeeditor'; +import { HoverBox } from '@jupyterlab/ui-components'; +import { CodeMirrorEditor } from '@jupyterlab/codemirror'; +import { SourceChange } from '@jupyter/ydoc'; +import { kernelIcon, Toolbar } from '@jupyterlab/ui-components'; +import { TranslationBundle } from '@jupyterlab/translation'; +import { + IInlineCompleterFactory, + IInlineCompleterSettings, + IInlineCompletionList +} from './tokens'; +import { CompletionHandler } from './handler'; +import { GhostTextManager } from './ghost'; + +const INLINE_COMPLETER_CLASS = 'jp-InlineCompleter'; +const HOVER_CLASS = 'jp-InlineCompleter-hover'; +const PROGRESS_BAR_CLASS = 'jp-InlineCompleter-progressBar'; + +/** + * Widget enabling user to choose among inline completions, + * typically by pressing next/previous buttons, and showing + * additional metadata about active completion, such as + * inline completion provider name. + */ +export class InlineCompleter extends Widget { + constructor(options: InlineCompleter.IOptions) { + super({ node: document.createElement('div') }); + this.model = options.model ?? null; + this.editor = options.editor ?? null; + this.addClass(INLINE_COMPLETER_CLASS); + this._ghostManager = new GhostTextManager({ + onBlur: this._onEditorBlur.bind(this) + }); + this._trans = options.trans; + const layout = (this.layout = new PanelLayout()); + layout.addWidget(this._suggestionsCounter); + layout.addWidget(this.toolbar); + layout.addWidget(this._providerWidget); + this._progressBar = document.createElement('div'); + this._progressBar.className = PROGRESS_BAR_CLASS; + this.node.appendChild(this._progressBar); + this._updateShortcutsVisibility(); + this._updateDisplay(); + // Allow the node to receive focus, which prevents removing the ghost text + // when user mis-clicks on the tooltip instead of the button in the tooltip. + this.node.tabIndex = 0; + } + + /** + * Toolbar with buttons such as previous/next/accept. + */ + get toolbar() { + return this._toolbar; + } + + /** + * The editor used by the completion widget. + */ + get editor(): CodeEditor.IEditor | null | undefined { + return this._editor; + } + set editor(newValue: CodeEditor.IEditor | null | undefined) { + this.model?.reset(); + this._editor = newValue; + } + + /** + * The model used by the completer widget. + */ + get model(): InlineCompleter.IModel | null { + return this._model; + } + set model(model: InlineCompleter.IModel | null) { + if ((!model && !this._model) || model === this._model) { + return; + } + if (this._model) { + this._model.suggestionsChanged.disconnect( + this._onModelSuggestionsChanged, + this + ); + this._model.filterTextChanged.disconnect( + this._onModelFilterTextChanged, + this + ); + this._model.provisionProgress.disconnect(this._onProvisionProgress, this); + } + this._model = model; + if (this._model) { + this._model.suggestionsChanged.connect( + this._onModelSuggestionsChanged, + this + ); + this._model.filterTextChanged.connect( + this._onModelFilterTextChanged, + this + ); + this._model.provisionProgress.connect(this._onProvisionProgress, this); + } + } + + cycle(direction: 'next' | 'previous'): void { + const items = this.model?.completions?.items; + if (!items) { + return; + } + if (direction === 'next') { + const proposed = this._current + 1; + this._current = proposed === items.length ? 0 : proposed; + } else { + const proposed = this._current - 1; + this._current = proposed === -1 ? items.length - 1 : proposed; + } + this._updateStreamTracking(); + this._render(); + } + + accept(): void { + const model = this.model; + const candidate = this.current; + const editor = this._editor; + if (!editor || !model || !candidate) { + return; + } + const position = model.cursor; + const value = candidate.insertText; + const cursorBeforeChange = editor.getOffsetAt(editor.getCursorPosition()); + const requestPosition = editor.getOffsetAt(position); + const start = requestPosition; + const end = cursorBeforeChange; + // update the shared model in a single transaction so that the undo manager works as expected + editor.model.sharedModel.updateSource( + requestPosition, + cursorBeforeChange, + value + ); + if (cursorBeforeChange <= end && cursorBeforeChange >= start) { + editor.setCursorPosition(editor.getPositionAt(start + value.length)!); + } + model.reset(); + this.update(); + } + + get current(): CompletionHandler.IInlineItem | null { + const completions = this.model?.completions; + if (!completions) { + return null; + } + return completions.items[this._current]; + } + + private _updateStreamTracking() { + if (this._lastItem) { + this._lastItem.stream.disconnect(this._onStream, this); + } + const current = this.current; + if (current) { + current.stream.connect(this._onStream, this); + } + this._lastItem = current; + } + + private _onStream( + _emitter: CompletionHandler.IInlineItem, + _change: CompletionHandler.StraemEvent + ) { + // TODO handle stuck streams, i.e. if we connected and received 'opened' + // but then did not receive 'closed' for a long time we should disconnect + // and update widget with an 'timed out' status. + const completions = this.model?.completions; + if (!completions || !completions.items || completions.items.length === 0) { + return; + } + + if (this.isHidden) { + return; + } + + const candidate = completions.items[this._current]; + this._setText(candidate); + } + + /** + * Change user-configurable settings. + */ + configure(settings: IInlineCompleterSettings) { + this._showWidget = settings.showWidget; + this._updateDisplay(); + if (settings.showShortcuts !== this._showShortcuts) { + this._showShortcuts = settings.showShortcuts; + this._updateShortcutsVisibility(); + } + GhostTextManager.streamingAnimation = settings.streamingAnimation; + } + + /** + * Handle the DOM events for the widget. + * + * @param event - The DOM event sent to the widget. + * + * #### Notes + * This method implements the DOM `EventListener` interface and is + * called in response to events on the dock panel's node. It should + * not be called directly by user code. + */ + handleEvent(event: Event): void { + if (this.isHidden || !this._editor) { + return; + } + switch (event.type) { + case 'pointerdown': + this._evtPointerdown(event as PointerEvent); + break; + case 'scroll': + this._evtScroll(event as MouseEvent); + break; + default: + break; + } + } + + /** + * Handle `update-request` messages. + */ + protected onUpdateRequest(msg: Message): void { + super.onUpdateRequest(msg); + const model = this._model; + if (!model) { + return; + } + let reply = model.completions; + + // If there are no items, hide. + if (!reply || !reply.items || reply.items.length === 0) { + if (!this.isHidden) { + this.hide(); + } + return; + } + + if (this.isHidden) { + this.show(); + this._setGeometry(); + } + } + + /** + * Handle `after-attach` messages for the widget. + */ + protected onAfterAttach(msg: Message): void { + document.addEventListener('scroll', this, true); + document.addEventListener('pointerdown', this, true); + } + + /** + * Handle `before-detach` messages for the widget. + */ + protected onBeforeDetach(msg: Message): void { + document.removeEventListener('scroll', this, true); + document.removeEventListener('pointerdown', this, true); + } + + /** + * Handle pointerdown events for the widget. + */ + private _evtPointerdown(event: PointerEvent) { + if (this.isHidden || !this._editor) { + return; + } + const target = event.target as HTMLElement; + if (this.node.contains(target)) { + return true; + } + this.hide(); + this.model?.reset(); + } + + /** + * Handle scroll events for the widget + */ + private _evtScroll(event: MouseEvent) { + if (this.isHidden || !this._editor) { + return; + } + + const { node } = this; + + // All scrolls except scrolls in the actual hover box node may cause the + // referent editor that anchors the node to move, so the only scroll events + // that can safely be ignored are ones that happen inside the hovering node. + if (node.contains(event.target as HTMLElement)) { + return; + } + + // Set the geometry of the node asynchronously. + requestAnimationFrame(() => { + this._setGeometry(); + }); + } + + private _onEditorBlur(event: FocusEvent) { + if (this.node.contains(event.relatedTarget as HTMLElement)) { + // Cancel removing ghost text if our node is receiving focus + return false; + } + // Hide the widget if editor was blurred. + this.hide(); + } + + private _onModelSuggestionsChanged( + _emitter: InlineCompleter.IModel, + args: ISuggestionsChangedArgs + ): void { + if (!this.isAttached) { + this.update(); + return; + } + if (args.event === 'set') { + this._current = args.indexMap!.get(this._current) ?? 0; + } else if (args.event === 'clear') { + const editor = this.editor; + if (editor) { + this._ghostManager.clearGhosts((editor as CodeMirrorEditor).editor); + } + } + this._updateStreamTracking(); + this.update(); + this._render(); + } + + private _onModelFilterTextChanged( + _emitter: InlineCompleter.IModel, + mapping: Map + ): void { + const completions = this.model?.completions; + if (!completions || !completions.items || completions.items.length === 0) { + return; + } + this._current = mapping.get(this._current) ?? 0; + this._updateStreamTracking(); + // Because the signal will be emitted during `EditorView.update` we want to + // wait for the update to complete before calling `this._render()`. As there + // is no API to check if update is done, we instead defer to next engine tick. + setTimeout(() => { + this._render(); + // (reading layout to get coordinate to position hoverbox is not allowed either) + this._setGeometry(); + }, 0); + } + + private _onProvisionProgress( + _emitter: InlineCompleter.IModel, + progress: InlineCompleter.IProvisionProgress + ): void { + requestAnimationFrame(() => { + if (progress.pendingProviders === 0) { + this._progressBar.style.display = 'none'; + } else { + this._progressBar.style.display = ''; + this._progressBar.style.width = + (100 * progress.pendingProviders) / progress.totalProviders + '%'; + } + }); + } + + private _render(): void { + const completions = this.model?.completions; + if (!completions || !completions.items || completions.items.length === 0) { + return; + } + const candidate = completions.items[this._current]; + this._setText(candidate); + + if (this._showWidget === 'never') { + return; + } + this._suggestionsCounter.node.innerText = this._trans.__( + '%1/%2', + this._current + 1, + completions.items.length + ); + this._providerWidget.node.title = this._trans.__( + 'Provider: %1', + candidate.provider.name + ); + const icon = candidate.provider.icon ?? kernelIcon; + icon.render(this._providerWidget.node); + } + + private _setText(item: CompletionHandler.IInlineItem) { + const text = item.insertText; + + const editor = this._editor; + const model = this._model; + if (!model || !editor) { + return; + } + + const view = (editor as CodeMirrorEditor).editor; + this._ghostManager.placeGhost(view, { + from: editor.getOffsetAt(model.cursor), + content: text, + providerId: item.provider.identifier, + addedPart: item.lastStreamed, + streaming: item.streaming, + onPointerOver: this._onPointerOverGhost.bind(this), + onPointerLeave: this._onPointerLeaveGhost.bind(this) + }); + } + + private _onPointerOverGhost() { + if (this._clearHoverTimeout !== null) { + window.clearTimeout(this._clearHoverTimeout); + this._clearHoverTimeout = null; + } + this.node.classList.add(HOVER_CLASS); + } + + private _onPointerLeaveGhost() { + // Remove after a small delay to avoid flicker when moving cursor + // between the lines or around the edges of the ghost text. + this._clearHoverTimeout = window.setTimeout( + () => this.node.classList.remove(HOVER_CLASS), + 500 + ); + } + + private _setGeometry() { + const { node } = this; + const model = this._model; + const editor = this._editor; + + if (!editor || !model || !model.cursor) { + return; + } + + const host = + (editor.host.closest('.jp-MainAreaWidget > .lm-Widget') as HTMLElement) || + editor.host; + + let anchor: DOMRect; + try { + anchor = editor.getCoordinateForPosition(model.cursor) as DOMRect; + } catch { + // if coordinate is no longer in editor (e.g. after deleting a line), hide widget + this.hide(); + return; + } + HoverBox.setGeometry({ + anchor, + host: host, + maxHeight: 40, + minHeight: 20, + node: node, + privilege: 'forceAbove', + outOfViewDisplay: { + top: 'stick-outside', + bottom: 'stick-inside', + left: 'stick-inside', + right: 'stick-outside' + } + }); + } + + private _updateShortcutsVisibility() { + this.node.dataset.showShortcuts = this._showShortcuts + ''; + } + + private _updateDisplay() { + this.node.dataset.display = this._showWidget; + } + + private _clearHoverTimeout: number | null = null; + private _current: number = 0; + private _editor: CodeEditor.IEditor | null | undefined = null; + private _ghostManager: GhostTextManager; + private _lastItem: CompletionHandler.IInlineItem | null = null; + private _model: InlineCompleter.IModel | null = null; + private _providerWidget = new Widget(); + private _showShortcuts = InlineCompleter.defaultSettings.showShortcuts; + private _showWidget = InlineCompleter.defaultSettings.showWidget; + private _suggestionsCounter = new Widget(); + private _trans: TranslationBundle; + private _toolbar = new Toolbar(); + private _progressBar: HTMLElement; +} + +/** + * Map between old and new inline completion position in the list. + */ +type IndexMap = Map; + +interface ISuggestionsChangedArgs { + /** + * Whether completions were set (new query) or appended (for existing query) + */ + event: 'set' | 'append' | 'clear'; + /** + * Map between old and new inline indices, only present for `set` event. + */ + indexMap?: IndexMap; +} + +/** + * A namespace for inline completer statics. + */ +export namespace InlineCompleter { + /** + * The initialization options for inline completer widget. + */ + export interface IOptions extends IInlineCompleterFactory.IOptions { + /** + * JupyterLab translation bundle. + */ + trans: TranslationBundle; + } + + /** + * Defaults for runtime user-configurable settings. + */ + export const defaultSettings: IInlineCompleterSettings = { + showWidget: 'onHover', + showShortcuts: true, + streamingAnimation: 'uncover', + providers: {} + }; + + /** + * Progress in generation of completion candidates by providers. + */ + export interface IProvisionProgress { + /** + * The number of providers to yet provide a reply. + * Excludes providers which resolved/rejected their promise, or timed out. + */ + pendingProviders: number; + + /** + * The number of providers from which inline completions were requested. + */ + totalProviders: number; + } + + /** + * Model for inline completions. + */ + export interface IModel extends IDisposable { + /** + * A signal emitted when new suggestions are set on the model. + */ + readonly suggestionsChanged: ISignal; + + /** + * A signal emitted when filter text is updated. + * Emits a mapping from old to new index for items after filtering. + */ + readonly filterTextChanged: ISignal; + + /** + * A signal emitted when new information about progress is available. + */ + readonly provisionProgress: ISignal; + + /** + * Original placement of cursor. + */ + cursor: CodeEditor.IPosition; + + /** + * Reset completer model. + */ + reset(): void; + + /** + * Set completions clearing existing ones. + */ + setCompletions( + reply: IInlineCompletionList + ): void; + + /** + * Append completions while preserving new ones. + */ + appendCompletions( + reply: IInlineCompletionList + ): void; + + /** + * Notify model about progress in generation of completion candidates by providers. + */ + notifyProgress(providerProgress: IProvisionProgress): void; + + /** + * Current inline completions. + */ + readonly completions: IInlineCompletionList | null; + + /** + * Handle a source change. + */ + handleTextChange(change: SourceChange): void; + + /** + * Handle cursor selection change. + */ + handleSelectionChange(range: CodeEditor.IRange): void; + } + + /** + * Model for inline completions. + */ + export class Model implements InlineCompleter.IModel { + setCompletions( + reply: IInlineCompletionList + ) { + const previousPositions: Map = new Map( + this._completions?.items?.map((item, index) => [item.insertText, index]) + ); + this._completions = reply; + const indexMap = new Map( + reply.items.map((item, newIndex) => [ + previousPositions.get(item.insertText)!, + newIndex + ]) + ); + this.suggestionsChanged.emit({ + event: 'set', + indexMap + }); + } + + appendCompletions( + reply: IInlineCompletionList + ) { + if (!this._completions || !this._completions.items) { + console.warn('No completions to append to'); + return; + } + this._completions.items.push(...reply.items); + this.suggestionsChanged.emit({ event: 'append' }); + } + + notifyProgress(progress: IProvisionProgress) { + this.provisionProgress.emit(progress); + } + + get cursor(): CodeEditor.IPosition { + return this._cursor; + } + + set cursor(value: CodeEditor.IPosition) { + this._cursor = value; + } + + get completions() { + return this._completions; + } + + reset() { + this._completions = null; + this.suggestionsChanged.emit({ event: 'clear' }); + } + + /** + * Get whether the model is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + handleTextChange(sourceChange: SourceChange) { + const completions = this._completions; + if ( + !completions || + !completions.items || + completions.items.length === 0 + ) { + return; + } + const originalPositions: Map = + new Map(completions.items.map((item, index) => [item, index])); + for (let change of sourceChange.sourceChange ?? []) { + const insert = change.insert; + if (insert) { + const items = completions.items.filter(item => { + const filterText = item.filterText ?? item.insertText; + if (!filterText.startsWith(insert)) { + return false; + } + item.filterText = filterText.substring(insert.length); + item.insertText = item.insertText.substring(insert.length); + return true; + }); + if (items.length === 0) { + // all items from this provider were filtered out + this._completions = null; + } + completions.items = items; + } else { + if (!change.retain) { + this._completions = null; + } + } + } + const indexMap = new Map( + completions.items.map((item, newIndex) => [ + originalPositions.get(item)!, + newIndex + ]) + ); + this.filterTextChanged.emit(indexMap); + } + + handleSelectionChange(range: CodeEditor.IRange) { + const initialCursor = this.cursor; + if (!initialCursor) { + return; + } + const { start, end } = range; + if (start.column !== end.column || start.line !== end.line) { + // Cancel if user started selecting text. + this.reset(); + } + if ( + start.line !== initialCursor.line || + start.column < initialCursor.column + ) { + // Cancel if user moved cursor to next line or receded to before the origin + this.reset(); + } + } + + /** + * Dispose of the resources held by the model. + */ + dispose(): void { + // Do nothing if already disposed. + if (this._isDisposed) { + return; + } + this._isDisposed = true; + Signal.clearData(this); + } + + suggestionsChanged = new Signal(this); + filterTextChanged = new Signal(this); + provisionProgress = new Signal(this); + private _isDisposed = false; + private _completions: IInlineCompletionList | null = + null; + private _cursor: CodeEditor.IPosition; + } +} diff --git a/packages/completer/src/manager.ts b/packages/completer/src/manager.ts index 0738e4f2dce6..5ce5286d8ba6 100644 --- a/packages/completer/src/manager.ts +++ b/packages/completer/src/manager.ts @@ -8,15 +8,21 @@ import { CONTEXT_PROVIDER_ID } from './default/contextprovider'; import { KERNEL_PROVIDER_ID } from './default/kernelprovider'; import { CompletionHandler } from './handler'; import { CompleterModel } from './model'; +import { InlineCompleter } from './inline'; import { ICompletionContext, ICompletionProvider, - ICompletionProviderManager + ICompletionProviderManager, + IInlineCompleterActions, + IInlineCompleterFactory, + IInlineCompleterSettings, + IInlineCompletionProvider, + IInlineCompletionProviderInfo } from './tokens'; import { Completer } from './widget'; /** - * A manager for completer provider. + * A manager for completion providers. */ export class CompletionProviderManager implements ICompletionProviderManager { /** @@ -24,11 +30,13 @@ export class CompletionProviderManager implements ICompletionProviderManager { */ constructor() { this._providers = new Map(); + this._inlineProviders = new Map(); this._panelHandlers = new Map(); this._mostRecentContext = new Map(); this._activeProvidersChanged = new Signal( this ); + this._inlineCompleterFactory = null; } /** @@ -74,7 +82,7 @@ export class CompletionProviderManager implements ICompletionProviderManager { const identifier = provider.identifier; if (this._providers.has(identifier)) { console.warn( - `Completion service with identifier ${identifier} is already registered` + `Completion provider with identifier ${identifier} is already registered` ); } else { this._providers.set(identifier, provider); @@ -84,6 +92,20 @@ export class CompletionProviderManager implements ICompletionProviderManager { } } + registerInlineProvider(provider: IInlineCompletionProvider): void { + const identifier = provider.identifier; + if (this._inlineProviders.has(identifier)) { + console.warn( + `Completion provider with identifier ${identifier} is already registered` + ); + } else { + this._inlineProviders.set(identifier, provider); + this._panelHandlers.forEach((handler, id) => { + void this.updateCompleter(this._mostRecentContext.get(id)!); + }); + } + } + /** * * Return the map of providers. @@ -141,6 +163,7 @@ export class CompletionProviderManager implements ICompletionProviderManager { const options = { model, + editor, renderer, sanitizer, showDoc: this._showDoc @@ -197,6 +220,69 @@ export class CompletionProviderManager implements ICompletionProviderManager { } } + /** + * Set inline completer factory. + */ + setInlineCompleterFactory(factory: IInlineCompleterFactory) { + this._inlineCompleterFactory = factory; + this._panelHandlers.forEach((handler, id) => { + void this.updateCompleter(this._mostRecentContext.get(id)!); + }); + if (this.inline) { + return; + } + this.inline = { + invoke: (id: string) => { + const handler = this._panelHandlers.get(id); + if (handler && handler.inlineCompleter) { + handler.invokeInline(); + } + }, + cycle: (id: string, direction: 'next' | 'previous') => { + const handler = this._panelHandlers.get(id); + if (handler && handler.inlineCompleter) { + handler.inlineCompleter.cycle(direction); + } + }, + accept: (id: string) => { + const handler = this._panelHandlers.get(id); + if (handler && handler.inlineCompleter) { + handler.inlineCompleter.accept(); + } + }, + configure: (settings: IInlineCompleterSettings) => { + this._inlineCompleterSettings = settings; + this._panelHandlers.forEach((handler, handlerId) => { + for (const [ + providerId, + provider + ] of this._inlineProviders.entries()) { + if (provider.configure) { + provider.configure(settings.providers[providerId]); + } + } + if (handler.inlineCompleter) { + handler.inlineCompleter.configure(settings); + } + // trigger update to regenerate reconciliator + void this.updateCompleter(this._mostRecentContext.get(handlerId)!); + }); + } + }; + } + + /** + * Inline completer actions. + */ + inline?: IInlineCompleterActions; + + /** + * Inline providers information. + */ + get inlineProviders(): IInlineCompletionProviderInfo[] { + return [...this._inlineProviders.values()]; + } + /** * Helper function to generate a `ProviderReconciliator` with provided context. * The `isApplicable` method of provider is used to filter out the providers @@ -207,8 +293,19 @@ export class CompletionProviderManager implements ICompletionProviderManager { private async generateReconciliator( completerContext: ICompletionContext ): Promise { + const enabledProviders: string[] = []; + for (const [id, providerSettings] of Object.entries( + this._inlineCompleterSettings.providers + )) { + if ((providerSettings as any).enabled === true) { + enabledProviders.push(id); + } + } + const inlineProviders = [...this._inlineProviders.values()].filter( + provider => enabledProviders.includes(provider.identifier) + ); + const providers: Array = []; - //TODO Update list with rank for (const id of this._activeProviders) { const provider = this._providers.get(id); if (provider) { @@ -218,6 +315,8 @@ export class CompletionProviderManager implements ICompletionProviderManager { return new ProviderReconciliator({ context: completerContext, providers, + inlineProviders, + inlineProvidersSettings: this._inlineCompleterSettings.providers, timeout: this._timeout }); } @@ -231,6 +330,8 @@ export class CompletionProviderManager implements ICompletionProviderManager { private disposeHandler(id: string, handler: CompletionHandler) { handler.completer.model?.dispose(); handler.completer.dispose(); + handler.inlineCompleter?.model?.dispose(); + handler.inlineCompleter?.dispose(); handler.dispose(); this._panelHandlers.delete(id); } @@ -243,11 +344,23 @@ export class CompletionProviderManager implements ICompletionProviderManager { options: Completer.IOptions ): Promise { const completer = new Completer(options); + const inlineCompleter = this._inlineCompleterFactory + ? this._inlineCompleterFactory.factory({ + ...options, + model: new InlineCompleter.Model() + }) + : undefined; completer.hide(); Widget.attach(completer, document.body); + if (inlineCompleter) { + Widget.attach(inlineCompleter, document.body); + inlineCompleter.hide(); + inlineCompleter.configure(this._inlineCompleterSettings); + } const reconciliator = await this.generateReconciliator(completerContext); const handler = new CompletionHandler({ completer, + inlineCompleter, reconciliator: reconciliator }); handler.editor = completerContext.editor; @@ -256,10 +369,15 @@ export class CompletionProviderManager implements ICompletionProviderManager { } /** - * The completer provider map, the keys are id of provider + * The completion provider map, the keys are id of provider */ private readonly _providers: Map; + /** + * The inline completion provider map, the keys are id of provider + */ + private readonly _inlineProviders: Map; + /** * The completer handler map, the keys are id of widget and * values are the completer handler attached to this widget. @@ -278,7 +396,7 @@ export class CompletionProviderManager implements ICompletionProviderManager { private _activeProviders = new Set([KERNEL_PROVIDER_ID, CONTEXT_PROVIDER_ID]); /** - * Timeout value for the completer provider. + * Timeout value for the completion provider. */ private _timeout: number; @@ -293,4 +411,6 @@ export class CompletionProviderManager implements ICompletionProviderManager { private _autoCompletion: boolean; private _activeProvidersChanged: Signal; + private _inlineCompleterFactory: IInlineCompleterFactory | null; + private _inlineCompleterSettings = InlineCompleter.defaultSettings; } diff --git a/packages/completer/src/reconciliator.ts b/packages/completer/src/reconciliator.ts index e2d2852703f0..ad616c37750d 100644 --- a/packages/completer/src/reconciliator.ts +++ b/packages/completer/src/reconciliator.ts @@ -7,9 +7,17 @@ import { CompletionTriggerKind, ICompletionContext, ICompletionProvider, + IInlineCompleterSettings, + IInlineCompletionList, + IInlineCompletionProvider, + InlineCompletionTriggerKind, IProviderReconciliator } from './tokens'; import { Completer } from './widget'; +import { Signal } from '@lumino/signaling'; + +// Shorthand for readability. +type InlineResult = IInlineCompletionList | null; /** * The reconciliator which is used to fetch and merge responses from multiple completion providers. @@ -20,6 +28,8 @@ export class ProviderReconciliator implements IProviderReconciliator { */ constructor(options: ProviderReconciliator.IOptions) { this._providers = options.providers; + this._inlineProviders = options.inlineProviders ?? []; + this._inlineProvidersSettings = options.inlineProvidersSettings ?? {}; this._context = options.context; this._timeout = options.timeout; } @@ -37,6 +47,96 @@ export class ProviderReconciliator implements IProviderReconciliator { return this._providers.filter((_, idx) => applicableProviders[idx]); } + fetchInline( + request: CompletionHandler.IRequest, + trigger: InlineCompletionTriggerKind + ): Promise[] { + let promises: Promise< + IInlineCompletionList + >[] = []; + const current = ++this._inlineFetching; + for (const provider of this._inlineProviders) { + const settings = this._inlineProvidersSettings[provider.identifier]; + + let delay = 0; + if (trigger === InlineCompletionTriggerKind.Automatic) { + delay = settings.debouncerDelay; + } + + const fetch = (): Promise => { + const promise = provider + .fetch(request, { ...this._context, triggerKind: trigger }) + .then(completionList => { + return { + ...completionList, + items: completionList.items.map(item => { + const newItem = item as CompletionHandler.IInlineItem; + newItem.stream = new Signal(newItem); + newItem.provider = provider; + void this._stream(newItem, provider); + return newItem; + }) + }; + }); + const timeoutPromise = new Promise(resolve => { + return setTimeout(() => resolve(null), delay + settings.timeout); + }); + return Promise.race([promise, timeoutPromise]); + }; + const promise = + delay === 0 + ? fetch() + : new Promise((resolve, reject) => { + return setTimeout(() => { + if (current != this._inlineFetching) { + // User pressed another key or explicitly requested completions since. + return reject(null); + } else { + return resolve(fetch()); + } + }, delay); + }); + + // Wrap promise and return error in case of failure. + promises.push(promise.catch(p => p)); + } + return promises; + } + + private async _stream( + item: CompletionHandler.IInlineItem, + provider: IInlineCompletionProvider + ) { + if (!item.isIncomplete || !provider.stream || !item.token) { + return; + } + const streamed = item.stream as Signal< + CompletionHandler.IInlineItem, + CompletionHandler.StraemEvent + >; + const token = item.token; + item.token = undefined; + + // Notify that streaming started. + item.streaming = true; + streamed.emit(CompletionHandler.StraemEvent.opened); + + for await (const reply of provider.stream(token)) { + const updated = reply.response; + const addition = updated.insertText.substring(item.insertText.length); + // Stream an update. + item.insertText = updated.insertText; + item.lastStreamed = addition; + streamed.emit(CompletionHandler.StraemEvent.update); + } + + // Notify that streaming is no longer in progress. + item.isIncomplete = false; + item.lastStreamed = undefined; + item.streaming = false; + streamed.emit(CompletionHandler.StraemEvent.closed); + } + /** * Fetch response from multiple providers, If a provider can not return * the response for a completer request before timeout, @@ -65,10 +165,9 @@ export class ProviderReconciliator implements IProviderReconciliator { return { ...reply, items }; }); - const timeoutPromise = - new Promise(resolve => { - return setTimeout(() => resolve(null), this._timeout); - }); + const timeoutPromise = new Promise(resolve => { + return setTimeout(() => resolve(null), this._timeout); + }); promise = Promise.race([promise, timeoutPromise]); // Wrap promise and return error in case of failure. promises.push(promise.catch(p => p)); @@ -225,6 +324,16 @@ export class ProviderReconciliator implements IProviderReconciliator { */ private _providers: Array; + /** + * List of inline providers. + */ + private _inlineProviders: Array; + + /** + * Inline providers settings. + */ + private _inlineProvidersSettings: IInlineCompleterSettings['providers']; + /** * Current completer context. */ @@ -239,6 +348,11 @@ export class ProviderReconciliator implements IProviderReconciliator { * Counter to reject current provider response if a new fetch request is created. */ private _fetching = 0; + + /** + * Counter to reject current inline provider response if a new `inlineFetch` request is created. + */ + private _inlineFetching = 0; } export namespace ProviderReconciliator { @@ -254,6 +368,12 @@ export namespace ProviderReconciliator { * List of completion providers, assumed to contain at least one provider. */ providers: ICompletionProvider[]; + /** + * List of inline completion providers, may be empty. + */ + inlineProviders?: IInlineCompletionProvider[]; + + inlineProvidersSettings?: IInlineCompleterSettings['providers']; /** * How long should we wait for each of the providers to resolve `fetch` promise */ diff --git a/packages/completer/src/svg.d.ts b/packages/completer/src/svg.d.ts new file mode 100644 index 000000000000..1ecebae240da --- /dev/null +++ b/packages/completer/src/svg.d.ts @@ -0,0 +1,9 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +declare module '*.svg' { + const value: string; // @ts-ignore + export default value; +} diff --git a/packages/completer/src/testutils.ts b/packages/completer/src/testutils.ts new file mode 100644 index 000000000000..dd68261d3d9c --- /dev/null +++ b/packages/completer/src/testutils.ts @@ -0,0 +1,46 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor'; +import { CodeMirrorEditor, ybinding } from '@jupyterlab/codemirror'; +import { YFile } from '@jupyter/ydoc'; + +export function createEditorWidget(): CodeEditorWrapper { + const model = new CodeEditor.Model({ sharedModel: new YFile() }); + const factory = (options: CodeEditor.IOptions) => { + const m = options.model.sharedModel as any; + options.extensions = [ + ...(options.extensions ?? []), + ybinding({ ytext: m.ysource }) + ]; + return new CodeMirrorEditor(options); + }; + return new CodeEditorWrapper({ factory, model }); +} + +/** + * jsdom mock for getBoundingClientRect returns zeros for all fields, + * see https://github.com/jsdom/jsdom/issues/653. We can do better, + * and need to do better to get meaningful tests for rendering. + */ +export function getBoundingClientRectMock() { + const style = window.getComputedStyle(this); + const top = parseFloat(style.top) || 0; + const left = parseFloat(style.left) || 0; + const dimensions = { + width: parseFloat(style.width) || parseFloat(style.minWidth) || 0, + height: parseFloat(style.height) || parseFloat(style.minHeight) || 0, + top, + left, + x: left, + y: top, + bottom: 0, + right: 0 + }; + return { + ...dimensions, + toJSON: () => dimensions + }; +} diff --git a/packages/completer/src/tokens.ts b/packages/completer/src/tokens.ts index bb7240e4b335..b03c0e054984 100644 --- a/packages/completer/src/tokens.ts +++ b/packages/completer/src/tokens.ts @@ -4,12 +4,15 @@ import { CodeEditor } from '@jupyterlab/codeeditor'; import { IRenderMime } from '@jupyterlab/rendermime'; import { Session } from '@jupyterlab/services'; +import { LabIcon } from '@jupyterlab/ui-components'; import { SourceChange } from '@jupyter/ydoc'; -import { Token } from '@lumino/coreutils'; +import { JSONValue, Token } from '@lumino/coreutils'; +import type { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ISignal } from '@lumino/signaling'; import { Widget } from '@lumino/widgets'; import { CompletionHandler } from './handler'; import { Completer } from './widget'; +import { InlineCompleter } from './inline'; /** * The type of completion request. @@ -21,8 +24,7 @@ export enum CompletionTriggerKind { } /** - * The context which will be passed to the `fetch` function - * of a provider. + * The context which will be passed to the completion provider. */ export interface ICompletionContext { /** @@ -48,7 +50,7 @@ export interface ICompletionContext { } /** - * The interface to implement a completer provider. + * The interface to implement a completion provider. */ export interface ICompletionProvider< T extends @@ -138,6 +140,181 @@ export interface ICompletionProvider< ): boolean; } +/** + * Describes how an inline completion provider was triggered. + * @alpha + */ +export enum InlineCompletionTriggerKind { + /** + * Completion was triggered explicitly by a user gesture. + * Return multiple completion items to enable cycling through them. + */ + Invoke = 0, + /** + * Completion was triggered automatically while editing. + * It is sufficient to return a single completion item in this case. + */ + Automatic = 1 +} + +/** + * The context which will be passed to the inline completion provider. + * @alpha + */ +export interface IInlineCompletionContext { + /** + * The widget (notebook, console, code editor) which invoked + * the inline completer + */ + widget: Widget; + + /** + * Describes how an inline completion provider was triggered. + */ + triggerKind: InlineCompletionTriggerKind; + + /** + * The session extracted from widget for convenience. + */ + session?: Session.ISessionConnection | null; +} + +/** + * LSP 3.18-compliant inline completion API subset. + */ +interface IInlineCompletionItemLSP { + /** + * The text to replace the range with. Must be set. + * Is used both for the preview and the accept operation. + */ + insertText: string; + + /** + * A text that is used to decide if this inline completion should be + * shown. When `falsy` the insertText is used. + * + * An inline completion is shown if the text to replace is a prefix of the + * filter text. + */ + filterText?: string; +} + +/** + * An inline completion item represents a text snippet that is proposed inline + * to complete text that is being typed. + * @alpha + */ +export interface IInlineCompletionItem extends IInlineCompletionItemLSP { + token?: string; + /** + * Whether generation of `insertText` is still ongoing. If your provider supports streaming, + * you can set this to true, which will result in the provider's `stream()` method being called + * with `token` which has to be set for incomplete completions. + */ + isIncomplete?: boolean; +} + +export interface IInlineCompletionList< + T extends IInlineCompletionItem = IInlineCompletionItem +> { + /** + * The inline completion items. + */ + items: T[]; +} + +/** + * The inline completion provider information used in widget rendering. + */ +export interface IInlineCompletionProviderInfo { + /** + * Name of the provider to be displayed in the user interface. + */ + readonly name: string; + + /** + * Unique identifier, cannot change on runtime. + * + * The identifier is also added on data attribute of ghost text widget, + * allowing different providers to style the ghost text differently. + */ + readonly identifier: string; + + /** + * The icon representing the provider in the user interface. + */ + readonly icon?: LabIcon.ILabIcon; + + /** + * Settings schema contributed by provider for user customization. + */ + readonly schema?: ISettingRegistry.IProperty; +} + +/** + * The interface extensions should implement to provide inline completions. + */ +export interface IInlineCompletionProvider< + T extends IInlineCompletionItem = IInlineCompletionItem +> extends IInlineCompletionProviderInfo { + /** + * The method called when user requests inline completions. + * + * The implicit request (on typing) vs explicit invocation are distinguished + * by the value of `triggerKind` in the provided `context`. + */ + fetch( + request: CompletionHandler.IRequest, + context: IInlineCompletionContext + ): Promise>; + + /** + * Optional method called when user changes settings. + * + * This is only called if `schema` for settings is present. + */ + configure?(settings: { [property: string]: JSONValue }): void; + + /** + * Optional method to stream remainder of the `insertText`. + */ + stream?(token: string): AsyncGenerator<{ response: T }>; +} + +/** + * Inline completer factory + */ +export interface IInlineCompleterFactory { + factory(options: IInlineCompleterFactory.IOptions): InlineCompleter; +} + +/** + * A namespace for inline completer factory statics. + */ +export namespace IInlineCompleterFactory { + /** + * The subset of inline completer widget initialization options provided to the factory. + */ + export interface IOptions { + /** + * The semantic parent of the completer widget, its referent editor. + */ + editor?: CodeEditor.IEditor | null; + /** + * The model for the completer widget. + */ + model?: InlineCompleter.IModel; + } +} + +/** + * Token allowing to override (or disable) inline completer widget factory. + */ +export const IInlineCompleterFactory = new Token( + '@jupyterlab/completer:IInlineCompleterFactory', + 'A factory of inline completer widgets.' +); + /** * The exported token used to register new provider. */ @@ -154,6 +331,16 @@ export interface ICompletionProviderManager { */ registerProvider(provider: ICompletionProvider): void; + /** + * Register an inline completer provider with the manager. + */ + registerInlineProvider(provider: IInlineCompletionProvider): void; + + /** + * Set inline completer factory. + */ + setInlineCompleterFactory(factory: IInlineCompleterFactory): void; + /** * Invoke the completer in the widget with provided id. * @@ -179,6 +366,80 @@ export interface ICompletionProviderManager { * Signal emitted when active providers list is changed. */ activeProvidersChanged: ISignal; + + /** + * Inline completer actions. + */ + inline?: IInlineCompleterActions; + + /** + * Inline providers information. + */ + inlineProviders?: IInlineCompletionProviderInfo[]; +} + +export interface IInlineCompleterActions { + /** + * Invoke inline completer. + * @experimental + * + * @param id - the id of notebook panel, console panel or code editor. + */ + invoke(id: string): void; + + /** + * Switch to next or previous completion of inline completer. + * @experimental + * + * @param id - the id of notebook panel, console panel or code editor. + * @param direction - the cycling direction + */ + cycle(id: string, direction: 'next' | 'previous'): void; + + /** + * Accept active inline completion. + * @experimental + * + * @param id - the id of notebook panel, console panel or code editor. + */ + accept(id: string): void; + + /** + * Configure the inline completer. + * @experimental + * + * @param settings - the new settings values. + */ + configure(settings: IInlineCompleterSettings): void; +} + +/** + * Inline completer user-configurable settings. + */ +export interface IInlineCompleterSettings { + /** + * Whether to show the inline completer widget. + */ + showWidget: 'always' | 'onHover' | 'never'; + /** + * Whether to show shortcuts in the inline completer widget. + */ + showShortcuts: boolean; + /** + * Transition effect used when streaming tokens from model. + */ + streamingAnimation: 'none' | 'uncover'; + /** + * Provider settings. + */ + providers: { + [providerId: string]: { + enabled: boolean; + debouncerDelay: number; + timeout: number; + [property: string]: JSONValue; + }; + }; } export interface IProviderReconciliator { @@ -195,6 +456,17 @@ export interface IProviderReconciliator { trigger?: CompletionTriggerKind ): Promise; + /** + * Returns a list of promises to enable showing results from + * the provider which resolved fastest, even if other providers + * are still generating. + * The result may be null if the request timed out. + */ + fetchInline( + request: CompletionHandler.IRequest, + trigger?: InlineCompletionTriggerKind + ): Promise | null>[]; + /** * Check if completer should make request to fetch completion responses * on user typing. If the provider with highest rank does not have diff --git a/packages/completer/src/widget.ts b/packages/completer/src/widget.ts index 5a2acd452b94..504cd415410c 100644 --- a/packages/completer/src/widget.ts +++ b/packages/completer/src/widget.ts @@ -349,6 +349,7 @@ export class Completer extends Widget { this._docPanel.innerText = ''; node.appendChild(this._docPanel); this._docPanelExpanded = false; + this._docPanel.style.display = 'none'; this._updateDocPanel(resolvedItem, active); } diff --git a/packages/completer/style/base.css b/packages/completer/style/base.css index a56a115e09c9..6b131d92a876 100644 --- a/packages/completer/style/base.css +++ b/packages/completer/style/base.css @@ -181,8 +181,8 @@ .jp-Completer-loading-bar-container { height: 2px; - width: 100%; - background-color: var(--jp-layout-color2); + width: calc(100% - var(--jp-private-completer-item-height)); + left: var(--jp-private-completer-item-height); position: absolute; overflow: hidden; top: 0; @@ -191,7 +191,7 @@ .jp-Completer-loading-bar { height: 100%; width: 50%; - background-color: var(--jp-layout-color4); + background-color: var(--jp-accent-color2); position: absolute; left: -50%; animation: jp-Completer-loading 2s ease-in 0.5s infinite; @@ -206,3 +206,130 @@ transform: translateX(400%); } } + +.jp-GhostText { + color: var(--jp-ui-font-color3); + white-space: pre-wrap; +} + +.jp-GhostText-lineSpacer, +.jp-GhostText-letterSpacer { + opacity: 0; + display: inline-block; + vertical-align: top; + max-width: 0; +} + +.jp-GhostText-lineSpacer { + animation: jp-GhostText-hide 300ms 700ms ease-out forwards; +} + +@keyframes jp-GhostText-hide { + 0% { + font-size: unset; + } + + 100% { + font-size: 0; + } +} + +.jp-GhostText[data-animation='uncover'] { + position: relative; +} + +.jp-GhostText-streamedToken { + white-space: pre; +} + +.jp-GhostText[data-animation='uncover'] > .jp-GhostText-streamedToken { + animation: jp-GhostText-typing 2s forwards; + display: inline-flex; + overflow: hidden; +} + +@keyframes jp-GhostText-typing { + from { + max-width: 0; + } + + to { + max-width: 100%; + } +} + +.jp-GhostText-streamingIndicator::after { + animation: jp-GhostText-streaming 2s infinite; + animation-delay: 400ms; + content: ' '; + background: var(--jp-layout-color4); + opacity: 0.2; +} + +@keyframes jp-GhostText-streaming { + 0% { + opacity: 0.2; + } + + 20% { + opacity: 0.4; + } + + 40% { + opacity: 0.2; + } +} + +.jp-InlineCompleter { + box-shadow: var(--jp-elevation-z2); + background: var(--jp-layout-color1); + color: var(--jp-content-font-color1); + border: var(--jp-border-width) solid var(--jp-border-color1); + display: flex; + flex-direction: row; + align-items: center; + padding: 0 8px; +} + +.jp-InlineCompleter-progressBar { + height: 2px; + position: absolute; + top: 0; + left: 0; + background-color: var(--jp-accent-color2); +} + +.jp-InlineCompleter[data-display='onHover'] { + opacity: 0; + transition: + visibility 0s linear 0.1s, + opacity 0.1s linear; + visibility: hidden; +} + +.jp-InlineCompleter[data-display='onHover']:hover, +.jp-InlineCompleter-hover[data-display='onHover'] { + opacity: 1; + visibility: visible; + transition-delay: 0s; +} + +.jp-InlineCompleter[data-display='never'] { + display: none; +} + +.jp-InlineCompleter > .jp-Toolbar { + box-shadow: none; + border-bottom: none; + background: none; +} + +.jp-InlineCompleter[data-show-shortcuts='false'] + .jp-ToolbarButtonComponent-label { + display: none; +} + +[data-command='inline-completer:next'] > .jp-ToolbarButtonComponent-icon, +[data-command='inline-completer:previous'] > .jp-ToolbarButtonComponent-icon { + scale: 1.5; +} diff --git a/packages/completer/style/icons/inline.svg b/packages/completer/style/icons/inline.svg new file mode 100644 index 000000000000..996ba59ba740 --- /dev/null +++ b/packages/completer/style/icons/inline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/completer/style/icons/widget.svg b/packages/completer/style/icons/widget.svg new file mode 100644 index 000000000000..3c370e2478de --- /dev/null +++ b/packages/completer/style/icons/widget.svg @@ -0,0 +1,9 @@ + + + + diff --git a/packages/completer/style/index.css b/packages/completer/style/index.css index 0a3aea25b58c..d653449ab40f 100644 --- a/packages/completer/style/index.css +++ b/packages/completer/style/index.css @@ -7,5 +7,6 @@ @import url('~@lumino/widgets/style/index.css'); @import url('~@jupyterlab/ui-components/style/index.css'); @import url('~@jupyterlab/apputils/style/index.css'); +@import url('~@jupyterlab/codemirror/style/index.css'); @import url('~@jupyterlab/rendermime/style/index.css'); @import url('./base.css'); diff --git a/packages/completer/style/index.js b/packages/completer/style/index.js index 7a5968c1d68f..24a06ccc2b83 100644 --- a/packages/completer/style/index.js +++ b/packages/completer/style/index.js @@ -7,6 +7,7 @@ import '@lumino/widgets/style/index.js'; import '@jupyterlab/ui-components/style/index.js'; import '@jupyterlab/apputils/style/index.js'; +import '@jupyterlab/codemirror/style/index.js'; import '@jupyterlab/rendermime/style/index.js'; import './base.css'; diff --git a/packages/completer/test/inline.spec.ts b/packages/completer/test/inline.spec.ts new file mode 100644 index 000000000000..675e2ae3c194 --- /dev/null +++ b/packages/completer/test/inline.spec.ts @@ -0,0 +1,353 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { + CompletionHandler, + IInlineCompletionProvider, + InlineCompleter +} from '@jupyterlab/completer'; +import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; +import { nullTranslator } from '@jupyterlab/translation'; +import { simulate } from '@jupyterlab/testing'; +import { Signal } from '@lumino/signaling'; +import { createEditorWidget } from '@jupyterlab/completer/lib/testutils'; +import { Widget } from '@lumino/widgets'; +import { MessageLoop } from '@lumino/messaging'; +import { Doc, Text } from 'yjs'; + +describe('completer/inline', () => { + const exampleProvider: IInlineCompletionProvider = { + name: 'An inline provider', + identifier: 'inline-provider', + fetch: async _request => { + return { + items: [itemDefaults] + }; + } + }; + const itemDefaults: CompletionHandler.IInlineItem = { + insertText: 'test', + streaming: false, + provider: exampleProvider, + stream: new Signal({} as any) + }; + + describe('InlineCompleter', () => { + let completer: InlineCompleter; + let editorWidget: CodeEditorWrapper; + let model: InlineCompleter.Model; + let suggestionsAbc: CompletionHandler.IInlineItem[]; + + beforeEach(() => { + editorWidget = createEditorWidget(); + model = new InlineCompleter.Model(); + model.cursor = { + line: 0, + column: 0 + }; + completer = new InlineCompleter({ + editor: editorWidget.editor, + model, + trans: nullTranslator.load('test') + }); + model.cursor = editorWidget.editor.getPositionAt(0)!; + suggestionsAbc = [ + { + ...itemDefaults, + insertText: 'suggestion a' + }, + { + ...itemDefaults, + insertText: 'suggestion b' + }, + { + ...itemDefaults, + insertText: 'suggestion c' + } + ]; + }); + + describe('#accept()', () => { + it('should update editor source', () => { + model.setCompletions({ + items: suggestionsAbc + }); + completer.accept(); + expect(editorWidget.editor.model.sharedModel.source).toBe( + 'suggestion a' + ); + }); + }); + + describe('#current', () => { + it('preserves active completion when results change order', () => { + Widget.attach(completer, document.body); + model.setCompletions({ + items: suggestionsAbc + }); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion c'); + model.setCompletions({ + items: suggestionsAbc.slice().reverse() + }); + expect(completer.current?.insertText).toBe('suggestion c'); + }); + + it('switches to a first item if the previous current has disappeared', () => { + Widget.attach(completer, document.body); + model.setCompletions({ + items: suggestionsAbc + }); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion c'); + model.setCompletions({ + items: [ + { + ...itemDefaults, + insertText: 'suggestion b' + } + ] + }); + expect(completer.current?.insertText).toBe('suggestion b'); + }); + + it('preserves active completion when prefix is typed', () => { + Widget.attach(completer, document.body); + model.setCompletions({ + items: suggestionsAbc + }); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion c'); + model.handleTextChange({ sourceChange: [{ insert: 'sugg' }] }); + expect(completer.current?.insertText).toBe('estion c'); + }); + + it('discards when a non-prefix is typed', () => { + Widget.attach(completer, document.body); + model.setCompletions({ + items: suggestionsAbc + }); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion c'); + model.handleTextChange({ sourceChange: [{ insert: 'not a prefix' }] }); + expect(completer.current).toBe(null); + }); + }); + + describe('#configure()', () => { + it('should update widget class to reflect display preference', () => { + expect(completer.node.dataset.display).toBe('onHover'); + completer.configure({ + ...InlineCompleter.defaultSettings, + showWidget: 'always' + }); + expect(completer.node.dataset.display).toBe('always'); + }); + + it('should update widget class to reflect shortcut display preference', () => { + expect(completer.node.dataset.showShortcuts).toBe('true'); + completer.configure({ + ...InlineCompleter.defaultSettings, + showShortcuts: false + }); + expect(completer.node.dataset.showShortcuts).toBe('false'); + }); + }); + + describe('#cycle()', () => { + it('should cycle forward with "next"', () => { + model.setCompletions({ + items: suggestionsAbc + }); + expect(completer.current?.insertText).toBe('suggestion a'); + completer.cycle('next'); + expect(completer.current?.insertText).toBe('suggestion b'); + completer.cycle('next'); + expect(completer.current?.insertText).toBe('suggestion c'); + completer.cycle('next'); + expect(completer.current?.insertText).toBe('suggestion a'); + }); + + it('should cycle backward with "previous"', () => { + model.setCompletions({ + items: suggestionsAbc + }); + expect(completer.current?.insertText).toBe('suggestion a'); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion c'); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion b'); + completer.cycle('previous'); + expect(completer.current?.insertText).toBe('suggestion a'); + }); + }); + + describe('#handleEvent()', () => { + it('hides completer on pointer down', () => { + Widget.attach(editorWidget, document.body); + Widget.attach(completer, document.body); + model.setCompletions({ items: suggestionsAbc }); + MessageLoop.sendMessage(completer, Widget.Msg.UpdateRequest); + expect(completer.isHidden).toBe(false); + simulate(editorWidget.node, 'pointerdown'); + MessageLoop.sendMessage(completer, Widget.Msg.UpdateRequest); + expect(completer.isHidden).toBe(true); + }); + + it('does not hide when pointer clicks on the widget', () => { + Widget.attach(editorWidget, document.body); + Widget.attach(completer, document.body); + model.setCompletions({ items: suggestionsAbc }); + MessageLoop.sendMessage(completer, Widget.Msg.UpdateRequest); + expect(completer.isHidden).toBe(false); + simulate(completer.node, 'pointerdown'); + MessageLoop.sendMessage(completer, Widget.Msg.UpdateRequest); + expect(completer.isHidden).toBe(false); + }); + }); + }); + + describe('InlineCompleter.Model', () => { + let editorWidget: CodeEditorWrapper; + let model: InlineCompleter.Model; + let options: CompletionHandler.IInlineItem[]; + let ytext: Text; + + beforeEach(() => { + editorWidget = createEditorWidget(); + model = new InlineCompleter.Model(); + model.cursor = { + line: 0, + column: 0 + }; + model.cursor = editorWidget.editor.getPositionAt(0)!; + options = [ + { + ...itemDefaults, + insertText: 'suggestion a' + }, + { + ...itemDefaults, + insertText: 'text b' + }, + { + ...itemDefaults, + insertText: 'option c' + } + ]; + const ydoc = new Doc(); + ytext = ydoc.getText(); + }); + + describe('#setCompletions()', () => { + it('should emit `suggestionsChanged` signal', () => { + const callback = jest.fn(); + model.suggestionsChanged.connect(callback); + expect(callback).toHaveBeenCalledTimes(0); + model.setCompletions({ items: options }); + expect(callback).toHaveBeenCalledTimes(1); + model.suggestionsChanged.disconnect(callback); + }); + it('should set completions', () => { + model.setCompletions({ items: options }); + expect(model.completions?.items).toHaveLength(3); + }); + it('should override existing completions', () => { + model.setCompletions({ items: options }); + model.setCompletions({ items: options }); + expect(model.completions?.items).toHaveLength(3); + }); + }); + + describe('#appendCompletions()', () => { + it('should emit `suggestionsChanged` signal', () => { + const callback = jest.fn(); + model.setCompletions({ items: options }); + model.suggestionsChanged.connect(callback); + expect(callback).toHaveBeenCalledTimes(0); + model.appendCompletions({ items: options }); + expect(callback).toHaveBeenCalledTimes(1); + model.suggestionsChanged.disconnect(callback); + }); + it('should append completions', () => { + model.setCompletions({ items: options }); + model.appendCompletions({ items: options }); + expect(model.completions?.items).toHaveLength(6); + }); + }); + + describe('#handleTextChange()', () => { + it('should emit `filterTextChanged` signal', () => { + const callback = jest.fn(); + model.filterTextChanged.connect(callback); + model.setCompletions({ items: options }); + expect(callback).toHaveBeenCalledTimes(0); + ytext.insert(0, 'test'); + model.handleTextChange({ sourceChange: ytext.toDelta() }); + expect(callback).toHaveBeenCalledTimes(1); + model.filterTextChanged.disconnect(callback); + }); + it('should filter by prefix', () => { + model.setCompletions({ items: options }); + ytext.insert(0, 'text'); + model.handleTextChange({ sourceChange: ytext.toDelta() }); + expect(model.completions?.items).toHaveLength(1); + expect(model.completions?.items[0].insertText).toBe(' b'); + }); + it('should nullify completions on prefix mismatch', () => { + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + ytext.insert(0, 'insertion'); + model.handleTextChange({ sourceChange: ytext.toDelta() }); + expect(model.completions).toBeNull(); + }); + it('should nullify completions on backspace/deletion', () => { + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + model.handleTextChange({ sourceChange: [{ delete: 1 }] }); + expect(model.completions).toBeNull(); + }); + }); + + describe('#handleSelectionChange()', () => { + it('should reset on cursor moving to a different line', () => { + model.cursor = { line: 0, column: 0 }; + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + const secondLine = { line: 1, column: 0 }; + model.handleSelectionChange({ start: secondLine, end: secondLine }); + expect(model.completions).toBeNull(); + }); + + it('should reset on cursor moving backwards in the same line', () => { + model.cursor = { line: 0, column: 5 }; + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + const oneBack = { line: 0, column: 4 }; + model.handleSelectionChange({ start: oneBack, end: oneBack }); + expect(model.completions).toBeNull(); + }); + + it('should reset on character selection', () => { + model.cursor = { line: 0, column: 5 }; + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + model.handleSelectionChange({ + start: { line: 0, column: 5 }, + end: { line: 0, column: 6 } + }); + expect(model.completions).toBeNull(); + }); + + it('should reset on line selection', () => { + model.cursor = { line: 0, column: 5 }; + model.setCompletions({ items: options }); + expect(model.completions).toBeTruthy(); + model.handleSelectionChange({ + start: { line: 0, column: 5 }, + end: { line: 1, column: 5 } + }); + expect(model.completions).toBeNull(); + }); + }); + }); +}); diff --git a/packages/completer/tsconfig.json b/packages/completer/tsconfig.json index 8885881dd526..8ff0f64e947f 100644 --- a/packages/completer/tsconfig.json +++ b/packages/completer/tsconfig.json @@ -12,6 +12,9 @@ { "path": "../codeeditor" }, + { + "path": "../codemirror" + }, { "path": "../coreutils" }, @@ -21,9 +24,15 @@ { "path": "../services" }, + { + "path": "../settingregistry" + }, { "path": "../statedb" }, + { + "path": "../translation" + }, { "path": "../ui-components" } diff --git a/packages/completer/tsconfig.test.json b/packages/completer/tsconfig.test.json index 7cc229a729fa..4ee0c6088041 100644 --- a/packages/completer/tsconfig.test.json +++ b/packages/completer/tsconfig.test.json @@ -8,6 +8,9 @@ { "path": "../codeeditor" }, + { + "path": "../codemirror" + }, { "path": "../coreutils" }, @@ -17,9 +20,15 @@ { "path": "../services" }, + { + "path": "../settingregistry" + }, { "path": "../statedb" }, + { + "path": "../translation" + }, { "path": "../ui-components" }, diff --git a/packages/csvviewer/package.json b/packages/csvviewer/package.json index 211fd0981b1b..860e7e6558b3 100644 --- a/packages/csvviewer/package.json +++ b/packages/csvviewer/package.json @@ -56,7 +56,7 @@ "devDependencies": { "@jupyterlab/testing": "^4.1.0-alpha.2", "@types/jest": "^29.2.0", - "canvas": "^2.9.1", + "canvas": "^2.11.2", "csv-spectrum": "^1.0.0", "jest": "^29.2.0", "rimraf": "~3.0.0", diff --git a/packages/debugger/package.json b/packages/debugger/package.json index fcaed6d59dcc..5ddc9c7a4f0f 100644 --- a/packages/debugger/package.json +++ b/packages/debugger/package.json @@ -82,7 +82,7 @@ "devDependencies": { "@jupyterlab/testing": "^4.1.0-alpha.2", "@types/jest": "^29.2.0", - "canvas": "^2.9.1", + "canvas": "^2.11.2", "jest": "^29.2.0", "rimraf": "~3.0.0", "typedoc": "~0.24.7", diff --git a/packages/docregistry/src/mimedocument.ts b/packages/docregistry/src/mimedocument.ts index 652379935f8f..eaf62c56fd09 100644 --- a/packages/docregistry/src/mimedocument.ts +++ b/packages/docregistry/src/mimedocument.ts @@ -2,6 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { Printing, showErrorMessage } from '@jupyterlab/apputils'; +import { IEditorMimeTypeService } from '@jupyterlab/codeeditor'; import { ActivityMonitor } from '@jupyterlab/coreutils'; import { IRenderMime, @@ -283,7 +284,9 @@ export class MimeDocumentFactory extends ABCWidgetFactory { */ protected createNewWidget(context: DocumentRegistry.Context): MimeDocument { const ft = this._fileType; - const mimeType = ft?.mimeTypes.length ? ft.mimeTypes[0] : 'text/plain'; + const mimeType = ft?.mimeTypes.length + ? ft.mimeTypes[0] + : IEditorMimeTypeService.defaultMimeType; const rendermime = this._rendermime.clone({ resolver: context.urlResolver diff --git a/packages/documentsearch/src/searchview.tsx b/packages/documentsearch/src/searchview.tsx index 60ed2571c02d..5aa090fb2050 100644 --- a/packages/documentsearch/src/searchview.tsx +++ b/packages/documentsearch/src/searchview.tsx @@ -32,6 +32,7 @@ import { IFilter, IFilters, IReplaceOptionsSupport } from './tokens'; const OVERLAY_CLASS = 'jp-DocumentSearch-overlay'; const OVERLAY_ROW_CLASS = 'jp-DocumentSearch-overlay-row'; const INPUT_CLASS = 'jp-DocumentSearch-input'; +const INPUT_LABEL_CLASS = 'jp-DocumentSearch-input-label'; const INPUT_WRAPPER_CLASS = 'jp-DocumentSearch-input-wrapper'; const INPUT_BUTTON_CLASS_OFF = 'jp-DocumentSearch-input-button-off'; const INPUT_BUTTON_CLASS_ON = 'jp-DocumentSearch-input-button-on'; @@ -77,13 +78,19 @@ export interface ISearchKeyBindings { function SearchInput(props: ISearchInputProps): JSX.Element { const [rows, setRows] = useState(1); - const updateRows = useCallback( + const updateDimensions = useCallback( (event?: React.SyntheticEvent) => { const element = event ? (event.target as HTMLTextAreaElement) : props.inputRef?.current; if (element) { - setRows(element.value.split(/\n/).length); + const split = element.value.split(/\n/); + // use the longest string out of all lines to compute the width. + let longest = split.reduce((a, b) => (a.length > b.length ? a : b), ''); + if (element.parentNode && element.parentNode instanceof HTMLElement) { + element.parentNode.dataset.value = longest; + } + setRows(split.length); } }, [] @@ -98,31 +105,33 @@ function SearchInput(props: ISearchInputProps): JSX.Element { props.inputRef?.current?.select(); // After any change to initial value we also want to update rows in case if // multi-line text was selected. - updateRows(); + updateDimensions(); }, [props.initialValue]); return ( - + ); } diff --git a/packages/documentsearch/style/base.css b/packages/documentsearch/style/base.css index e84a72766d84..1406a37f8e93 100644 --- a/packages/documentsearch/style/base.css +++ b/packages/documentsearch/style/base.css @@ -11,6 +11,7 @@ font-family: var(--jp-ui-font-family); padding: 2px 1px; resize: none; + white-space: pre; } .jp-DocumentSearch-overlay { @@ -81,11 +82,13 @@ } .jp-DocumentSearch-toggle-wrapper { + flex-shrink: 0; width: 14px; height: 14px; } .jp-DocumentSearch-button-wrapper { + flex-shrink: 0; width: var(--jp-private-document-search-button-height); height: var(--jp-private-document-search-button-height); } @@ -209,6 +212,7 @@ margin: auto 2px; padding: 1px 4px; height: calc(var(--jp-private-document-search-button-height) + 2px); + flex-shrink: 0; } .jp-DocumentSearch-replace-button-wrapper:focus { @@ -246,3 +250,37 @@ .jp-DocumentSearch-replace-toggle:hover { background-color: var(--jp-layout-color2); } + +/* + The following few rules allow the search box to expand horizontally, + as the text within it grows. This is done by using putting + the text within a wrapper element and using that wrapper for sizing, + as