From aa117ca13d612ed6184848769e5cdd0ef8a909bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krassowski?= <5832902+krassowski@users.noreply.github.com> Date: Tue, 31 Oct 2023 17:55:46 +0000 Subject: [PATCH] Experimental inline completer (#15160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Early WIP for inline completion provider API * Improve ghost text behaviour, widget, and tokens * Improve inline completer hoverbox widget * Implement accept and invoke * Fix shortcuts assignment * Split up ghost to separate file, improve ghost and widget * Improve appending and filter text logic, move protected method * Add `readonly` keyword to identifier/name Co-authored-by: Frédéric Collonval * Remove `Omit<>`, improve documentation * Expose provider ID on the ghost text widget This is based on the observation that various providers have different ways of styling the ghost text, for example by adding brand-specific background; to enable providers to style the ghost text specifically this commit adds a data attribute on the ghost text widget. * Avoid using `Omit<>` for factory, add readonly on `completions` * Check for items (fix for no kernel) * Add provider icon * Implement settings, rearrange plugins * Make `inlineProviders` optional in options for backward compatibility * Use search history request with limit of 100 cells * Add icons to settings * Polish completer widget icon * Implement settings for inline completion providers * Initial streaming implementation * Implement streaming indicator, typing and streaming animations * Implement setting for streaming animation * Expose mime type in request * Fix hiding stream on cell change & not showing if 1st provider yields empty * Wrap lines (while preserving indentation), useful in markdown * Revert pre-wrap as it introduces jitter on streaming * Better blur handling * Hide inline completions on cursor movement * Implement on hover display of widget * Show new line immediately when streaming * Implement per-provider timeout and debouncer * Restore `pre-wrap`, but exclude the streaming token itself * Integrity fix * Fix invoke command * Add unit tests for inline compelter * Implement progress bar for multiple providers * Automatic application of license header * Remove `const` as `enum const` breaks building tests separately Though ideally it would be `enum cosnt` * Fix initial defaults composition for providers * `onHover`: allow clicking past widget when hidden * Add visual/integration tests * Use `oneOf` instead of `enum` to enable translations * Update Playwright Snapshots * Add missing `await` * Add user-facing and developer-facing docs * Remove LSP provider declaration for now Co-authored-by: Frédéric Collonval * Fix icon styling in dark theme --------- Co-authored-by: Frédéric Collonval Co-authored-by: github-actions[bot] --- docs/source/extension/extension_points.rst | 97 +++ docs/source/user/completer.rst | 51 ++ docs/source/user/index.md | 1 + .../test/jupyterlab/inline-completer.test.ts | 152 ++++ ...ditor-with-ghost-text-jupyterlab-linux.png | Bin 0 -> 1736 bytes ...mpleter-shortcuts-off-jupyterlab-linux.png | Bin 0 -> 455 bytes ...ompleter-shortcuts-on-jupyterlab-linux.png | Bin 0 -> 1513 bytes packages/completer-extension/package.json | 2 + .../schema/inline-completer.json | 64 ++ .../completer-extension/schema/manager.json | 3 +- packages/completer-extension/src/index.ts | 262 +++++- packages/completer-extension/tsconfig.json | 3 + packages/completer/package.json | 6 + .../src/default/inlinehistoryprovider.ts | 120 +++ packages/completer/src/ghost.ts | 435 ++++++++++ packages/completer/src/handler.ts | 192 ++++- packages/completer/src/icons.ts | 17 + packages/completer/src/index.ts | 3 + packages/completer/src/inline.ts | 760 ++++++++++++++++++ packages/completer/src/manager.ts | 132 ++- packages/completer/src/reconciliator.ts | 128 ++- packages/completer/src/svg.d.ts | 9 + packages/completer/src/testutils.ts | 46 ++ packages/completer/src/tokens.ts | 280 ++++++- packages/completer/style/base.css | 127 +++ packages/completer/style/icons/inline.svg | 4 + packages/completer/style/icons/widget.svg | 9 + packages/completer/style/index.css | 1 + packages/completer/style/index.js | 1 + packages/completer/test/inline.spec.ts | 353 ++++++++ packages/completer/tsconfig.json | 9 + packages/completer/tsconfig.test.json | 9 + .../test/completer/handler.spec.ts | 19 +- .../metapackage/test/completer/widget.spec.ts | 56 +- .../ui-components/src/icon/iconimports.ts | 2 + packages/ui-components/style/deprecated.css | 5 + .../ui-components/style/icons/history.svg | 3 + yarn.lock | 7 + 38 files changed, 3275 insertions(+), 93 deletions(-) create mode 100644 docs/source/user/completer.rst create mode 100644 galata/test/jupyterlab/inline-completer.test.ts create mode 100644 galata/test/jupyterlab/inline-completer.test.ts-snapshots/editor-with-ghost-text-jupyterlab-linux.png create mode 100644 galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-off-jupyterlab-linux.png create mode 100644 galata/test/jupyterlab/inline-completer.test.ts-snapshots/inline-completer-shortcuts-on-jupyterlab-linux.png create mode 100644 packages/completer-extension/schema/inline-completer.json create mode 100644 packages/completer/src/default/inlinehistoryprovider.ts create mode 100644 packages/completer/src/ghost.ts create mode 100644 packages/completer/src/icons.ts create mode 100644 packages/completer/src/inline.ts create mode 100644 packages/completer/src/svg.d.ts create mode 100644 packages/completer/src/testutils.ts create mode 100644 packages/completer/style/icons/inline.svg create mode 100644 packages/completer/style/icons/widget.svg create mode 100644 packages/completer/test/inline.spec.ts create mode 100644 packages/ui-components/style/icons/history.svg 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/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/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 0000000000000000000000000000000000000000..584b5b75a61d9f8b2f10480d2f25d1680c5a6f95 GIT binary patch literal 1736 zcmV;(1~>VMP)Px$^-xSyMF0Q*8FtbkAt53nA|xavCMG5uIyyQ$JUl-?KR`f0L_|bIMMXwNMn^|SNJvOYNl8mfOHEBpPft%#QBhP> zR8>_~SXfwGTwGvaU}0flV`F1xW@cz;XlZF_YHDh1Y;10BZf|dIaBy&OadC2Ta&vQY zbaZreb#-=jc6WDodwY9)e0+X>et&;|fPjF3fq{gCgoTBLhK7cRhlhxWh>MGhjg5_t zj*gI!kdcv*l9G~@m6ey5mzCf> zsHv%`s;a82tgNlAt*)-FudlDLu&}YQv9hwVv$M0bw6wOiwzs#pxVX5u-TS$@xw^W# zyu7@>zrVo1z`?=6!otGC!^6bH#KpzM#>U3S$H&RZ$;->j%*@Qr&d$%z&(P4&(CPfq z(b3Y<($v({)z#J3*4EbS{nyvm*x1lt)=I7_<=;-L_>FMg~>g((4?Ck9A?d|UF?(gsK@bK{P`2O+n@$&NW z^Yioc^z`-h_4fAm_xJbr`~Ud(`1$$y`uh6&`}_R-{Qdp?{{H^||Nn|z7Qg@i1c6CJ zK~#9!?cMudQ*|82aU_dOy4b}oWY-h9$RT8umM%+bb8{Cq*v24DGcJJ{gHSo75v~|B zkuh;C7;NW!{?5MNGX}$*^PO9#2VLH;AGn?Eu*c5x@%?_!84p&s3n3G$+jV|o3L#UP zIL|;$++9V;l^?Rny&0w#)h(=#{ zxaI;wmI+@ zr}*jW1E`9iWa)x7>0OZYJ;iLcm_U>O)Y{>L&CLg`YqfwXDmMW2$r~&AvE(MG25LHxR3}3!#eEsNw^TYpX=|vYE3V4kHHtfCDDKV!l*yoEqIEVVNcx`m z0T+l8O?jzgYily4JLOrnmQa=&K%IQ~n!-L6LG|xr<6&c%It~#{Gf+hSO90%CXUJ6t53HGoUC~qJb40g>CUHkIP*;E;*(INIB zBC{!veRd>`KxH#p7@?{LwcOi2t_#W*D^5u~?C-q^r$Z9GXp{ztd@)2rg^Io}cF7op?zwQVn1~ur>!U$D0sFC+tKD?thbaVLia2{OGZ29w`1E3ROr^01E9`0EGYw0Tco#ga8Tw6apxO z015#V0w@Gf0EGYw0Tco#ga8Tw6apxO015#V0x0x92Zeupk4*i?_jqd#Wa?XUAY|s7 e2VvqzX7(qirUR-XNln-Q0000200001b5ch_0Itp) z=>Px$fk{L`R9M4f*gF^j!`}ac4~)8M)K#Od8u70Bx~wCyZqmcS<*Kt;CF>8b??~K0 zk*gl=#3i%yAyGkt%6*+R2Q0EPaVK_HZ3xj;fw0P^zbv!WaGHb7QCaJ8*|$E62xrad zK;?JTfe5hf*H~S3VDln~tNxnYz2150%?_>ASR9pz+f|8#oizttN#a=$0cIV>5rcan zt~%uk;ii{iaTGpR5p))Yqpl}@g}JI@DSlTqcY&!NmJ6{s3a_gOI1AlTU+aKcfI*bC z;VWTR*}2!m)e2OnuA^TE2N;pSu*_ju|ws@kN^L@kNp4tIU?rc zj~~CNS28@z!XbM-t7OBG4IL<+MRwH+MG$!V!~p1+yPx$;809dMHXwnAt50nBO@dvBqb#!CMG5)CnqQi%gfEp&Cbrw(9qD* z($dq@)6~?|)z#J3*4Ee8*Vx$D+S=OP-QC{a-rwKf;Nall;o;%s>Eq+$lt) z=I7_<=;-L_>FMg~>g((4?Ck9A?d|UF?(gsK@bK{Q@$vHV^7Hfa_4W1k_V)Mp_xSku z`T6K`9PPA}7@J+u(7k|-g&0;OC#R5yjFtXm3pk|^i=ikFV* zq4>N=1P(t_PW_VKTg#_`48EWd^cDWPoV=Ts_DnmhGT zNJQnO#WbKm`BiEF#h2NKv6&+X6Rl08s5(U#ym)#62(!93WI6j-rfcp%i@NM`m5srY_37ISAS;8xqQ_ z)oRVmf>pvGN<%5wvbD-k?pUok1ni(y8)d+!FiAy_P|jGbN77*rA0SE&yf#9VNdS6F zzzhMGjY0WHfhydjiFee;km)n8ffC8%7Qd~!>Q$gSeVT25!0$}Igp#0X`uamy8Js@x zqip~@IJqOCEYtLa33jlmjS{7Nh=~|MLSbn7c{S`|6QX>9*A<9D^X%^r!yo@ZrcZn{ zxe1{Qtrp7T23T!ea;$4^J|fDtJ;P?RnJajdKEu;T_LjPc9GlHTgn5)QBhv?}$W6ou z97^A=(?>v=A=bFYC3I&O4k+lBEJsPOy(X3g;PfB>YZn|VEW4$*CHEZ~XBZ|-OmQd^ zSJdmd6(|v^J}?;Y$`(om zN*BMeFa10QEkgYLMZLpYt&_i0c=*Gc+iDpyfFeJi~|=J-Nk0`|H77X#ZCq2sKdN zjQ{VSlIQWi4loKv!6*lal7>;#DDNDvN&szoZzW52n_t P00000NkvXXu0mjfhuh`G literal 0 HcmV?d00001 diff --git a/packages/completer-extension/package.json b/packages/completer-extension/package.json index 93693a5554c6..aed212a7f508 100644 --- a/packages/completer-extension/package.json +++ b/packages/completer-extension/package.json @@ -41,7 +41,9 @@ "@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.13.2", "react": "^18.2.0" 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/style/base.css b/packages/completer/style/base.css index e748a9b864ad..6b131d92a876 100644 --- a/packages/completer/style/base.css +++ b/packages/completer/style/base.css @@ -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/metapackage/test/completer/handler.spec.ts b/packages/metapackage/test/completer/handler.spec.ts index 778615d236fa..191f94a3bede 100644 --- a/packages/metapackage/test/completer/handler.spec.ts +++ b/packages/metapackage/test/completer/handler.spec.ts @@ -2,8 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { ISessionContext, SessionContext } from '@jupyterlab/apputils'; -import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor'; -import { CodeMirrorEditor, ybinding } from '@jupyterlab/codemirror'; +import { CodeEditorWrapper } from '@jupyterlab/codeeditor'; import { Completer, CompleterModel, @@ -13,23 +12,11 @@ import { ICompletionProvider, ProviderReconciliator } from '@jupyterlab/completer'; +import { createEditorWidget } from '@jupyterlab/completer/lib/testutils'; import { Widget } from '@lumino/widgets'; -import { ISharedText, SourceChange, YFile } from '@jupyter/ydoc'; +import { ISharedText, SourceChange } from '@jupyter/ydoc'; import { createSessionContext } from '@jupyterlab/apputils/lib/testutils'; -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 }); -} - class TestCompleterModel extends CompleterModel { methods: string[] = []; diff --git a/packages/metapackage/test/completer/widget.spec.ts b/packages/metapackage/test/completer/widget.spec.ts index 66a021849ef6..1726f73a1230 100644 --- a/packages/metapackage/test/completer/widget.spec.ts +++ b/packages/metapackage/test/completer/widget.spec.ts @@ -2,13 +2,15 @@ // Distributed under the terms of the Modified BSD License. import { CodeEditor, CodeEditorWrapper } from '@jupyterlab/codeeditor'; -import { CodeMirrorEditor, ybinding } from '@jupyterlab/codemirror'; import { Completer, CompleterModel, CompletionHandler } from '@jupyterlab/completer'; -import { YFile } from '@jupyter/ydoc'; +import { + createEditorWidget, + getBoundingClientRectMock +} from '@jupyterlab/completer/lib/testutils'; import { framePromise, simulate, sleep } from '@jupyterlab/testing'; import { Message, MessageLoop } from '@lumino/messaging'; import { Panel, Widget } from '@lumino/widgets'; @@ -23,44 +25,6 @@ const DOC_PANEL_CLASS = 'jp-Completer-docpanel'; const ACTIVE_CLASS = 'jp-mod-active'; -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. - */ -function betterGetBoundingClientRectMock() { - 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 - }; -} - class CustomRenderer extends Completer.Renderer { createCompletionItemNode( item: CompletionHandler.ICompletionItem, @@ -1083,7 +1047,7 @@ describe('completer/widget', () => { it('should pre-compute and cache dimensions when items are many', () => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; let anchor = createEditorWidget(); let model = new CompleterModel(); @@ -1112,7 +1076,7 @@ describe('completer/widget', () => { it('should compute height based on number of items', () => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; let anchor = createEditorWidget(); let model = new CompleterModel(); @@ -1141,7 +1105,7 @@ describe('completer/widget', () => { it('should account for documentation panel width if shown', async () => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; let anchor = createEditorWidget(); let model = new CompleterModel(); @@ -1179,7 +1143,7 @@ describe('completer/widget', () => { it('should show/hide the documentation panel depending on documentation presence', async () => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; let anchor = createEditorWidget(); let model = new CompleterModel(); Widget.attach(anchor, document.body); @@ -1231,7 +1195,7 @@ describe('completer/widget', () => { it('should render completions lazily in chunks', async () => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; let anchor = createEditorWidget(); let model = new CompleterModel(); @@ -1280,7 +1244,7 @@ describe('completer/widget', () => { beforeEach(() => { window.HTMLElement.prototype.getBoundingClientRect = - betterGetBoundingClientRectMock; + getBoundingClientRectMock; wrapper = createEditorWidget(); model = new CompleterModel(); const editor = wrapper.editor; diff --git a/packages/ui-components/src/icon/iconimports.ts b/packages/ui-components/src/icon/iconimports.ts index 4c23a15aed4d..257ccf4ea5fc 100644 --- a/packages/ui-components/src/icon/iconimports.ts +++ b/packages/ui-components/src/icon/iconimports.ts @@ -53,6 +53,7 @@ import filterListSvgstr from '../../style/icons/toolbar/filter-list.svg'; import filterSvgstr from '../../style/icons/search/filter.svg'; import folderFavoriteSvgstr from '../../style/icons/filetype/folder-favorite.svg'; import folderSvgstr from '../../style/icons/filetype/folder.svg'; +import historySvgstr from '../../style/icons/history.svg'; import homeSvgstr from '../../style/icons/filetype/home.svg'; import html5Svgstr from '../../style/icons/filetype/html5.svg'; import imageSvgstr from '../../style/icons/filetype/image.svg'; @@ -158,6 +159,7 @@ export const filterIcon = new LabIcon({ name: 'ui-components:filter', svgstr: fi export const filterListIcon = new LabIcon({ name: 'ui-components:filter-list', svgstr: filterListSvgstr }); export const folderFavoriteIcon = new LabIcon({ name: 'ui-components:folder-favorite', svgstr: folderFavoriteSvgstr }); export const folderIcon = new LabIcon({ name: 'ui-components:folder', svgstr: folderSvgstr }); +export const historyIcon = new LabIcon({ name: 'ui-components:history', svgstr: historySvgstr }); export const homeIcon = new LabIcon({ name: 'ui-components:home', svgstr: homeSvgstr }); export const html5Icon = new LabIcon({ name: 'ui-components:html5', svgstr: html5Svgstr }); export const imageIcon = new LabIcon({ name: 'ui-components:image', svgstr: imageSvgstr }); diff --git a/packages/ui-components/style/deprecated.css b/packages/ui-components/style/deprecated.css index ee16d71d59cd..d518949f4b3c 100644 --- a/packages/ui-components/style/deprecated.css +++ b/packages/ui-components/style/deprecated.css @@ -57,6 +57,7 @@ --jp-icon-filter: url('icons/search/filter.svg'); --jp-icon-folder-favorite: url('icons/filetype/folder-favorite.svg'); --jp-icon-folder: url('icons/filetype/folder.svg'); + --jp-icon-history: url('icons/history.svg'); --jp-icon-home: url('icons/filetype/home.svg'); --jp-icon-html5: url('icons/filetype/html5.svg'); --jp-icon-image: url('icons/filetype/image.svg'); @@ -299,6 +300,10 @@ background-image: var(--jp-icon-folder); } +.jp-HistoryIcon { + background-image: var(--jp-icon-history); +} + .jp-HomeIcon { background-image: var(--jp-icon-home); } diff --git a/packages/ui-components/style/icons/history.svg b/packages/ui-components/style/icons/history.svg new file mode 100644 index 000000000000..6b56253dfd45 --- /dev/null +++ b/packages/ui-components/style/icons/history.svg @@ -0,0 +1,3 @@ + + + diff --git a/yarn.lock b/yarn.lock index d0ac098d61e2..c0611c20c8d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2579,7 +2579,9 @@ __metadata: "@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.13.2 react: ^18.2.0 @@ -2593,14 +2595,19 @@ __metadata: version: 0.0.0-use.local resolution: "@jupyterlab/completer@workspace:packages/completer" 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/testing": ^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