diff --git a/extensions/terminal-suggest/README.md b/extensions/terminal-suggest/README.md index d93e4620567..adaffc410ac 100644 --- a/extensions/terminal-suggest/README.md +++ b/extensions/terminal-suggest/README.md @@ -4,4 +4,4 @@ ## Features -Provides terminal suggestions for zsh, bash, and fish. +Provides terminal suggestions for zsh, bash, fish, and pwsh. diff --git a/extensions/terminal-suggest/src/completions/cd.ts b/extensions/terminal-suggest/src/completions/cd.ts new file mode 100644 index 00000000000..89fc633911b --- /dev/null +++ b/extensions/terminal-suggest/src/completions/cd.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const cdSpec: Fig.Spec = { + name: 'cd', + description: 'Change the shell working directory', + args: { + name: 'folder', + template: 'folders', + isVariadic: true, + + suggestions: [ + { + name: '-', + description: 'Switch to the last used folder', + hidden: true, + }, + { + name: '~', + description: 'Switch to the home directory', + hidden: true, + }, + ], + } +}; + +export default cdSpec; diff --git a/extensions/terminal-suggest/src/terminalSuggestMain.ts b/extensions/terminal-suggest/src/terminalSuggestMain.ts index a8d537334e7..1ff88ed9eb6 100644 --- a/extensions/terminal-suggest/src/terminalSuggestMain.ts +++ b/extensions/terminal-suggest/src/terminalSuggestMain.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import { ExecOptionsWithStringEncoding, execSync } from 'child_process'; import codeInsidersCompletionSpec from './completions/code-insiders'; import codeCompletionSpec from './completions/code'; +import cdSpec from './completions/cd'; let cachedAvailableCommands: Set | undefined; let cachedBuiltinCommands: Map | undefined; @@ -20,8 +21,7 @@ function getBuiltinCommands(shell: string): string[] | undefined { if (cachedCommands) { return cachedCommands; } - // fixes a bug with file/folder completions brought about by the '.' command - const filter = (cmd: string) => cmd && cmd !== '.'; + const filter = (cmd: string) => cmd; const options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell }; switch (shellType) { case 'bash': { @@ -52,8 +52,11 @@ function getBuiltinCommands(shell: string): string[] | undefined { } break; } + case 'pwsh': { + // native pwsh completions are builtin to vscode + return []; + } } - // native pwsh completions are builtin to vscode return; } catch (error) { @@ -62,7 +65,6 @@ function getBuiltinCommands(shell: string): string[] | undefined { } } - export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({ id: 'terminal-suggest', @@ -87,12 +89,12 @@ export async function activate(context: vscode.ExtensionContext) { const items: vscode.TerminalCompletionItem[] = []; const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition); - const specs = [codeCompletionSpec, codeInsidersCompletionSpec]; + const specs = [codeCompletionSpec, codeInsidersCompletionSpec, cdSpec]; const specCompletions = await getCompletionItemsFromSpecs(specs, terminalContext, new Set(commands), prefix, token); + items.push(...specCompletions.items); let filesRequested = specCompletions.filesRequested; let foldersRequested = specCompletions.foldersRequested; - items.push(...specCompletions.items); if (!specCompletions.specificSuggestionsProvided) { for (const command of commands) { @@ -106,26 +108,26 @@ export async function activate(context: vscode.ExtensionContext) { return undefined; } - const uniqueResults = new Map(); - for (const item of items) { - if (!uniqueResults.has(item.label)) { - uniqueResults.set(item.label, item); - } - } - const resultItems = uniqueResults.size ? Array.from(uniqueResults.values()) : 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 no completions are found, the prefix is a path, and neither files nor folders - // are going to be requested (for a specific spec's argument), show file/folder completions - const shouldShowResourceCompletions = !resultItems?.length && prefix.match(/^[./\\ ]/) && !filesRequested && !foldersRequested; if (shouldShowResourceCompletions) { filesRequested = true; foldersRequested = true; } - if (filesRequested || foldersRequested) { - return new vscode.TerminalCompletionList(resultItems, { filesRequested, foldersRequested, cwd: terminal.shellIntegration?.cwd, pathSeparator: shellPath.includes('/') ? '/' : '\\' }); + return new vscode.TerminalCompletionList(items, { filesRequested, foldersRequested, cwd: terminal.shellIntegration?.cwd, pathSeparator: osIsWindows() ? '\\' : '/' }); } - return resultItems; + return items; } })); } @@ -157,7 +159,7 @@ async function getCommandsInPath(): Promise | undefined> { if (cachedAvailableCommands) { return cachedAvailableCommands; } - const paths = os.platform() === 'win32' ? process.env.PATH?.split(';') : process.env.PATH?.split(':'); + const paths = osIsWindows() ? process.env.PATH?.split(';') : process.env.PATH?.split(':'); if (!paths) { return; } @@ -213,7 +215,7 @@ export function asArray(x: T | T[]): T[] { } function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { commandLine: string; cursorPosition: number }, availableCommands: Set, prefix: string, token: vscode.CancellationToken): { items: vscode.TerminalCompletionItem[]; filesRequested: boolean; foldersRequested: boolean; specificSuggestionsProvided: boolean } { - let items: vscode.TerminalCompletionItem[] = []; + const items: vscode.TerminalCompletionItem[] = []; let filesRequested = false; let foldersRequested = false; for (const spec of specs) { @@ -222,73 +224,105 @@ function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { comma continue; } for (const specLabel of specLabels) { - if (!availableCommands.has(specLabel) || token.isCancellationRequested) { + if (!availableCommands.has(specLabel) || token.isCancellationRequested || !terminalContext.commandLine.startsWith(specLabel)) { continue; } - if (terminalContext.commandLine.startsWith(specLabel)) { - if ('options' in spec && spec.options) { - for (const option of spec.options) { - const optionLabels = getLabel(option); - if (!optionLabels) { + const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1); + if ('options' in spec && spec.options) { + for (const option of spec.options) { + const optionLabels = getLabel(option); + if (!optionLabels) { + continue; + } + for (const optionLabel of optionLabels) { + if (optionLabel.startsWith(prefix) || (prefix.length > specLabel.length && prefix.trim() === specLabel)) { + items.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag)); + } + const expectedText = `${specLabel} ${optionLabel} `; + if (!precedingText.includes(expectedText)) { continue; } - for (const optionLabel of optionLabels) { - if (optionLabel.startsWith(prefix) || (prefix.length > specLabel.length && prefix.trim() === specLabel)) { - items.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag)); - } - if (!option.args) { - continue; - } - const args = asArray(option.args); - for (const arg of args) { - if (!arg) { - continue; - } - const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1); - const expectedText = `${specLabel} ${optionLabel} `; - if (!precedingText.includes(expectedText)) { - continue; - } - if (arg.template) { - if (arg.template === 'filepaths') { - if (precedingText.includes(expectedText)) { - filesRequested = true; - } - } else if (arg.template === 'folders') { - if (precedingText.includes(expectedText)) { - foldersRequested = true; - } - } - } - if (arg.suggestions?.length) { - // there are specific suggestions to show - items = []; - const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText); - const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length); - for (const suggestion of arg.suggestions) { - const suggestionLabels = getLabel(suggestion); - if (!suggestionLabels) { - continue; - } - for (const suggestionLabel of suggestionLabels) { - if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) { - const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' '; - // prefix will be '' if there is a space before the cursor - items.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, arg.name, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument)); - } - } - } - if (items.length) { - return { items, filesRequested, foldersRequested, specificSuggestionsProvided: true }; - } - } - } + const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText); + const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length); + const argsCompletions = getCompletionItemsFromArgs(option.args, currentPrefix, terminalContext, precedingText); + if (!argsCompletions) { + continue; } + if (argsCompletions.specificSuggestionsProvided) { + // prevents the list from containing a bunch of other stuff + return argsCompletions; + } + items.push(...argsCompletions.items); + filesRequested = filesRequested || argsCompletions.filesRequested; + foldersRequested = foldersRequested || argsCompletions.foldersRequested; } } } + if ('args' in spec && asArray(spec.args)) { + const expectedText = `${specLabel} `; + if (!precedingText.includes(expectedText)) { + continue; + } + const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText); + const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length); + const argsCompletions = getCompletionItemsFromArgs(spec.args, currentPrefix, terminalContext, precedingText); + if (!argsCompletions) { + continue; + } + items.push(...argsCompletions.items); + filesRequested = filesRequested || argsCompletions.filesRequested; + foldersRequested = foldersRequested || argsCompletions.foldersRequested; + } } } return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false }; } +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 { + if (!args) { + return; + } + + let items: vscode.TerminalCompletionItem[] = []; + let filesRequested = false; + let foldersRequested = false; + for (const arg of asArray(args)) { + if (!arg) { + continue; + } + if (arg.template) { + if (arg.template === 'filepaths') { + filesRequested = true; + } else if (arg.template === 'folders') { + foldersRequested = true; + } + } + if (arg.suggestions?.length) { + // there are specific suggestions to show + items = []; + for (const suggestion of arg.suggestions) { + const suggestionLabels = getLabel(suggestion); + if (!suggestionLabels) { + continue; + } + + for (const suggestionLabel of suggestionLabels) { + if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) { + const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' '; + // prefix will be '' if there is a space before the cursor + const description = typeof suggestion !== 'string' ? suggestion.description : ''; + items.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, description, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument)); + } + } + } + if (items.length) { + return { items, filesRequested, foldersRequested, specificSuggestionsProvided: true }; + } + } + } + return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false }; +} + +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 4e8379c847f..bee024edb19 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -4,6 +4,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 { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -197,44 +198,47 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo } const resourceCompletions: ITerminalCompletion[] = []; - const fileStat = await this._fileService.resolve(cwd, { resolveSingleChildDescendants: true }); - if (!fileStat || !fileStat?.children) { - return; + 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, '..'); + + const lastWord = promptValue.substring(0, cursorPosition).split(' ').pop() ?? ''; + + for (const [dir, prefix] of dirToPrefixMap) { + const fileStat = await this._fileService.resolve(dir, { resolveSingleChildDescendants: true }); + + if (!fileStat || !fileStat?.children) { + return; + } + + for (const stat of fileStat.children) { + let kind: TerminalCompletionItemKind | undefined; + if (foldersRequested && stat.isDirectory) { + kind = TerminalCompletionItemKind.Folder; + } + if (filesRequested && !stat.isDirectory && (stat.isFile || stat.resource.scheme === Schemas.file)) { + kind = TerminalCompletionItemKind.File; + } + if (kind === undefined) { + continue; + } + + const label = prefix + stat.resource.fsPath.replace(cwd.fsPath, ''); + resourceCompletions.push({ + label, + kind, + isDirectory: kind === TerminalCompletionItemKind.Folder, + isFile: kind === TerminalCompletionItemKind.File, + replacementIndex: cursorPosition - lastWord.length, + replacementLength: label.length + }); + } } - for (const stat of fileStat.children) { - let kind: TerminalCompletionItemKind | undefined; - if (foldersRequested && stat.isDirectory) { - kind = TerminalCompletionItemKind.Folder; - } - if (filesRequested && !stat.isDirectory && (stat.isFile || stat.resource.scheme === 'file')) { - kind = TerminalCompletionItemKind.File; - } - if (kind === undefined) { - continue; - } - const lastWord = promptValue.substring(0, cursorPosition).split(' ').pop(); - const lastIndexOfDot = lastWord?.lastIndexOf('.') ?? -1; - const lastIndexOfSlash = lastWord?.lastIndexOf(resourceRequestConfig.pathSeparator) ?? -1; - let label; - if (lastIndexOfSlash > -1) { - label = stat.resource.fsPath.replace(cwd.fsPath, '').substring(1); - } else if (lastIndexOfDot === -1) { - label = '.' + stat.resource.fsPath.replace(cwd.fsPath, ''); - } else { - label = stat.resource.fsPath.replace(cwd.fsPath, ''); - } - - resourceCompletions.push({ - label, - kind, - isDirectory: kind === TerminalCompletionItemKind.Folder, - isFile: kind === TerminalCompletionItemKind.File, - replacementIndex: cursorPosition, - replacementLength: label.length - }); - } return resourceCompletions.length ? resourceCompletions : undefined; } } diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts index 0779fb33879..860bb28015a 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/common/terminalSuggestConfiguration.ts @@ -34,7 +34,7 @@ export interface ITerminalSuggestConfiguration { export const terminalSuggestConfiguration: IStringDictionary = { [TerminalSuggestSettingId.Enabled]: { restricted: true, - markdownDescription: localize('suggest.enabled', "Enables experimental terminal Intellisense suggestions for supported shells ({0}) when {1} is set to {2}.\n\nIf shell integration is installed manually, {3} needs to be set to {4} before calling the shell integration script. \n\nFor zsh and bash completions, {5} will also need to be set.", 'PowerShell v7+, zsh, bash', `\`#${TerminalSettingId.ShellIntegrationEnabled}#\``, '`true`', '`VSCODE_SUGGEST`', '`1`', `\`#${TerminalSuggestSettingId.EnableExtensionCompletions}#\``), + markdownDescription: localize('suggest.enabled', "Enables experimental terminal Intellisense suggestions for supported shells ({0}) when {1} is set to {2}.\n\nIf shell integration is installed manually, {3} needs to be set to {4} before calling the shell integration script. \n\nFor extension provided completions, {5} will also need to be set.", 'PowerShell v7+, zsh, bash, fish', `\`#${TerminalSettingId.ShellIntegrationEnabled}#\``, '`true`', '`VSCODE_SUGGEST`', '`1`', `\`#${TerminalSuggestSettingId.EnableExtensionCompletions}#\``), type: 'boolean', default: false, tags: ['experimental'],