diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index f105a135e29..cc5247870d8 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -250,8 +250,7 @@ export async function activate(context: vscode.ExtensionContext) { const machineId = await vscode.env.machineId; const remoteAuthority = vscode.env.remoteName; - context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({ - id: 'terminal-suggest', + context.subscriptions.push(vscode.window.registerTerminalCompletionProvider('terminal-suggest', { async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: vscode.TerminalCompletionContext, token: vscode.CancellationToken): Promise { currentTerminalEnv = terminal.shellIntegration?.env?.value ?? process.env; if (token.isCancellationRequested) { @@ -305,14 +304,14 @@ export async function activate(context: vscode.ExtensionContext) { } } - - if (terminal.shellIntegration?.cwd && (result.filesRequested || result.foldersRequested)) { + const cwd = result.cwd ?? terminal.shellIntegration?.cwd; + if (cwd && (result.filesRequested || result.foldersRequested)) { + const globPattern = createFileRegex(result.fileExtensions); return new vscode.TerminalCompletionList(result.items, { filesRequested: result.filesRequested, foldersRequested: result.foldersRequested, - fileExtensions: result.fileExtensions, - cwd: result.cwd ?? terminal.shellIntegration.cwd, - env: terminal.shellIntegration?.env?.value, + globPattern, + cwd, }); } return result.items; @@ -565,3 +564,17 @@ export function sanitizeProcessEnvironment(env: Record, ...prese }); } + +// Escapes regex special characters in a string +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function createFileRegex(fileExtensions?: string[]): vscode.GlobPattern | undefined { + if (!fileExtensions || fileExtensions.length === 0) { + return undefined; + } + const exts = fileExtensions.map(ext => ext.startsWith('.') ? ext : '.' + ext); + // Create a regex that matches any string ending with one of the extensions + return `.*(${exts.map(ext => escapeRegExp(ext)).join('|')})$`; +} diff --git a/src/vs/workbench/api/browser/mainThreadTerminalService.ts b/src/vs/workbench/api/browser/mainThreadTerminalService.ts index f82ac2d5fe1..64afd9475cf 100644 --- a/src/vs/workbench/api/browser/mainThreadTerminalService.ts +++ b/src/vs/workbench/api/browser/mainThreadTerminalService.ts @@ -279,13 +279,27 @@ export class MainThreadTerminalService implements MainThreadTerminalServiceShape id, provideCompletions: async (commandLine, cursorPosition, allowFallbackCompletions, token) => { const completions = await this._proxy.$provideTerminalCompletions(id, { commandLine, cursorPosition, allowFallbackCompletions }, token); - return { - items: completions?.items.map(c => ({ - provider: `ext:${id}`, - ...c, - })), - resourceRequestConfig: completions?.resourceRequestConfig - }; + if (!completions) { + return undefined; + } + if (completions.resourceRequestConfig) { + const { cwd, globPattern, ...rest } = completions.resourceRequestConfig; + return { + items: completions.items?.map(c => ({ + provider: `ext:${id}`, + ...c, + })), + resourceRequestConfig: { + ...rest, + cwd, + globPattern + } + }; + } + return completions.items?.map(c => ({ + provider: `ext:${id}`, + ...c, + })); } }, ...triggerCharacters)); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 65587b00b7a..894bd1b233a 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -876,9 +876,9 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I registerTerminalProfileProvider(id: string, provider: vscode.TerminalProfileProvider): vscode.Disposable { return extHostTerminalService.registerProfileProvider(extension, id, provider); }, - registerTerminalCompletionProvider(provider: vscode.TerminalCompletionProvider, ...triggerCharacters: string[]): vscode.Disposable { + registerTerminalCompletionProvider(id: string, provider: vscode.TerminalCompletionProvider, ...triggerCharacters: string[]): vscode.Disposable { checkProposedApiEnabled(extension, 'terminalCompletionProvider'); - return extHostTerminalService.registerTerminalCompletionProvider(extension, provider, ...triggerCharacters); + return extHostTerminalService.registerTerminalCompletionProvider(extension, id, provider, ...triggerCharacters); }, registerTerminalQuickFixProvider(id: string, provider: vscode.TerminalQuickFixProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'terminalQuickFixProvider'); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0c4bf617744..72cc69a0896 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2527,8 +2527,8 @@ export class TerminalCompletionListDto, ...triggerCharacters: string[]): vscode.Disposable; + registerTerminalCompletionProvider(extension: IExtensionDescription, id: string, provider: vscode.TerminalCompletionProvider, ...triggerCharacters: string[]): vscode.Disposable; } interface IEnvironmentVariableCollection extends vscode.EnvironmentVariableCollection { @@ -757,15 +757,15 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I }); } - public registerTerminalCompletionProvider(extension: IExtensionDescription, provider: vscode.TerminalCompletionProvider, ...triggerCharacters: string[]): vscode.Disposable { - if (this._completionProviders.has(provider.id)) { - throw new Error(`Terminal completion provider "${provider.id}" already registered`); + public registerTerminalCompletionProvider(extension: IExtensionDescription, id: string, provider: vscode.TerminalCompletionProvider, ...triggerCharacters: string[]): vscode.Disposable { + if (this._completionProviders.has(id)) { + throw new Error(`Terminal completion provider "${id}" already registered`); } - this._completionProviders.set(provider.id, provider); - this._proxy.$registerCompletionProvider(provider.id, extension.identifier.value, ...triggerCharacters); + this._completionProviders.set(id, provider); + this._proxy.$registerCompletionProvider(id, extension.identifier.value, ...triggerCharacters); return new VSCodeDisposable(() => { - this._completionProviders.delete(provider.id); - this._proxy.$unregisterCompletionProvider(provider.id); + this._completionProviders.delete(id); + this._proxy.$unregisterCompletionProvider(id); }); } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 4255ae1ba0f..19b21ebd925 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3407,6 +3407,7 @@ export namespace TerminalResourceRequestConfig { ...resourceRequestConfig, pathSeparator, cwd: resourceRequestConfig.cwd, + globPattern: GlobPattern.from(resourceRequestConfig.globPattern) ?? undefined }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index 97396cb6ed4..4dd889411c0 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -21,6 +21,7 @@ import { timeout } from '../../../../../base/common/async.js'; import { gitBashToWindowsPath, windowsToGitBashPath } from './terminalGitBashHelpers.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IRelativePattern, match } from '../../../../../base/common/glob.js'; export const ITerminalCompletionService = createDecorator('terminalCompletionService'); @@ -55,10 +56,9 @@ export class TerminalCompletionList { export interface TerminalResourceRequestConfig { filesRequested?: boolean; foldersRequested?: boolean; - fileExtensions?: string[]; - cwd?: UriComponents; + globPattern?: string | IRelativePattern; + cwd: UriComponents; pathSeparator: string; - env?: { [key: string]: string | null | undefined }; } @@ -258,10 +258,9 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo // provide diagnostics when a folder is provided where a file is expected. const foldersRequested = (resourceRequestConfig.foldersRequested || resourceRequestConfig.filesRequested) ?? false; const filesRequested = resourceRequestConfig.filesRequested ?? false; - const fileExtensions = resourceRequestConfig.fileExtensions ?? undefined; + const globPattern = resourceRequestConfig.globPattern ?? undefined; - const cwd = URI.revive(resourceRequestConfig.cwd); - if (!cwd || (!foldersRequested && !filesRequested)) { + if (!foldersRequested && !filesRequested) { return; } @@ -307,6 +306,8 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo const lastWordFolderHasTildePrefix = !!lastWordFolder.match(/^~[\\\/]?/); const isAbsolutePath = getIsAbsolutePath(shellType, resourceRequestConfig.pathSeparator, lastWordFolder, useWindowsStylePath); const type = lastWordFolderHasTildePrefix ? 'tilde' : isAbsolutePath ? 'absolute' : 'relative'; + const cwd = URI.revive(resourceRequestConfig.cwd); + switch (type) { case 'tilde': { const home = this._getHomeDir(useWindowsStylePath, capabilities); @@ -442,9 +443,12 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo label = escapeTerminalCompletionLabel(label, shellType, resourceRequestConfig.pathSeparator); - if (child.isFile && fileExtensions) { - const extension = child.name.split('.').length > 1 ? child.name.split('.').at(-1) : undefined; - if (extension && !fileExtensions.includes(extension)) { + if (child.isFile && globPattern) { + const filePath = child.resource.fsPath; + const matches = typeof globPattern === 'string' + ? new RegExp(globPattern).test(filePath) + : match(globPattern, filePath); + if (!matches) { return; } } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts index b8e5eff0c50..5094ba0acae 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts @@ -147,12 +147,6 @@ suite('TerminalCompletionService', () => { }); suite('resolveResources should return undefined', () => { - test('if cwd is not provided', async () => { - const resourceRequestConfig: TerminalResourceRequestConfig = { pathSeparator }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ', 3, provider, capabilities); - assert(!result); - }); - test('if neither filesRequested nor foldersRequested are true', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), diff --git a/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts b/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts index 5824afff3d0..1a0196f683c 100644 --- a/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts @@ -7,10 +7,22 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/226562 + /** + * A provider that supplies terminal completion items. + * + * Implementations of this interface should return an array of {@link TerminalCompletionItem} or a + * {@link TerminalCompletionList} describing completions for the current command line. + * + * @example Simple provider returning a single completion + * window.registerTerminalCompletionProvider('extension-provider-id', { + * provideTerminalCompletions(terminal, context) { + * return [{ label: '--help', replacementIndex: Math.max(0, context.cursorPosition - 2), replacementLength: 2 }]; + * } + * }); + */ export interface TerminalCompletionProvider { - id: string; /** - * Provide completions for the given position and document. + * Provide completions for the given terminal and context. * @param terminal The terminal for which completions are being provided. * @param context Information about the terminal's current state. * @param token A cancellation token. @@ -20,6 +32,21 @@ declare module 'vscode' { } + /** + * Represents a completion suggestion for a terminal command line. + * + * @example Completion item for `ls -|` + * const item = { + * label: '-A', + * replacementIndex: 3, + * replacementLength: 1, + * detail: 'List all entries except for . and .. (always set for the super-user)', + * kind: TerminalCompletionItemKind.Flag + * }; + * + * The fields on a completion item describe what text should be shown to the user + * and which portion of the command line should be replaced when the item is accepted. + */ export interface TerminalCompletionItem { /** * The label of the completion. @@ -41,7 +68,6 @@ declare module 'vscode' { */ detail?: string; - /** * A human-readable string that represents a doc-comment. */ @@ -55,7 +81,11 @@ declare module 'vscode' { /** - * Terminal item kinds. + * The kind of an individual terminal completion item. + * + * The kind is used to render an appropriate icon in the suggest list and to convey the semantic + * meaning of the suggestion (file, folder, flag, commit, branch, etc.). + * */ export enum TerminalCompletionItemKind { File = 0, @@ -77,6 +107,13 @@ declare module 'vscode' { PullRequestDone = 16, } + + /** + * Context information passed to {@link TerminalCompletionProvider.provideTerminalCompletions}. + * + * It contains the full command line, the current cursor position, and a flag indicating whether + * fallback completions are allowed when the exact completion type cannot be determined. + */ export interface TerminalCompletionContext { /** * The complete terminal command line. @@ -95,17 +132,31 @@ declare module 'vscode' { export namespace window { /** - * Register a completion provider for a certain type of terminal. - * + * Register a completion provider for terminals. + * @param id The unique identifier of the terminal provider, used as a settings key and shown in the information hover of the suggest widget. * @param provider The completion provider. * @returns A {@link Disposable} that unregisters this provider when being disposed. + * + * @example Register a provider for an extension + * window.registerTerminalCompletionProvider('extension-provider-id', { + * provideTerminalCompletions(terminal, context) { + * return new TerminalCompletionList([ + * { label: '--version', replacementIndex: Math.max(0, context.cursorPosition - 2), replacementLength: 2 } + * ]); + * } + * }); */ - export function registerTerminalCompletionProvider(provider: TerminalCompletionProvider, ...triggerCharacters: string[]): Disposable; + export function registerTerminalCompletionProvider(id: string, provider: TerminalCompletionProvider, ...triggerCharacters: string[]): Disposable; } /** * Represents a collection of {@link TerminalCompletionItem completion items} to be presented * in the terminal. + * + * @example Create a completion list that requests files for the terminal cwd + * const list = new TerminalCompletionList([ + * { label: 'ls', replacementIndex: 0, replacementLength: 0, kind: TerminalCompletionItemKind.Method } + * ], { filesRequested: true, cwd: Uri.file('/home/user') }); */ export class TerminalCompletionList { @@ -128,6 +179,13 @@ declare module 'vscode' { constructor(items?: T[], resourceRequestConfig?: TerminalResourceRequestConfig); } + + /** + * Configuration for requesting file and folder resources to be shown as completions. + * + * When a provider indicates that it wants file/folder resources, the terminal will surface completions for files and + * folders that match {@link globPattern} from the provided {@link cwd}. + */ export interface TerminalResourceRequestConfig { /** * Show files as completion items. @@ -138,16 +196,12 @@ declare module 'vscode' { */ foldersRequested?: boolean; /** - * File extensions to filter by. + * A {@link GlobPattern glob pattern} that controls which files suggest should surface. */ - fileExtensions?: string[]; + globPattern?: GlobPattern; /** - * If no cwd is provided, no resources will be shown as completions. + * The cwd from which to request resources. */ - cwd?: Uri; - /** - * Environment variables to use when constructing paths. - */ - env?: { [key: string]: string | null | undefined }; + cwd: Uri; } }