diff --git a/README.md b/README.md index 84ffd13..97097f5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This extension provides a new command, `Load Current File As Extension`, availab It also adds a single right sidebar panel with two collapsible sections: -- **Service Tokens**: token string IDs you can use in plugin `requires` and `optional` arrays, with search, copy, and import actions. +- **Extension Points**: token string IDs and command IDs, with a `Tokens` / `Commands` switch. Tokens support search, copy, and import actions. Commands support search and copy actions. - **Extension Examples**: discovered examples from a local checkout of [`jupyterlab/extension-examples`](https://github.com/jupyterlab/extension-examples), so you can open them directly from the panel. If examples are missing: @@ -30,6 +30,8 @@ If examples are missing: When reloading a plugin with the same `id`, the playground attempts to deactivate the previously loaded plugin first. Defining `deactivate()` in examples is recommended for clean reruns. +When typing inside `commands.execute(` or `app.commands.execute(` in JavaScript and TypeScript editors, the completer will also suggest available command IDs. + As an example, open the text editor by creating a new text file and paste this small JupyterLab plugin into it. This plugin will create a simple command `My Super Cool Toggle` in the command palette that can be toggled on and off. ```typescript diff --git a/package.json b/package.json index a2f5f3b..ba8ef28 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@jupyter-widgets/base": "^6.0.0", "@jupyterlab/application": "^4.5.5", "@jupyterlab/apputils": "^4.6.5", + "@jupyterlab/completer": "^4.5.5", "@jupyterlab/fileeditor": "^4.5.5", "@jupyterlab/settingregistry": "^4.5.5", "raw-loader": "^4.0.2", @@ -72,7 +73,6 @@ "@jupyter/eslint-plugin": "^0.0.1", "@jupyterlab/builder": "^4.5.5", "@jupyterlab/cell-toolbar": "^4.5.5", - "@jupyterlab/completer": "^4.5.5", "@jupyterlab/console": "^4.5.5", "@jupyterlab/csvviewer": "^4.5.5", "@jupyterlab/debugger": "^4.5.5", diff --git a/src/command-completion.ts b/src/command-completion.ts new file mode 100644 index 0000000..6f721aa --- /dev/null +++ b/src/command-completion.ts @@ -0,0 +1,203 @@ +import { JupyterFrontEnd } from '@jupyterlab/application'; + +import { + CompletionHandler, + ICompletionContext, + ICompletionProvider +} from '@jupyterlab/completer'; + +export interface ICommandRecord { + id: string; + label: string; + caption: string; +} + +interface ICommandQuery { + searchText: string; + replaceStart: number; + replaceEnd: number; + insertText: (commandId: string) => string; +} + +export const COMMAND_COMPLETION_PROVIDER_ID = + 'CompletionProvider:plugin-playground-commands'; +export const COMMAND_COMPLETION_PROVIDER_RANK = 1200; + +const SUPPORTED_MIME_PATTERN = /(typescript|javascript|jsx|tsx)/i; +const INTERNAL_COMMAND_PREFIX = '__internal:'; + +export class CommandCompletionProvider implements ICompletionProvider { + constructor(app: JupyterFrontEnd) { + this._app = app; + } + + readonly identifier = COMMAND_COMPLETION_PROVIDER_ID; + readonly rank = COMMAND_COMPLETION_PROVIDER_RANK; + readonly renderer = null; + + async isApplicable(context: ICompletionContext): Promise { + return ( + !!context.editor && + SUPPORTED_MIME_PATTERN.test(context.editor.model.mimeType) + ); + } + + async fetch( + request: CompletionHandler.IRequest, + _context: ICompletionContext + ): Promise { + const query = Private.extractCommandQuery(request.text, request.offset); + + if (!query) { + return { + start: request.offset, + end: request.offset, + items: [] + }; + } + + const searchText = query.searchText.toLowerCase(); + const items = getCommandRecords(this._app) + .filter(record => Private.matchesSearch(record, searchText)) + .map(record => { + const insertText = query.insertText(record.id); + const details = formatCommandDescription(record); + + return { + label: insertText, + insertText, + type: 'command', + documentation: details || undefined + }; + }); + + return { + start: query.replaceStart, + end: query.replaceEnd, + items + }; + } + + private readonly _app: JupyterFrontEnd; +} + +export function getCommandRecords( + app: Pick +): Array { + return app.commands + .listCommands() + .filter(id => !Private.isHiddenCommand(id)) + .map(id => ({ + id, + label: Private.safeCommandText(() => app.commands.label(id)), + caption: Private.safeCommandText(() => app.commands.caption(id)) + })) + .sort((left, right) => left.id.localeCompare(right.id)); +} + +export function formatCommandDescription(record: ICommandRecord): string { + return [record.label, record.caption] + .filter(Boolean) + .filter((value, index, values) => values.indexOf(value) === index) + .join(' | '); +} + +namespace Private { + const IDENTIFIER_PATTERN = '[A-Za-z_$][A-Za-z0-9_$]*'; + const COMMAND_METHODS = [ + 'execute', + 'isEnabled', + 'isVisible', + 'isToggled', + 'hasCommand', + 'label', + 'caption', + 'usage', + 'className', + 'dataset', + 'describedBy', + 'iconClass', + 'iconLabel', + 'mnemonic', + 'notifyCommandChanged' + ].join('|'); + const COMMAND_TARGET = `(?:${IDENTIFIER_PATTERN}\\.)*commands`; + + const QUOTED_EXECUTE_PATTERN = new RegExp( + `(?:^|[^\\w$.])${COMMAND_TARGET}\\.(?:${COMMAND_METHODS})\\(\\s*(['"])([^'"\\\\]*)$`, + 's' + ); + + const BARE_EXECUTE_PATTERN = new RegExp( + `(?:^|[^\\w$.])${COMMAND_TARGET}\\.(?:${COMMAND_METHODS})\\(\\s*([A-Za-z0-9:._-]*)$`, + 's' + ); + + export function extractCommandQuery( + source: string, + offset: number + ): ICommandQuery | null { + const beforeCursor = source.slice(0, offset); + const afterCursor = source.slice(offset); + const quotedMatch = beforeCursor.match(QUOTED_EXECUTE_PATTERN); + + if (quotedMatch) { + const quote = quotedMatch[1]; + const prefix = quotedMatch[2]; + const suffix = quotedStringSuffix(afterCursor, quote); + + return { + searchText: `${prefix}${suffix}`, + replaceStart: offset - prefix.length, + replaceEnd: offset + suffix.length, + insertText: commandId => commandId + }; + } + + const bareMatch = beforeCursor.match(BARE_EXECUTE_PATTERN); + + if (!bareMatch) { + return null; + } + + const fragment = bareMatch[1]; + + return { + searchText: fragment, + replaceStart: offset - fragment.length, + replaceEnd: offset, + insertText: commandId => `'${commandId}'` + }; + } + + export function matchesSearch( + record: ICommandRecord, + searchText: string + ): boolean { + if (!searchText) { + return true; + } + + return [record.id, record.label, record.caption].some(value => + value.toLowerCase().includes(searchText) + ); + } + + export function safeCommandText(getValue: () => string): string { + try { + return getValue().trim(); + } catch { + return ''; + } + } + + export function isHiddenCommand(id: string): boolean { + return id.startsWith(INTERNAL_COMMAND_PREFIX); + } + + function quotedStringSuffix(afterCursor: string, quote: string): string { + const pattern = quote === "'" ? /^[^'\\\r\n]*/ : /^[^"\\\r\n]*/; + + return afterCursor.match(pattern)?.[0] ?? ''; + } +} diff --git a/src/index.ts b/src/index.ts index 9463396..13499a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { extensionIcon, SidePanel } from '@jupyterlab/ui-components'; import { IDocumentManager } from '@jupyterlab/docmanager'; import { Contents } from '@jupyterlab/services'; +import { ICompletionProviderManager } from '@jupyterlab/completer'; import { PluginLoader, PluginLoadingError } from './loader'; @@ -46,6 +47,11 @@ import { ExampleSidebar } from './example-sidebar'; import { tokenSidebarIcon } from './icons'; +import { + CommandCompletionProvider, + getCommandRecords +} from './command-completion'; + import { fileModelToText, getDirectoryModel, @@ -177,29 +183,23 @@ class PluginPlayground { app.restored.then(async () => { const settings = this.settings; this._updateSettings(requirejs, settings); - try { - this._populateTokenMap(); - } catch (error) { - console.warn( - 'Failed to discover token names for the playground sidebar', - error - ); - } - const tokenNames = Array.from(this._tokenMap.keys()).sort((a, b) => - a.localeCompare(b) - ); - const tokens = tokenNames.map(name => ({ - name, - description: this._tokenDescriptionMap.get(name) ?? '' - })); + this._refreshExtensionPoints(); const tokenSidebar = new TokenSidebar({ - tokens, + getTokens: () => + Array.from(this._tokenMap.keys()) + .sort((left, right) => left.localeCompare(right)) + .map(name => ({ + name, + description: this._tokenDescriptionMap.get(name) ?? '' + })), + getCommands: () => getCommandRecords(this.app), onInsertImport: this._insertTokenImport.bind(this), isImportEnabled: this._canInsertImport.bind(this) }); + this._tokenSidebar = tokenSidebar; tokenSidebar.id = 'jp-plugin-token-sidebar'; - tokenSidebar.title.label = 'Service Tokens'; - tokenSidebar.title.caption = 'Available service token strings for plugin'; + tokenSidebar.title.label = 'Extension Points'; + tokenSidebar.title.caption = 'Available extension points for plugin'; tokenSidebar.title.icon = tokenSidebarIcon; const exampleSidebar = new ExampleSidebar({ @@ -228,6 +228,11 @@ class PluginPlayground { editorTracker.currentChanged.connect(() => { tokenSidebar.update(); }); + app.commands.commandChanged.connect((_, args) => { + if (args.type === 'added' || args.type === 'removed') { + tokenSidebar.update(); + } + }); // add to the launcher if (launcher && (settings.composite.showIconInLauncher as boolean)) { launcher.add({ @@ -343,6 +348,7 @@ class PluginPlayground { await this._deactivateAndDeregisterPlugin(plugin.id); this.app.registerPlugin(plugin); } + this._refreshExtensionPoints(); for (const plugin of plugins) { if (!plugin.autoStart) { @@ -359,6 +365,7 @@ class PluginPlayground { } try { await this.app.activatePlugin(plugin.id); + this._refreshExtensionPoints(); } catch (e) { showDialog({ title: `Plugin autostart failed: ${(e as Error).message}`, @@ -369,6 +376,19 @@ class PluginPlayground { } } + private _refreshExtensionPoints(): void { + try { + this._populateTokenMap(); + } catch (error) { + console.warn( + 'Failed to discover token names for the playground sidebar', + error + ); + } + + this._tokenSidebar?.update(); + } + private _missingRequiredTokens( plugin: IPlugin ): string[] { @@ -777,7 +797,11 @@ class PluginPlayground { return `import { ${tokenSymbol} } from '${packageName}';`; } - private _canInsertImport(): boolean { + private _canInsertImport(tokenName: string): boolean { + if (!this._importStatement(tokenName)) { + return false; + } + const editorWidget = this.editorTracker.currentWidget; if (!editorWidget) { return false; @@ -791,6 +815,7 @@ class PluginPlayground { 'No description provided by this example.'; private readonly _tokenMap = new Map>(); private readonly _tokenDescriptionMap = new Map(); + private _tokenSidebar: TokenSidebar | null = null; } /** @@ -802,15 +827,20 @@ const plugin: JupyterFrontEndPlugin = { 'Provide a playground for developing and testing JupyterLab plugins.', autoStart: true, requires: [ISettingRegistry, ICommandPalette, IEditorTracker], - optional: [ILauncher, IDocumentManager], + optional: [ICompletionProviderManager, ILauncher, IDocumentManager], activate: ( app: JupyterFrontEnd, settingRegistry: ISettingRegistry, commandPalette: ICommandPalette, editorTracker: IEditorTracker, + completionManager: ICompletionProviderManager | null, launcher: ILauncher | null, documentManager: IDocumentManager | null ) => { + if (completionManager) { + completionManager.registerProvider(new CommandCompletionProvider(app)); + } + // In order to accommodate loading ipywidgets and other AMD modules, we // load RequireJS before loading any custom extensions. diff --git a/src/token-sidebar.tsx b/src/token-sidebar.tsx index bcbd818..f2bf243 100644 --- a/src/token-sidebar.tsx +++ b/src/token-sidebar.tsx @@ -9,6 +9,11 @@ import { addIcon, checkIcon, copyIcon } from '@jupyterlab/ui-components'; import * as React from 'react'; +import { + formatCommandDescription, + type ICommandRecord +} from './command-completion'; + export namespace TokenSidebar { export interface ITokenRecord { name: string; @@ -16,23 +21,30 @@ export namespace TokenSidebar { } export interface IOptions { - tokens: ReadonlyArray; + getTokens: () => ReadonlyArray; + getCommands: () => ReadonlyArray; onInsertImport: (tokenName: string) => Promise | void; - isImportEnabled: () => boolean; + isImportEnabled: (tokenName: string) => boolean; } } +type ExtensionPointView = 'tokens' | 'commands'; +const EXTENSION_POINT_PANEL_ID = 'jp-PluginPlayground-extensionPointPanel'; + export class TokenSidebar extends ReactWidget { - private readonly _tokens: ReadonlyArray; + private readonly _getTokens: () => ReadonlyArray; + private readonly _getCommands: () => ReadonlyArray; private readonly _onInsertImport: (tokenName: string) => Promise | void; - private readonly _isImportEnabled: () => boolean; + private readonly _isImportEnabled: (tokenName: string) => boolean; private _query = ''; - private _copiedTokenName: string | null = null; + private _activeView: ExtensionPointView = 'tokens'; + private _copiedValue: string | null = null; private _copiedTimer: number | null = null; constructor(options: TokenSidebar.IOptions) { super(); - this._tokens = options.tokens; + this._getTokens = options.getTokens; + this._getCommands = options.getCommands; this._onInsertImport = options.onInsertImport; this._isImportEnabled = options.isImportEnabled; this.addClass('jp-PluginPlayground-sidebar'); @@ -49,98 +61,199 @@ export class TokenSidebar extends ReactWidget { render(): JSX.Element { const query = this._query.trim().toLowerCase(); - const filteredTokens = - query.length > 0 - ? this._tokens.filter( - token => - token.name.toLowerCase().includes(query) || - token.description.toLowerCase().includes(query) - ) - : this._tokens; + const isTokenView = this._activeView === 'tokens'; + const activeTabId = `jp-PluginPlayground-extensionPointTab-${this._activeView}`; + let tokens: ReadonlyArray = []; + let commands: ReadonlyArray = []; + let filteredTokens: ReadonlyArray = []; + let filteredCommands: ReadonlyArray = []; + + if (isTokenView) { + tokens = this._getTokens(); + filteredTokens = + query.length > 0 + ? tokens.filter( + token => + token.name.toLowerCase().includes(query) || + token.description.toLowerCase().includes(query) + ) + : tokens; + } else { + commands = this._getCommands(); + filteredCommands = + query.length > 0 + ? commands.filter( + command => + command.id.toLowerCase().includes(query) || + command.label.toLowerCase().includes(query) || + command.caption.toLowerCase().includes(query) + ) + : commands; + } + + const itemCount = isTokenView + ? filteredTokens.length + : filteredCommands.length; + const totalCount = isTokenView ? tokens.length : commands.length; return (
- -

- {filteredTokens.length} of {this._tokens.length} token strings -

- {filteredTokens.length === 0 ? ( +
+ {this._renderViewButton('tokens', 'Tokens')} + {this._renderViewButton('commands', 'Commands')} +
+
+

- No matching token strings. + {itemCount} of {totalCount}{' '} + {isTokenView ? 'token strings' : 'commands'}

- ) : ( -
    - {filteredTokens.map(token => ( -
  • -
    - - {token.name} - -
    - - + + title={ + this._copiedValue === token.name + ? 'Copied' + : 'Copy token string' + } + > + {React.createElement( + this._copiedValue === token.name + ? checkIcon.react + : copyIcon.react, + { + tag: 'span', + elementSize: 'normal', + className: 'jp-PluginPlayground-actionIcon' + } + )} + +
    -
- {token.description ? ( -

- {token.description} -

- ) : null} - - ))} - - )} + {token.description ? ( +

+ {token.description} +

+ ) : null} + + ))} + + ) : ( +
    + {filteredCommands.map(command => { + const description = formatCommandDescription(command); + + return ( +
  • +
    + + {command.id} + +
    + +
    +
    + {description ? ( +

    + {description} +

    + ) : null} +
  • + ); + })} +
+ )} +
); } @@ -150,6 +263,41 @@ export class TokenSidebar extends ReactWidget { this.update(); }; + private _renderViewButton( + view: ExtensionPointView, + label: string + ): JSX.Element { + const isActive = this._activeView === view; + + return ( + + ); + } + + private _setActiveView(view: ExtensionPointView): void { + if (this._activeView === view) { + return; + } + + this._activeView = view; + this._query = ''; + this.update(); + } + private async _insertImport(tokenName: string): Promise { try { await this._onInsertImport(tokenName); @@ -164,32 +312,18 @@ export class TokenSidebar extends ReactWidget { } } - private _canInsertImport(tokenName: string): boolean { - const separatorIndex = tokenName.indexOf(':'); - if (separatorIndex <= 0) { - return false; - } - const packageName = tokenName.slice(0, separatorIndex).trim(); - const tokenSymbol = tokenName.slice(separatorIndex + 1).trim(); - return ( - packageName.length > 0 && - /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tokenSymbol) && - this._isImportEnabled() - ); - } - - private async _copyTokenName(tokenName: string): Promise { + private async _copyValue(value: string, valueKind: string): Promise { try { if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(tokenName); + await navigator.clipboard.writeText(value); } else { - Clipboard.copyToSystem(tokenName); + Clipboard.copyToSystem(value); } - this._setCopiedState(tokenName); + this._setCopiedState(value); } catch (error) { try { - Clipboard.copyToSystem(tokenName); - this._setCopiedState(tokenName); + Clipboard.copyToSystem(value); + this._setCopiedState(value); } catch (fallbackError) { const message = fallbackError instanceof Error @@ -198,23 +332,23 @@ export class TokenSidebar extends ReactWidget { ? error.message : 'Unknown clipboard error'; await showDialog({ - title: 'Failed to copy token string', - body: `Could not copy "${tokenName}". ${message}`, + title: `Failed to copy ${valueKind}`, + body: `Could not copy "${value}". ${message}`, buttons: [Dialog.okButton()] }); } } } - private _setCopiedState(tokenName: string): void { - this._copiedTokenName = tokenName; + private _setCopiedState(value: string): void { + this._copiedValue = value; this.update(); if (this._copiedTimer !== null) { window.clearTimeout(this._copiedTimer); } this._copiedTimer = window.setTimeout(() => { - this._copiedTokenName = null; + this._copiedValue = null; this._copiedTimer = null; this.update(); }, 1200); diff --git a/style/base.css b/style/base.css index 7dd3bcf..3a27aa8 100644 --- a/style/base.css +++ b/style/base.css @@ -24,6 +24,44 @@ container-type: inline-size; } +.jp-PluginPlayground-viewToggle { + display: grid; + gap: 6px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-bottom: 5px; +} + +.jp-PluginPlayground-viewButton { + background: var(--jp-shortcuts-button-background); + border: var(--jp-border-width) solid var(--jp-shortcuts-button-background); + color: var(--jp-ui-font-color1); + justify-content: center; + width: 100%; +} + +.jp-PluginPlayground-viewButton:hover { + background: var(--jp-shortcuts-button-hover-background); + border-color: var(--jp-shortcuts-button-hover-background); +} + +.jp-PluginPlayground-viewButton.jp-mod-active { + background: var(--jp-brand-color1); + border-color: var(--jp-brand-color1); + color: var(--jp-ui-inverse-font-color1); +} + +.jp-PluginPlayground-viewButton.jp-mod-active:hover { + background: var(--jp-brand-color1); + border-color: var(--jp-brand-color1); + color: var(--jp-ui-inverse-font-color1); +} + +.jp-PluginPlayground-viewPanel { + display: flex; + flex-direction: column; + gap: var(--jp-layout-spacing-standard); +} + .jp-PluginPlayground-count { color: var(--jp-ui-font-color2); font-size: var(--jp-ui-font-size0); @@ -93,10 +131,11 @@ background: var(--jp-layout-color3); } +.jp-PluginPlayground-viewButton:focus-visible, .jp-PluginPlayground-actionButton:focus-visible, .jp-PluginPlayground-filter:focus-visible { outline: var(--jp-border-width) solid var(--jp-brand-color1); - outline-offset: 1px; + outline-offset: 2px; } .jp-PluginPlayground-actionIcon { diff --git a/ui-tests/tests/plugin-playground.spec.ts b/ui-tests/tests/plugin-playground.spec.ts index 2339f98..1783a8b 100644 --- a/ui-tests/tests/plugin-playground.spec.ts +++ b/ui-tests/tests/plugin-playground.spec.ts @@ -4,10 +4,13 @@ import type { IJupyterLabPageFixture } from '@jupyterlab/galata'; import type { Locator } from '@playwright/test'; const LOAD_COMMAND = 'plugin-playground:load-as-extension'; +const INTERNAL_CONTEXT_INFO_COMMAND = '__internal:context-menu-info'; const CREATE_FILE_COMMAND = 'plugin-playground:create-new-plugin'; const TEST_PLUGIN_ID = 'playground-integration-test:plugin'; const TEST_TOGGLE_COMMAND = 'playground-integration-test:toggle'; const TEST_FILE = 'playground-integration-test.ts'; +const COMMAND_COMPLETION_FILE = 'command-completion.ts'; +const INVOKE_FILE_COMPLETER_COMMAND = 'completer:invoke-file'; const PLAYGROUND_SIDEBAR_ID = 'jp-plugin-playground-sidebar'; const TOKEN_SECTION_ID = 'jp-plugin-token-sidebar'; const EXAMPLE_SECTION_ID = 'jp-plugin-example-sidebar'; @@ -315,3 +318,92 @@ test('token sidebar inserts import statement into active editor', async ({ return source.split(expected).length - 1 === 1; }, expectedImport); }); + +test('commands tab lists and filters available commands', async ({ page }) => { + await page.goto(); + const panel = await openSidebarPanel(page, TOKEN_SECTION_ID); + + await expect( + panel.getByRole('tablist', { name: 'Extension points' }) + ).toBeVisible(); + + const commandsButton = panel.getByRole('tab', { + name: 'Commands', + exact: true + }); + await commandsButton.click(); + await expect(commandsButton).toHaveAttribute('aria-selected', 'true'); + + const filterInput = panel.getByPlaceholder('Filter command ids'); + await filterInput.fill(LOAD_COMMAND); + + await expect(panel.locator('.jp-PluginPlayground-listItem')).toHaveCount(1); + await expect(panel.locator('.jp-PluginPlayground-entryLabel')).toHaveText([ + LOAD_COMMAND + ]); + await expect(panel.getByText('Load Current File As Extension')).toBeVisible(); + + await filterInput.fill(INTERNAL_CONTEXT_INFO_COMMAND); + await expect(panel.locator('.jp-PluginPlayground-listItem')).toHaveCount(0); + await expect(panel.getByText('No matching commands.')).toBeVisible(); +}); + +test('command completer suggests command ids inside execute calls', async ({ + page, + tmpPath +}) => { + const editorPath = `${tmpPath}/${COMMAND_COMPLETION_FILE}`; + + await page.contents.uploadContent( + `import { JupyterFrontEnd } from '@jupyterlab/application'; + +const run = (application: JupyterFrontEnd) => { + application.commands.execute(); +}; +`, + 'text', + editorPath + ); + await page.goto(); + await page.filebrowser.open(editorPath); + expect(await page.activity.activateTab(COMMAND_COMPLETION_FILE)).toBe(true); + + await page.evaluate(() => { + const current = window.jupyterapp.shell.currentWidget as FileEditorWidget; + const editor = current.content.editor; + const line = 3; + const text = editor.getLine(line) ?? ''; + editor.setCursorPosition({ + line, + column: text.indexOf('(') + 1 + }); + editor.focus(); + }); + + await page.keyboard.type('pl'); + await page.waitForCondition(() => + page.evaluate((id: string) => { + return window.jupyterapp.commands.hasCommand(id); + }, INVOKE_FILE_COMPLETER_COMMAND) + ); + await page.evaluate((id: string) => { + return window.jupyterapp.commands.execute(id); + }, INVOKE_FILE_COMPLETER_COMMAND); + await page.waitForSelector(`.jp-Completer code:has-text("${LOAD_COMMAND}")`); + + const suggestion = page + .locator(`.jp-Completer code:has-text("${LOAD_COMMAND}")`) + .first(); + await Promise.all([ + page.waitForSelector(`.jp-Completer code:has-text("${LOAD_COMMAND}")`, { + state: 'hidden' + }), + suggestion.click() + ]); + + await page.waitForFunction((expected: string) => { + const current = window.jupyterapp.shell.currentWidget as FileEditorWidget; + const source = current.content.model.sharedModel.getSource(); + return source.includes(`application.commands.execute('${expected}')`); + }, LOAD_COMMAND); +});