diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.test.ts b/extensions/terminal-suggest/src/terminalSuggestMain.test.ts index 0024b37b688..e98c71c6668 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.test.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.test.ts @@ -7,7 +7,7 @@ import { deepStrictEqual, strictEqual } from 'assert'; import 'mocha'; import { availableSpecs, getCompletionItemsFromSpecs } from './terminalSuggestMain'; -const availableCommands = ['cd', 'code', 'code-insiders']; +const availableCommands = ['cd', 'code', 'code-insiders', 'c++', 'c.test', 'dir', 'ls']; const codeOptions = ['-', '--add', '--category', '--diff', '--disable-extension', '--disable-extensions', '--disable-gpu', '--enable-proposed-api', '--extensions-dir', '--goto', '--help', '--inspect-brk-extensions', '--inspect-extensions', '--install-extension', '--list-extensions', '--locale', '--log', '--max-memory', '--merge', '--new-window', '--pre-release', '--prof-startup', '--profile', '--reuse-window', '--show-versions', '--status', '--sync', '--telemetry', '--uninstall-extension', '--user-data-dir', '--verbose', '--version', '--wait', '-a', '-d', '-g', '-h', '-m', '-n', '-r', '-s', '-v', '-w']; const localeOptions = ['bg', 'de', 'en', 'es', 'fr', 'hu', 'it', 'ja', 'ko', 'pt-br', 'ru', 'tr', 'zh-CN', 'zh-TW']; const categoryOptions = ['azure', 'data science', 'debuggers', 'extension packs', 'education', 'formatters', 'keymaps', 'language packs', 'linters', 'machine learning', 'notebooks', 'programming languages', 'scm providers', 'snippets', 'testing', 'themes', 'visualization', 'other']; @@ -15,16 +15,18 @@ const logOptions = ['critical', 'error', 'warn', 'info', 'debug', 'trace', 'off' const syncOptions = ['on', 'off']; const testSpecs: ITestSpec[] = [ - { input: '|', expectedCompletionLabels: availableCommands }, - { input: 'c|', expectedCompletionLabels: availableCommands }, - { input: 'ls && c|', expectedCompletionLabels: availableCommands }, + { input: '|', expectedCompletionLabels: availableCommands, resourcesRequested: 'both' }, + { input: 'c|', expectedCompletionLabels: availableCommands.filter(c => c.startsWith('c')) }, + { input: 'ls && c|', expectedCompletionLabels: availableCommands.filter(c => c.startsWith('c')) }, { input: 'cd |', expectedCompletionLabels: ['~', '-'], resourcesRequested: 'folders' }, - { input: 'code|', expectedCompletionLabels: ['code-insiders'] }, - { input: 'code-insiders|' }, + { input: 'cd .|', resourcesRequested: 'folders' }, + { input: 'cd ..|', resourcesRequested: 'folders' }, + { input: 'code|', expectedCompletionLabels: ['code', 'code-insiders'] }, + { input: 'code-insiders|', expectedCompletionLabels: ['code-insiders'] }, { input: 'code |', expectedCompletionLabels: codeOptions }, { input: 'code --locale |', expectedCompletionLabels: localeOptions }, { input: 'code --diff |', resourcesRequested: 'files' }, - { input: 'code -di|', expectedCompletionLabels: codeOptions.filter(o => o.startsWith('di')) }, + { input: 'code -di|', expectedCompletionLabels: codeOptions.filter(o => o.startsWith('di')), resourcesRequested: 'both' }, { input: 'code --diff ./file1 |', resourcesRequested: 'files' }, { input: 'code --merge |', resourcesRequested: 'files' }, { input: 'code --merge ./file1 ./file2 |', resourcesRequested: 'files' }, @@ -63,7 +65,7 @@ suite('Terminal Suggest', () => { test(testSpec.input, () => { const commandLine = testSpec.input.split('|')[0]; const cursorPosition = testSpec.input.indexOf('|'); - const prefix = commandLine.slice(0, cursorPosition).split(' ').pop() || ''; + const prefix = commandLine.slice(0, cursorPosition).split(' ').at(-1) || ''; const filesRequested = testSpec.resourcesRequested === 'files' || testSpec.resourcesRequested === 'both'; const foldersRequested = testSpec.resourcesRequested === 'folders' || testSpec.resourcesRequested === 'both'; const result = getCompletionItemsFromSpecs(availableSpecs, { commandLine, cursorPosition }, availableCommands, prefix); diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index a6e03d65dcd..d9483f728cd 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -88,51 +88,47 @@ export async function activate(context: vscode.ExtensionContext) { } const commands = [...commandsInPath, ...builtinCommands]; - const items: vscode.TerminalCompletionItem[] = []; const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition); - const specCompletions = await getCompletionItemsFromSpecs(availableSpecs, terminalContext, commands, prefix, token); - - items.push(...specCompletions.items); - let filesRequested = specCompletions.filesRequested; - let foldersRequested = specCompletions.foldersRequested; - - if (!specCompletions.specificSuggestionsProvided) { - for (const command of commands) { - if (command.startsWith(prefix) && !items.find(item => item.label === command)) { - items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command)); - } - } + const result = getCompletionItemsFromSpecs(availableSpecs, terminalContext, commands, prefix, token); + if (result.filesRequested || result.foldersRequested) { + const cwd = await resolveCwdFromPrefix(prefix, terminal.shellIntegration?.cwd) ?? terminal.shellIntegration?.cwd; + return new vscode.TerminalCompletionList(result.items, { filesRequested: result.filesRequested, foldersRequested: result.foldersRequested, cwd, pathSeparator: osIsWindows() ? '\\' : '/' }); } - - if (token.isCancellationRequested) { - return undefined; - } - - const shouldShowResourceCompletions = - ( - // If the command line is empty - terminalContext.commandLine.trim().length === 0 - // or no completions are found - || !items?.length - // or the completion found is '.' - || items.length === 1 && items[0].label === '.' - ) - // and neither files nor folders are going to be requested (for a specific spec's argument) - && (!filesRequested && !foldersRequested); - - if (shouldShowResourceCompletions) { - filesRequested = true; - foldersRequested = true; - } - if (filesRequested || foldersRequested) { - return new vscode.TerminalCompletionList(items, { filesRequested, foldersRequested, cwd: terminal.shellIntegration?.cwd, pathSeparator: osIsWindows() ? '\\' : '/' }); - } - return items; + return result.items; } - })); + }, '/', '\\')); } + +/** + * Adjusts the current working directory based on a given prefix if it is a folder. + * @param prefix - The folder path prefix. + * @param currentCwd - The current working directory. + * @returns The new working directory. + */ +export async function resolveCwdFromPrefix(prefix: string, currentCwd?: vscode.Uri): Promise { + if (!currentCwd) { + return; + } + try { + // Resolve the absolute path of the prefix + const resolvedPath = path.resolve(currentCwd?.fsPath, prefix); + + const stat = await fs.stat(resolvedPath); + // Check if the resolved path exists and is a directory + if (stat.isDirectory()) { + return currentCwd.with({ path: resolvedPath }); + } + } catch { + // Ignore errors + } + + // If the prefix is not a folder, return the current cwd + return currentCwd; +} + + function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[] | undefined { if (typeof spec === 'string') { return [spec]; @@ -146,12 +142,12 @@ function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[] return spec.name; } -function createCompletionItem(cursorPosition: number, prefix: string, label: string, description?: string, hasSpaceBeforeCursor?: boolean, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem { +function createCompletionItem(commandLine: string, cursorPosition: number, prefix: string, label: string, description?: string, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem { return { label, detail: description ?? '', - replacementIndex: hasSpaceBeforeCursor ? cursorPosition : cursorPosition - 1, - replacementLength: label.length - prefix.length, + replacementIndex: commandLine[cursorPosition - 1] === ' ' ? cursorPosition : cursorPosition - 1, + replacementLength: label.length - prefix.length > 0 ? label.length - prefix.length : label.length, kind: kind ?? vscode.TerminalCompletionItemKind.Method }; } @@ -215,10 +211,11 @@ export function asArray(x: T | T[]): T[] { return Array.isArray(x) ? x : [x]; } -export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { commandLine: string; cursorPosition: number }, availableCommands: string[], prefix: string, token?: vscode.CancellationToken): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; specificSuggestionsProvided: boolean } { +export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { commandLine: string; cursorPosition: number }, availableCommands: string[], prefix: string, token?: vscode.CancellationToken): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean } { const items: vscode.TerminalCompletionItem[] = []; let filesRequested = false; let foldersRequested = false; + let specificSuggestionsProvided = false; for (const spec of specs) { const specLabels = getLabel(spec); if (!specLabels) { @@ -233,10 +230,10 @@ export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: // If the prompt is empty !terminalContext.commandLine // or the prefix matches the command and the prefix is not equal to the command - || !!prefix && specLabel.startsWith(prefix) && specLabel !== prefix + || !!prefix && specLabel.startsWith(prefix) ) { // push it to the completion items - items.push(createCompletionItem(terminalContext.cursorPosition, prefix, specLabel)); + items.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, prefix, specLabel)); } if (!terminalContext.commandLine.startsWith(specLabel)) { // the spec label is not the first word in the command line, so do not provide options or args @@ -251,7 +248,7 @@ export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: } for (const optionLabel of optionLabels) { if (!items.find(i => i.label === optionLabel) && optionLabel.startsWith(prefix) || (prefix.length > specLabel.length && prefix.trim() === specLabel)) { - items.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag)); + items.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, prefix, optionLabel, option.description, vscode.TerminalCompletionItemKind.Flag)); } const expectedText = `${specLabel} ${optionLabel} `; if (!precedingText.includes(expectedText)) { @@ -263,8 +260,12 @@ export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: if (!argsCompletions) { continue; } - // return early so that we don't show the other completions - return argsCompletions; + specificSuggestionsProvided = true; + const argCompletions = argsCompletions.items; + foldersRequested = foldersRequested || argsCompletions.foldersRequested; + filesRequested = filesRequested || argsCompletions.filesRequested; + specificSuggestionsProvided = argsCompletions.specificSuggestionsProvided; + return { items: argCompletions, filesRequested, foldersRequested }; } } } @@ -280,12 +281,39 @@ export function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: continue; } items.push(...argsCompletions.items); + specificSuggestionsProvided = argsCompletions.specificSuggestionsProvided; filesRequested = filesRequested || argsCompletions.filesRequested; foldersRequested = foldersRequested || argsCompletions.foldersRequested; } } } - return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false }; + + if (!specificSuggestionsProvided) { + // Include builitin/available commands in the results + for (const command of availableCommands) { + if ((!terminalContext.commandLine.trim() || !!prefix) && command.startsWith(prefix) && !items.find(item => item.label === command)) { + items.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, prefix, command)); + } + } + } + + const shouldShowResourceCompletions = + ( + // If the command line is empty + terminalContext.commandLine.trim().length === 0 + // or no completions are found and the prefix is empty + || !items?.length + // or all of the items are '.' or '..' IE file paths + || items.length && items.every(i => ['.', '..'].includes(i.label)) + ) + // and neither files nor folders are going to be requested (for a specific spec's argument) + && (!filesRequested && !foldersRequested); + + if (shouldShowResourceCompletions) { + filesRequested = true; + foldersRequested = true; + } + return { items, filesRequested, foldersRequested }; } function getCompletionItemsFromArgs(args: Fig.SingleOrArray | undefined, currentPrefix: string, terminalContext: { commandLine: string; cursorPosition: number }, precedingText: string): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; specificSuggestionsProvided: boolean } | undefined { @@ -315,16 +343,18 @@ function getCompletionItemsFromArgs(args: Fig.SingleOrArray | undefined if (!suggestionLabels) { continue; } - + const twoWordsBefore = terminalContext.commandLine.slice(0, terminalContext.cursorPosition).split(' ').at(-2); + const wordBefore = terminalContext.commandLine.slice(0, terminalContext.cursorPosition).split(' ').at(-1); for (const suggestionLabel of suggestionLabels) { if (items.find(i => i.label === suggestionLabel)) { continue; } - if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim()) && suggestionLabel !== currentPrefix.trim()) { - const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' '; - // prefix will be '' if there is a space before the cursor + if (!arg.isVariadic && twoWordsBefore === suggestionLabel && wordBefore?.trim() === '') { + return { items: [], filesRequested, foldersRequested, specificSuggestionsProvided: false }; + } + if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) { const description = typeof suggestion !== 'string' ? suggestion.description : ''; - items.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, description, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument)); + items.push(createCompletionItem(terminalContext.commandLine, terminalContext.cursorPosition, precedingText, suggestionLabel, description, vscode.TerminalCompletionItemKind.Argument)); } } } @@ -339,3 +369,4 @@ function getCompletionItemsFromArgs(args: Fig.SingleOrArray | undefined function osIsWindows(): boolean { return os.platform() === 'win32'; } + diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index ee6b3a6bb35..8666054c460 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -5,6 +5,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; +import { basename } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -76,7 +77,7 @@ export interface ITerminalCompletionService { _serviceBrand: undefined; readonly providers: IterableIterator; registerTerminalCompletionProvider(extensionIdentifier: string, id: string, provider: ITerminalCompletionProvider, ...triggerCharacters: string[]): IDisposable; - provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, token: CancellationToken, triggerCharacter?: boolean): Promise; + provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean): Promise; } export class TerminalCompletionService extends Disposable implements ITerminalCompletionService { @@ -121,7 +122,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo }); } - async provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, token: CancellationToken, triggerCharacter?: boolean): Promise { + async provideCompletions(promptValue: string, cursorPosition: number, shellType: TerminalShellType, token: CancellationToken, triggerCharacter?: boolean, skipExtensionCompletions?: boolean): Promise { if (!this._providers || !this._providers.values) { return undefined; } @@ -146,7 +147,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo providers = [...this._providers.values()].flatMap(providerMap => [...providerMap.values()]); } - if (!extensionCompletionsEnabled) { + if (!extensionCompletionsEnabled || skipExtensionCompletions) { providers = providers.filter(p => p.isBuiltin); } @@ -200,17 +201,44 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo const resourceCompletions: ITerminalCompletion[] = []; const parentDirPath = cwd.fsPath.split(resourceRequestConfig.pathSeparator).slice(0, -1).join(resourceRequestConfig.pathSeparator); - const parentCwd = URI.from({ scheme: cwd.scheme, path: parentDirPath }); const dirToPrefixMap = new Map(); dirToPrefixMap.set(cwd, '.'); - dirToPrefixMap.set(parentCwd, '..'); + dirToPrefixMap.set(cwd.with({ path: parentDirPath }), '..'); - const lastWord = promptValue.substring(0, cursorPosition).split(' ').pop() ?? ''; + const endsWithSpace = promptValue.substring(0, cursorPosition).endsWith(' '); + let lastWord; + if (endsWithSpace) { + lastWord = ''; + } else { + lastWord = promptValue.substring(0, cursorPosition).trim().split(' ').pop() ?? ''; + } + if (lastWord.includes(basename(cwd.fsPath))) { + lastWord = ''; + } + + // This breaks folder completions because has a different replacement index + // which results in replacement index being set to 0 + // const includeDirs = endsWithSpace || lastWord.match(/.*[\\\/]/); + // if (includeDirs) { + // resourceCompletions.push({ + // label: '.' + resourceRequestConfig.pathSeparator, + // kind: TerminalCompletionItemKind.Folder, + // isDirectory: true, + // replacementIndex: cursorPosition, + // replacementLength: 2 + // }); + // resourceCompletions.push({ + // label: '..' + resourceRequestConfig.pathSeparator, + // kind: TerminalCompletionItemKind.Folder, + // isDirectory: true, + // replacementIndex: cursorPosition, + // replacementLength: 3 + // }); + // } for (const [dir, prefix] of dirToPrefixMap) { const fileStat = await this._fileService.resolve(dir, { resolveSingleChildDescendants: true }); - if (!fileStat || !fileStat?.children) { return; } @@ -226,12 +254,12 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo if (kind === undefined) { continue; } - - const label = prefix + stat.resource.fsPath.replace(dir.fsPath, ''); + const isDirectory = kind === TerminalCompletionItemKind.Folder; + const label = isDirectory ? prefix + stat.resource.fsPath.replace(dir.fsPath, '') + resourceRequestConfig.pathSeparator : prefix + stat.resource.fsPath.replace(dir.fsPath, ''); resourceCompletions.push({ label, kind, - isDirectory: kind === TerminalCompletionItemKind.Folder, + isDirectory, isFile: kind === TerminalCompletionItemKind.File, replacementIndex: cursorPosition - lastWord.length, replacementLength: label.length diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 57812e50451..4594a1b60a8 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -69,6 +69,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest private _lastUserData?: string; static lastAcceptedCompletionTimestamp: number = 0; + private _lastUserDataTimestamp: number = 0; private _cancellationTokenSource: CancellationTokenSource | undefined; @@ -124,6 +125,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._terminal = xterm; this._register(xterm.onData(async e => { this._lastUserData = e; + this._lastUserDataTimestamp = Date.now(); })); } @@ -142,12 +144,19 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest return; } + let doNotRequestExtensionCompletions = false; + // Ensure that a key has been pressed since the last accepted completion in order to prevent + // completions being requested again right after accepting a completion + if (this._lastUserDataTimestamp < SuggestAddon.lastAcceptedCompletionTimestamp) { + doNotRequestExtensionCompletions = true; + } + const enableExtensionCompletions = this._configurationService.getValue(terminalSuggestConfigSection).enableExtensionCompletions; - if (enableExtensionCompletions) { + if (enableExtensionCompletions && !doNotRequestExtensionCompletions) { await this._extensionService.activateByEvent('onTerminalCompletionsRequested'); } - const providedCompletions = await this._terminalCompletionService.provideCompletions(this._promptInputModel.value, this._promptInputModel.cursorIndex, this._shellType, token, triggerCharacter); + const providedCompletions = await this._terminalCompletionService.provideCompletions(this._promptInputModel.value, this._promptInputModel.cursorIndex, this._shellType, token, triggerCharacter, doNotRequestExtensionCompletions); if (!providedCompletions?.length || token.isCancellationRequested) { return; } @@ -214,8 +223,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._showCompletions(model); } - - setContainerWithOverflow(container: HTMLElement): void { this._container = container; }