diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index b0c88e37c75..13d3186290f 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -7,7 +7,6 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { execSync } from 'child_process'; import { Arg, FigSpec, Option, Subcommand, Suggestion } from './types'; - const builtinCommands: string[] | undefined = getBuiltinCommands(); // TODO: use shell type to determine which builtin commands to use @@ -60,7 +59,7 @@ async function getCompletionSpecs(commands: Set): Promise { // TODO: try to use typescript instead? try { // Use a relative path to the autocomplete/src folder - const dirPath = path.resolve(__dirname, 'autocomplete/src'); + const dirPath = path.resolve(__dirname, 'autocomplete'); const files = await findFiles(dirPath, '.js'); const filtered = files.filter(file => commands.has(path.basename(file).replace('.js', ''))); @@ -99,22 +98,50 @@ async function getCompletionSpecs(commands: Set): Promise { // TODO: Leverage shellType when available https://github.com/microsoft/vscode/issues/230165 // terminal.state.shellType + // TODO: cache const availableCommands = await getCommandsInPath(); const specs = await getCompletionSpecs(availableCommands); builtinCommands?.forEach(command => availableCommands.add(command)); const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition); - if (prefix === undefined) { - return; - } + const result: vscode.TerminalCompletionItem[] = []; for (const spec of specs) { const name = getLabel(spec); if (!name || !availableCommands.has(name)) { continue; } + if (spec.args && name === 'cd') { + const escapedName = escapeRegExp(name); + if (terminalContext.commandLine.match(new RegExp(`${escapedName}\\s+$`))) { + console.log('command has args', spec.args, terminalContext.commandLine.match(new RegExp(`${escapedName}\\s+`))); + } + } + + if (spec.args && terminalContext.commandLine.match(new RegExp(`${escapeRegExp(name)}\\s+$`))) { + const fileArgument = shouldShowFile(spec.args) && !onlyShowFolders(spec.args); + const folderArgument = onlyShowFolders(spec.args); + console.log('command has fileArgument, folderArgument', fileArgument, folderArgument); + if (fileArgument || folderArgument) { + // TODO: return special items + console.log('pushing'); + result.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, ' ', fileArgument ? 'file' : 'folder', fileArgument ? 'File argument' : 'Folder argument')); + } + if (spec.args.suggestions) { + for (const suggestion of spec.args.suggestions) { + const suggestionName = getLabel(suggestion); + if (suggestionName) { + result.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, ' ', suggestionName, `Suggestion for ${name}: ${suggestion.description}`)); + } + } + } + } + + if (!prefix) { + continue; + } if (name.startsWith(prefix)) { - result.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, prefix, name, spec.description, spec.args)); + result.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, prefix, name, spec.description)); } // TODO: // deal with args on FigSpec, esp if non optional. @@ -152,7 +179,7 @@ async function getCompletionSpecs(commands: Set): Promise { // Return the completion results or undefined if no results return result.length ? result : undefined; } -}); +}, [' ']); function getLabel(spec: FigSpec | Option | Subcommand | Suggestion): string | undefined { if (typeof spec.name === 'string') { @@ -164,14 +191,12 @@ function getLabel(spec: FigSpec | Option | Subcommand | Suggestion): string | un return spec.name[0]; } -function createCompletionItem(commandLine: string, cursorPosition: number, prefix: string, label: string, description?: string, arg?: Arg): vscode.TerminalCompletionItem { +function createCompletionItem(commandLine: string, cursorPosition: number, prefix: string, label: string, description?: string): vscode.TerminalCompletionItem { return { label, isFile: false, isDirectory: false, detail: description ?? '', - fileArgument: shouldShowFile(arg) && !onlyShowFolders(arg), - folderArgument: onlyShowFolders(arg), replacementIndex: cursorPosition - prefix.length, replacementLength: label.length - prefix.length, }; @@ -182,16 +207,33 @@ function shouldShowFile(arg?: Arg): boolean { } function onlyShowFolders(arg?: Arg): boolean { - if (arg?.generators?.name === 'autocomplete_generators_1.filepaths') { - if ('showFolders' in arg.generators()) { - const showFolders = arg.generators().showFolders; - const onlyShowFolders = showFolders === 'only'; - return onlyShowFolders; - } - } - return false; + console.log(isFilepathsGenerator(arg?.generators)); + return isFilepathsGenerator(arg?.generators); } +function isFilepathsGenerator(generator: any) { + if (!generator || typeof generator !== 'object') { + return false; + } + // HACK because the generator object is not at all what I expect + // per logging below + return !!Object.keys(generator).find(key => key === 'getQueryTerm'); + // console.log('Generator:', generator); + // console.log('Type of generator:', typeof generator); + // if (generator && typeof generator === 'object') { + // console.log('Keys in generator:', Object.keys(generator)); + // Object.keys(generator).forEach(key => { + // console.log(`Key: ${key}, Type: ${typeof generator[key]}`); + // }); + // console.log('showFolders:', generator?.showFolders); + // } + // return ( + // generator && + // typeof generator === 'object' && + // 'showFolders' in generator && + // generator.showFolders === 'only' + // ); +} async function getCommandsInPath(): Promise> { // todo: use semicolon for windows const paths = process.env.PATH?.split(':') || []; @@ -218,7 +260,7 @@ async function getCommandsInPath(): Promise> { } function getPrefix(commandLine: string, cursorPosition: number): string | undefined { - // Check if cursor is at the end or there's whitespace after the cursor + // Check if cursor is not at the end and there's non-whitespace after the cursor if (cursorPosition < commandLine.length && /\S/.test(commandLine[cursorPosition])) { return undefined; } @@ -232,3 +274,6 @@ function getPrefix(commandLine: string, cursorPosition: number): string | undefi // Return the match if found, otherwise undefined return match ? match[0] : undefined; } +function escapeRegExp(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/extensions/terminal-suggest/src/types.ts b/extensions/terminal-suggest/src/types.ts index 0ad072c574b..99f615b7e29 100644 --- a/extensions/terminal-suggest/src/types.ts +++ b/extensions/terminal-suggest/src/types.ts @@ -13,7 +13,7 @@ export interface Arg { template?: string; description: string; isOptional?: boolean; - generators?: () => any; // Replace with the actual type if known + generators?: any; suggestions?: Suggestion[]; } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts index 92983dffb4b..a6bf42cdb53 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/pwshCompletionProviderAddon.ts @@ -53,6 +53,8 @@ const enum RequestCompletionsSequence { } export class PwshCompletionProviderAddon extends Disposable implements ITerminalAddon, ITerminalCompletionProvider { + id: string = PwshCompletionProviderAddon.ID; + triggerCharacters?: string[] | undefined; static readonly ID = 'terminal.pwshCompletionProvider'; static cachedPwshCommands: Set; readonly shellTypes = [GeneralShellType.PowerShell]; diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index 0bc17e22768..208c38f4ad8 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -18,6 +18,7 @@ export enum ISimpleCompletionKind { } export interface ITerminalCompletionProvider { + id: string; shellTypes?: TerminalShellType[]; provideCompletions(value: string, cursorPosition: number): Promise; triggerCharacters?: string[]; @@ -25,14 +26,16 @@ export interface ITerminalCompletionProvider { export interface ITerminalCompletionService { _serviceBrand: undefined; + providers: ITerminalCompletionProvider[]; registerTerminalCompletionProvider(extensionIdentifier: string, id: string, provider: ITerminalCompletionProvider, ...triggerCharacters: string[]): IDisposable; - provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType): Promise; + provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, triggeredProviders?: ITerminalCompletionProvider[]): Promise; } // TODO: make name consistent export class TerminalCompletionService extends Disposable implements ITerminalCompletionService { declare _serviceBrand: undefined; private readonly _providers: Map> = new Map(); + get providers() { return [...this._providers.values()].flatMap(providerMap => [...providerMap.values()]); } constructor(@IConfigurationService private readonly _configurationService: IConfigurationService) { super(); @@ -45,6 +48,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo this._providers.set(extensionIdentifier, extMap); } provider.triggerCharacters = triggerCharacters; + provider.id = id; extMap.set(id, provider); return toDisposable(() => { const extMap = this._providers.get(extensionIdentifier); @@ -57,31 +61,40 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo }); } - async provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType): Promise { + async provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, triggeredProviders?: ITerminalCompletionProvider[]): Promise { const completionItems: ISimpleCompletion[] = []; if (!this._providers || !this._providers.values) { return undefined; } - // TODO: Use Promise.all so all providers are called in parallel - for (const providerMap of this._providers.values()) { - for (const [extensionId, provider] of providerMap) { + const collectCompletions = async (providers: ITerminalCompletionProvider[]) => { + await Promise.all(providers.map(async provider => { if (provider.shellTypes && !provider.shellTypes.includes(shellType)) { - continue; + return; } const completions = await provider.provideCompletions(promptValue, cursorPosition); const devModeEnabled = this._configurationService.getValue(TerminalSettingId.DevMode); if (completions) { for (const completion of completions) { - if (devModeEnabled && !completion.detail?.includes(extensionId)) { - completion.detail = `(${extensionId}) ${completion.detail ?? ''}`; + if (devModeEnabled && !completion.detail?.includes(provider.id)) { + completion.detail = `(${provider.id}) ${completion.detail ?? ''}`; } completionItems.push(completion); } } - } + })); + }; + + if (triggeredProviders) { + // trigger characters were pressed, only get completions from those providers + console.log('triggered providers', triggeredProviders.map(p => p.id)); + await collectCompletions(triggeredProviders); + } else { + const allProviders = [...this._providers.values()].flatMap(providerMap => [...providerMap.values()]); + await collectCompletions(allProviders); } + return completionItems.length > 0 ? completionItems : undefined; } } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 71e9cb3e525..b8ff02f3db9 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -27,7 +27,7 @@ import { SimpleCompletionItem, ISimpleCompletion } from '../../../../services/su import { LineContext, SimpleCompletionModel } from '../../../../services/suggest/browser/simpleCompletionModel.js'; import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from '../../../../services/suggest/browser/simpleSuggestWidget.js'; import type { ISimpleSuggestWidgetFontInfo } from '../../../../services/suggest/browser/simpleSuggestWidgetRenderer.js'; -import { ITerminalCompletionService } from './terminalCompletionService.js'; +import { ITerminalCompletionProvider, ITerminalCompletionService } from './terminalCompletionService.js'; import { TerminalShellType } from '../../../../../platform/terminal/common/terminal.js'; export interface ISuggestController { @@ -115,7 +115,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest })); } - private async _handleCompletionProviders(terminal?: Terminal): Promise { + private async _handleCompletionProviders(terminal: Terminal | undefined, providers?: ITerminalCompletionProvider[]): Promise { // Nothing to handle if the terminal is not attached if (!terminal?.element || !this._enableWidget || !this._promptInputModel) { return; @@ -130,7 +130,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } this._requestedCompletionsIndex = this._promptInputModel.cursorIndex; - const providedCompletions = await this._terminalCompletionService.provideCompletions(this._promptInputModel.value, this._promptInputModel.cursorIndex, this._shellType); + const providedCompletions = await this._terminalCompletionService.provideCompletions(this._promptInputModel.value, this._promptInputModel.cursorIndex, this._shellType, providers); if (!providedCompletions?.length) { return; } @@ -196,7 +196,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._screen = screen; } - async requestCompletions(): Promise { + async requestCompletions(providers?: ITerminalCompletionProvider[]): Promise { if (!this._promptInputModel) { return; } @@ -205,7 +205,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } - await this._handleCompletionProviders(this._terminal); + await this._handleCompletionProviders(this._terminal, providers); } private _sync(promptInputState: IPromptInputModelState): void { @@ -246,7 +246,22 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this.requestCompletions(); sent = true; } - // TODO: eventually add an appropriate trigger char check for other shells + const providersToRequest: ITerminalCompletionProvider[] = []; + for (const provider of this._terminalCompletionService.providers) { + if (!provider.triggerCharacters) { + continue; + } + for (const char of provider.triggerCharacters) { + if (prefix?.endsWith(char)) { + providersToRequest.push(provider); + break; + } + } + if (providersToRequest.length > 0) { + this.requestCompletions(providersToRequest); + sent = true; + } + } } // #endregion } diff --git a/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts b/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts index f14a2d21326..cbabe591414 100644 --- a/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.terminalCompletionProvider.d.ts @@ -57,16 +57,6 @@ declare module 'vscode' { */ isKeyword?: boolean; - /** - * Whether a file completion should be provided upon accept of this completion item. - */ - fileArgument?: boolean; - - /** - * Whether a folder completion should be provided upon accept of this completion item. - */ - folderArgument?: boolean; - replacementIndex: number; replacementLength: number;