diff --git a/README.md b/README.md index 89c8c1d..dd98d20 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Plugin Playground is built to keep the full plugin prototyping workflow inside J ![Export format dropdown in editor toolbar](docs/images/readme/editor-toolbar-export-dropdown.png) ![Share target dropdown in editor toolbar](docs/images/readme/editor-toolbar-share-dropdown.png) -The right sidebar includes a single Plugin Playground panel with two collapsible sections. In **Extension Points**, the `Tokens` tab helps you discover available token strings and insert import/dependency updates, the `Commands` tab lets you search command IDs, inspect argument docs, and insert execution snippets (either directly or through AI-assisted prompt mode), and the `Packages` tab surfaces package docs plus npm and repository links for known modules. +The right sidebar includes a single Plugin Playground panel with two collapsible sections. In **Extension Points**, the `Tokens` tab helps you discover available token strings, insert import/dependency updates, and find existing usages, the `Commands` tab lets you search command IDs, inspect argument docs, find existing usages, and insert execution snippets (either directly or through AI-assisted prompt mode), and the `Packages` tab surfaces package docs plus npm and repository links for known modules. ![Extension Points token discovery and insertion actions](docs/images/readme/extension-points-tokens.png) ![Extension Points command discovery and insertion actions](docs/images/readme/extension-points-commands.png) @@ -200,6 +200,8 @@ The same command row also includes the `f(n)` button to inspect command argument ![Command argument documentation expanded in Commands tab](docs/images/readme/command-argument-docs.png) +Token and command rows also include a `Find usages` action. It searches TypeScript files in the current file browser directory and the bundled `extension-examples/` folder when available, then shows matching file paths and line numbers inline. + The sidebar remembers your last-used command insert mode in: - `commandInsertDefaultMode` (`insert` or `ai`, `insert` by default) diff --git a/src/index.ts b/src/index.ts index d2012ba..365520f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -338,6 +338,9 @@ const ARCHIVE_EXCLUDED_DIRECTORIES = new Set([ 'node_modules' ]); const ARCHIVE_FILE_READ_CONCURRENCY = 8; +const USAGE_SEARCH_FILE_READ_CONCURRENCY = 8; +const USAGE_SEARCH_LINE_PREVIEW_MAX_LENGTH = 240; +const USAGE_SEARCH_SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']); const HIDE_QUERY_KEY = 'hide'; const HIDE_QUERY_VALUE_ALL = 'all'; const HIDE_QUERY_VALUE_MENU = 'menu'; @@ -854,6 +857,7 @@ class PluginPlayground { isImportEnabled: this._canInsertImport.bind(this), onSetCommandInsertMode: this._setCommandInsertMode.bind(this), onInsertCommand: this._insertCommandExecution.bind(this), + onFindUsages: this._findUsages.bind(this), getCommandInsertMode: () => this._commandInsertMode, isCommandInsertEnabled: this._hasEditableEditor.bind(this) }); @@ -1874,6 +1878,108 @@ class PluginPlayground { return results; } + private async _findUsages( + value: string, + kind: TokenSidebar.UsageKind + ): Promise> { + const terms = this._usageSearchTerms(value, kind); + if (terms.length === 0) { + return []; + } + + const currentBrowser = this.fileBrowserFactory?.tracker.currentWidget; + let currentRoot = ''; + if (currentBrowser) { + currentRoot = ContentUtils.normalizeContentsPath( + currentBrowser.model.path + ).replace(/^\.$/, ''); + } else { + const currentEditorPath = this.editorTracker.currentWidget?.context.path; + if (currentEditorPath) { + currentRoot = ContentUtils.normalizeContentsPath( + PathExt.dirname(currentEditorPath) + ).replace(/^\.$/, ''); + } + } + + const roots = [...new Set([currentRoot, EXTENSION_EXAMPLES_ROOT])]; + const filePathsByRoot = await Promise.all( + roots.map(async root => { + try { + return (await this._collectArchiveFilePaths(root)).filter(filePath => + USAGE_SEARCH_SOURCE_EXTENSIONS.has( + PathExt.extname(filePath).toLowerCase() + ) + ); + } catch { + return []; + } + }) + ); + const filePaths = [...new Set(filePathsByRoot.flat())].sort((left, right) => + left.localeCompare(right) + ); + const matchesByFile = await this._mapWithConcurrency( + filePaths, + USAGE_SEARCH_FILE_READ_CONCURRENCY, + filePath => this._findUsageMatchesInFile(filePath, terms) + ); + return matchesByFile.flat(); + } + + private _usageSearchTerms( + value: string, + kind: TokenSidebar.UsageKind + ): string[] { + const terms = new Set(); + const normalizedValue = value.trim(); + if (normalizedValue) { + terms.add(normalizedValue); + } + if (kind === 'token') { + const tokenReference = parseTokenReference(normalizedValue); + if (tokenReference) { + terms.add(tokenReference.tokenSymbol); + } + } + return [...terms].sort((left, right) => right.length - left.length); + } + + private async _findUsageMatchesInFile( + filePath: string, + terms: ReadonlyArray + ): Promise { + const source = await ContentUtils.readContentsFileAsText( + this.app.serviceManager, + filePath + ); + if (source === null) { + return []; + } + + const matches: TokenSidebar.IUsageMatch[] = []; + const lines = source.split(/\r\n|\r|\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (terms.some(term => line.includes(term))) { + matches.push({ + path: filePath, + line: index + 1, + lineText: this._usageLinePreview(line) + }); + } + } + return matches; + } + + private _usageLinePreview(line: string): string { + const trimmed = line.trim(); + if (trimmed.length <= USAGE_SEARCH_LINE_PREVIEW_MAX_LENGTH) { + return trimmed; + } + return `${trimmed.slice(0, USAGE_SEARCH_LINE_PREVIEW_MAX_LENGTH - 3)}...`; + } + private _shouldSkipArchiveDirectory(name: string): boolean { return ARCHIVE_EXCLUDED_DIRECTORIES.has(name); } diff --git a/src/token-sidebar.tsx b/src/token-sidebar.tsx index cf6c845..d666bb0 100644 --- a/src/token-sidebar.tsx +++ b/src/token-sidebar.tsx @@ -7,6 +7,7 @@ import { jsonIcon, MenuSvg, offlineBoltIcon, + searchIcon, type LabIcon } from '@jupyterlab/ui-components'; @@ -42,6 +43,14 @@ export namespace TokenSidebar { description: string; } + export type UsageKind = 'token' | 'command'; + + export interface IUsageMatch { + path: string; + line: number; + lineText: string; + } + export interface IOptions { getTokens: () => ReadonlyArray; getCommands: () => ReadonlyArray; @@ -63,6 +72,10 @@ export namespace TokenSidebar { commandId: string, mode: CommandInsertMode ) => Promise | void; + onFindUsages: ( + value: string, + kind: UsageKind + ) => Promise>; getCommandInsertMode: () => CommandInsertMode; isCommandInsertEnabled: () => boolean; } @@ -170,6 +183,10 @@ export class TokenSidebar extends ReactWidget { commandId: string, mode: CommandInsertMode ) => Promise | void; + private readonly _onFindUsages: ( + value: string, + kind: TokenSidebar.UsageKind + ) => Promise>; private readonly _getCommandInsertMode: () => CommandInsertMode; private readonly _isCommandInsertEnabled: () => boolean; private _query = ''; @@ -186,6 +203,13 @@ export class TokenSidebar extends ReactWidget { ICommandArgumentDocumentation | null >(); private _commandArgumentCounts = new Map(); + private _expandedUsageKeys = new Set(); + private _loadingUsageKeys = new Set(); + private _usageResults = new Map< + string, + ReadonlyArray + >(); + private _usageErrors = new Map(); private readonly _commandInsertMenuCommands = new CommandRegistry(); private readonly _commandInsertMenu = new MenuSvg({ commands: this._commandInsertMenuCommands @@ -204,6 +228,7 @@ export class TokenSidebar extends ReactWidget { this._isImportEnabled = options.isImportEnabled; this._onSetCommandInsertMode = options.onSetCommandInsertMode; this._onInsertCommand = options.onInsertCommand; + this._onFindUsages = options.onFindUsages; this._getCommandInsertMode = options.getCommandInsertMode; this._isCommandInsertEnabled = options.isCommandInsertEnabled; this.addClass('jp-PluginPlayground-sidebar'); @@ -410,6 +435,7 @@ export class TokenSidebar extends ReactWidget { className: 'jp-PluginPlayground-actionIcon' })} + {this._renderUsageSearchButton('token', token.name)} + {this._renderUsageSearchButton('command', command.id)} + ); + } + + private _renderUsageResults( + kind: TokenSidebar.UsageKind, + value: string + ): JSX.Element | null { + const key = `${kind}:${value}`; + if (!this._expandedUsageKeys.has(key)) { + return null; + } + + const isLoading = this._loadingUsageKeys.has(key); + const error = this._usageErrors.get(key); + const matches = this._usageResults.get(key) ?? []; + const valueKind = kind === 'token' ? 'token string' : 'command id'; + const normalizedPanelValue = `${kind}-${value}`.replace( + /[^A-Za-z0-9_-]/g, + '-' + ); + const panelId = `jp-PluginPlayground-usageResults-${normalizedPanelValue}`; + const fileCount = new Set(matches.map(match => match.path)).size; + + return ( +
+ {isLoading ? ( +

Finding usages…

+ ) : error ? ( +

+ Failed to find usages: {error} +

+ ) : matches.length === 0 ? ( +

No usages found.

+ ) : ( + <> +

+ {matches.length} {matches.length === 1 ? 'usage' : 'usages'} in{' '} + {fileCount} {fileCount === 1 ? 'file' : 'files'}. +

+
    + {matches.map((match, index) => ( +
  • + + {match.path}:{match.line} + + + {match.lineText} + +
  • + ))} +
+ + )} +
+ ); + } + + private async _findUsages( + kind: TokenSidebar.UsageKind, + value: string + ): Promise { + const key = `${kind}:${value}`; + if (this._expandedUsageKeys.has(key) && !this._loadingUsageKeys.has(key)) { + this._expandedUsageKeys.delete(key); + this.update(); + return; + } + + this._expandedUsageKeys.add(key); + this._loadingUsageKeys.add(key); + this._usageErrors.delete(key); + this.update(); + + try { + const results = await this._onFindUsages(value, kind); + this._usageResults.set(key, results); + } catch (error) { + this._usageResults.set(key, []); + this._usageErrors.set( + key, + error instanceof Error ? error.message : 'Unknown search error' + ); + } finally { + this._loadingUsageKeys.delete(key); + this.update(); + } } private async _insertImport(tokenName: string): Promise { diff --git a/style/base.css b/style/base.css index 4b8f595..8abaaf8 100644 --- a/style/base.css +++ b/style/base.css @@ -325,6 +325,44 @@ margin-top: 8px; } +.jp-PluginPlayground-usageResults { + margin-top: 8px; +} + +.jp-PluginPlayground-usageList { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + margin: 6px 0 0; + padding: 0; +} + +.jp-PluginPlayground-usageListItem { + background: var(--jp-layout-color2); + border: var(--jp-border-width) solid var(--jp-border-color2); + border-radius: var(--jp-border-radius); + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px; +} + +.jp-PluginPlayground-usageLocation { + color: var(--jp-ui-font-color1); + font-family: var(--jp-code-font-family); + font-size: var(--jp-ui-font-size0); + overflow-wrap: anywhere; +} + +.jp-PluginPlayground-usageLine { + color: var(--jp-ui-font-color2); + font-family: var(--jp-code-font-family); + font-size: var(--jp-code-font-size); + overflow-wrap: anywhere; + white-space: pre-wrap; +} + .jp-PluginPlayground-commandArgumentsText { background: var(--jp-layout-color2); border: var(--jp-border-width) solid var(--jp-border-color2); diff --git a/ui-tests/tests/plugin-playground.spec.ts b/ui-tests/tests/plugin-playground.spec.ts index 6ed6249..1cb68e2 100644 --- a/ui-tests/tests/plugin-playground.spec.ts +++ b/ui-tests/tests/plugin-playground.spec.ts @@ -4458,6 +4458,80 @@ test('commands tab lists and filters available commands', async ({ page }) => { ).toContainText(/(Usage:|Arguments Schema:)/); }); +test('token and command rows find usages in the current folder', async ({ + page, + tmpPath +}) => { + await page.goto(); + const panel = await openSidebarPanel(page, TOKEN_SECTION_ID); + const tokenName = await findImportableToken(panel); + const separatorIndex = tokenName.indexOf(':'); + const packageName = tokenName.slice(0, separatorIndex).trim(); + const tokenSymbol = tokenName.slice(separatorIndex + 1).trim(); + const projectRoot = `${tmpPath}/usage-search-test`; + const tokenUsagePath = `${projectRoot}/src/token-usage.ts`; + const commandUsagePath = `${projectRoot}/command-usage.ts`; + const exampleUsagePath = `${EXTENSION_EXAMPLES_ROOT}/usage-search-example/src/index.ts`; + + await page.contents.uploadContent( + `import { ${tokenSymbol} } from '${packageName}'; +const tokenUsage: ${tokenSymbol} | null = null; +void tokenUsage; +`, + 'text', + tokenUsagePath + ); + await page.contents.uploadContent( + `await app.commands.execute('${LOAD_COMMAND}'); +`, + 'text', + commandUsagePath + ); + await page.contents.uploadContent( + `import { ${tokenSymbol} } from '${packageName}'; +await app.commands.execute('${LOAD_COMMAND}'); +`, + 'text', + exampleUsagePath + ); + await page.filebrowser.openDirectory(projectRoot); + + await panel.getByPlaceholder('Filter token strings').fill(tokenName); + const tokenListItem = panel.locator('.jp-PluginPlayground-listItem'); + await expect(tokenListItem).toHaveCount(1); + await tokenListItem + .getByRole('button', { + name: `Find usages of token string ${tokenName}` + }) + .click(); + const tokenUsageResults = tokenListItem.locator( + '.jp-PluginPlayground-usageResults' + ); + await expect(tokenUsageResults).toBeVisible(); + await expect(tokenUsageResults).toContainText(`${tokenUsagePath}:1`); + await expect(tokenUsageResults).toContainText(`${tokenUsagePath}:2`); + await expect(tokenUsageResults).toContainText(`${exampleUsagePath}:1`); + + await panel.getByRole('tab', { name: 'Commands', exact: true }).click(); + await panel.getByPlaceholder('Filter command ids').fill(LOAD_COMMAND); + const commandListItem = panel.locator('.jp-PluginPlayground-listItem'); + await expect(commandListItem).toHaveCount(1); + await commandListItem + .getByRole('button', { + name: `Find usages of command id ${LOAD_COMMAND}` + }) + .click(); + const commandUsageResults = commandListItem.locator( + '.jp-PluginPlayground-usageResults' + ); + await expect(commandUsageResults).toBeVisible(); + await expect(commandUsageResults).toContainText(`${commandUsagePath}:1`); + await expect(commandUsageResults).toContainText(`${exampleUsagePath}:2`); + await expect(commandUsageResults).toContainText( + `app.commands.execute('${LOAD_COMMAND}')` + ); +}); + test('commands tab inserts command execution at cursor position', async ({ page, tmpPath