Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
178 changes: 178 additions & 0 deletions src/command-completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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<boolean> {
return (
!!context.editor &&
SUPPORTED_MIME_PATTERN.test(context.editor.model.mimeType)
);
}

async fetch(
request: CompletionHandler.IRequest,
_context: ICompletionContext
): Promise<CompletionHandler.ICompletionItemsReply> {
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<JupyterFrontEnd, 'commands'>
): Array<ICommandRecord> {
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 QUOTED_EXECUTE_PATTERN =
/(?:^|[^\w$.])(?:app\.)?commands\.execute\(\s*(['"])([^'"\\]*)$/s;
const BARE_EXECUTE_PATTERN =
/(?:^|[^\w$.])(?:app\.)?commands\.execute\(\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] ?? '';
}
}
70 changes: 50 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -46,6 +47,11 @@ import { ExampleSidebar } from './example-sidebar';

import { tokenSidebarIcon } from './icons';

import {
CommandCompletionProvider,
getCommandRecords
} from './command-completion';

import {
fileModelToText,
getDirectoryModel,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -227,6 +227,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({
Expand Down Expand Up @@ -342,6 +347,7 @@ class PluginPlayground {
await this._deactivateAndDeregisterPlugin(plugin.id);
this.app.registerPlugin(plugin);
}
this._refreshExtensionPoints();

for (const plugin of plugins) {
if (!plugin.autoStart) {
Expand All @@ -358,6 +364,7 @@ class PluginPlayground {
}
try {
await this.app.activatePlugin(plugin.id);
this._refreshExtensionPoints();
} catch (e) {
showDialog({
title: `Plugin autostart failed: ${(e as Error).message}`,
Expand All @@ -368,6 +375,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<JupyterFrontEnd, unknown>
): string[] {
Expand Down Expand Up @@ -758,7 +778,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;
Expand All @@ -772,6 +796,7 @@ class PluginPlayground {
'No description provided by this example.';
private readonly _tokenMap = new Map<string, Token<string>>();
private readonly _tokenDescriptionMap = new Map<string, string>();
private _tokenSidebar: TokenSidebar | null = null;
}

/**
Expand All @@ -783,15 +808,20 @@ const plugin: JupyterFrontEndPlugin<void> = {
'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.

Expand Down
Loading
Loading