Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve folder/file terminal completions #234363

Merged
merged 19 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/terminal-suggest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@

## Features

Provides terminal suggestions for zsh, bash, and fish.
Provides terminal suggestions for zsh, bash, fish, and pwsh.
30 changes: 30 additions & 0 deletions extensions/terminal-suggest/src/completions/cd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*---------------------------------------------------------------------------------------------
* 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,

// Add an additional hidden suggestion so users can execute on it if they want to
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
suggestions: [
{
name: '-',
description: 'Switch to the last used folder',
hidden: true,
},
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
{
name: '~',
description: 'Switch to the home directory',
hidden: true,
},
],
}
};

export default cdSpec;
173 changes: 96 additions & 77 deletions extensions/terminal-suggest/src/terminalSuggestMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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': {
Expand Down Expand Up @@ -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) {
Expand All @@ -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',
Expand All @@ -87,13 +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);

let filesRequested = specCompletions.filesRequested;
let foldersRequested = specCompletions.foldersRequested;
items.push(...specCompletions.items);

if (!specCompletions.specificSuggestionsProvided) {
for (const command of commands) {
if (command.startsWith(prefix)) {
Expand All @@ -106,26 +107,17 @@ 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;

// If no completions are found, the prefix is a path, and neither files nor folders
// If the command line is empty, no completions are found, or the completion found is '.', 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;
const shouldShowResourceCompletions = (terminalContext.commandLine.trim().length === 0 || !items?.length || items.length === 1 && items[0].label === '.') && (!filesRequested && !foldersRequested);
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
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: shellPath.includes('/') ? '/' : '\\' });
}
return resultItems;
return items;
}
}));
}
Expand Down Expand Up @@ -213,7 +205,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) {
Expand All @@ -222,73 +214,100 @@ 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) {
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 precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1);
const expectedText = `${specLabel} ${optionLabel} `;
if (!precedingText.includes(expectedText)) {
continue;
}
const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText);
const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length);
const argsCompletions = getCompletionItemsFromArgs(option.args, currentPrefix, terminalContext, precedingText);
if (!argsCompletions) {
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 };
}
}
}
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 precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1);
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
items.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, arg.name, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument));
}
}
}
if (items.length) {
return { items, filesRequested, foldersRequested, specificSuggestionsProvided: true };
}
}
}
return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false };
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,44 +197,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>();

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, '');
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;
}

resourceCompletions.push({
label,
kind,
isDirectory: kind === TerminalCompletionItemKind.Folder,
isFile: kind === TerminalCompletionItemKind.File,
replacementIndex: cursorPosition,
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')) {
meganrogge marked this conversation as resolved.
Show resolved Hide resolved
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
});
}
}

return resourceCompletions.length ? resourceCompletions : undefined;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading