improve folder/file terminal completions (#234363)
parent
143f300a31
commit
f2fba1b45d
|
@ -4,4 +4,4 @@
|
|||
|
||||
## Features
|
||||
|
||||
Provides terminal suggestions for zsh, bash, and fish.
|
||||
Provides terminal suggestions for zsh, bash, fish, and pwsh.
|
||||
|
|
|
@ -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;
|
|
@ -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<string> | undefined;
|
||||
let cachedBuiltinCommands: Map<string, string[]> | 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<string, vscode.TerminalCompletionItem>();
|
||||
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<Set<string> | 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<T>(x: T | T[]): T[] {
|
|||
}
|
||||
|
||||
function getCompletionItemsFromSpecs(specs: Fig.Spec[], terminalContext: { commandLine: string; cursorPosition: number }, availableCommands: Set<string>, 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<Fig.Arg> | 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';
|
||||
}
|
||||
|
|
|
@ -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<URI, string>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ export interface ITerminalSuggestConfiguration {
|
|||
export const terminalSuggestConfiguration: IStringDictionary<IConfigurationPropertySchema> = {
|
||||
[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'],
|
||||
|
|
Loading…
Reference in New Issue