Skip to content

Commit

Permalink
add support for file/folder terminal completions (#234289)
Browse files Browse the repository at this point in the history
  • Loading branch information
meganrogge authored and osortega committed Nov 26, 2024
1 parent d190300 commit 78eed5d
Show file tree
Hide file tree
Showing 8 changed files with 334 additions and 107 deletions.
207 changes: 127 additions & 80 deletions extensions/terminal-suggest/src/terminalSuggestMain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ 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 options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell };
switch (shellType) {
case 'bash': {
const bashOutput = execSync('compgen -b', options);
const bashResult = bashOutput.split('\n').filter(cmd => cmd);
const bashResult = bashOutput.split('\n').filter(filter);
if (bashResult.length) {
cachedBuiltinCommands?.set(shellType, bashResult);
return bashResult;
Expand All @@ -33,7 +35,7 @@ function getBuiltinCommands(shell: string): string[] | undefined {
}
case 'zsh': {
const zshOutput = execSync('printf "%s\\n" ${(k)builtins}', options);
const zshResult = zshOutput.split('\n').filter(cmd => cmd);
const zshResult = zshOutput.split('\n').filter(filter);
if (zshResult.length) {
cachedBuiltinCommands?.set(shellType, zshResult);
return zshResult;
Expand All @@ -43,7 +45,7 @@ function getBuiltinCommands(shell: string): string[] | undefined {
// TODO: ghost text in the command line prevents
// completions from working ATM for fish
const fishOutput = execSync('functions -n', options);
const fishResult = fishOutput.split(', ').filter(cmd => cmd);
const fishResult = fishOutput.split(', ').filter(filter);
if (fishResult.length) {
cachedBuiltinCommands?.set(shellType, fishResult);
return fishResult;
Expand All @@ -64,122 +66,81 @@ function getBuiltinCommands(shell: string): string[] | undefined {
export async function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({
id: 'terminal-suggest',
async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: { commandLine: string; cursorPosition: number }, token: vscode.CancellationToken): Promise<vscode.TerminalCompletionItem[] | undefined> {
async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: { commandLine: string; cursorPosition: number }, token: vscode.CancellationToken): Promise<vscode.TerminalCompletionItem[] | vscode.TerminalCompletionList | undefined> {
if (token.isCancellationRequested) {
return;
}

const availableCommands = await getCommandsInPath();
if (!availableCommands) {
return;
}

// TODO: Leverage shellType when available https://github.com/microsoft/vscode/issues/230165
const shellPath = 'shellPath' in terminal.creationOptions ? terminal.creationOptions.shellPath : vscode.env.shell;
if (!shellPath) {
return;
}

const commandsInPath = await getCommandsInPath();
const builtinCommands = getBuiltinCommands(shellPath);
builtinCommands?.forEach(command => availableCommands.add(command));
if (!commandsInPath || !builtinCommands) {
return;
}
const commands = [...commandsInPath, ...builtinCommands];

const items: vscode.TerminalCompletionItem[] = [];
const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition);
let result: vscode.TerminalCompletionItem[] = [];
const specs = [codeCompletionSpec, codeInsidersCompletionSpec];
for (const spec of specs) {
const specName = getLabel(spec);
if (!specName || !availableCommands.has(specName)) {
continue;
}
if (terminalContext.commandLine.startsWith(specName)) {
if ('options' in codeInsidersCompletionSpec && codeInsidersCompletionSpec.options) {
for (const option of codeInsidersCompletionSpec.options) {
const optionLabel = getLabel(option);
if (!optionLabel) {
continue;
}

if (optionLabel.startsWith(prefix) || (prefix.length > specName.length && prefix.trim() === specName)) {
result.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag));
}
if (option.args !== undefined) {
const args = Array.isArray(option.args) ? option.args : [option.args];
for (const arg of args) {
if (!arg) {
continue;
}
const specs = [codeCompletionSpec, codeInsidersCompletionSpec];
const specCompletions = await getCompletionItemsFromSpecs(specs, terminalContext, new Set(commands), prefix, token);

if (arg.template) {
// TODO: return file/folder completion items
if (arg.template === 'filepaths') {
// if (label.startsWith(prefix+\s*)) {
// result.push(FilePathCompletionItem)
// }
} else if (arg.template === 'folders') {
// if (label.startsWith(prefix+\s*)) {
// result.push(FolderPathCompletionItem)
// }
}
continue;
}
let filesRequested = specCompletions.filesRequested;
let foldersRequested = specCompletions.foldersRequested;
items.push(...specCompletions.items);

const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition);
const expectedText = `${optionLabel} `;
if (arg.suggestions?.length && precedingText.includes(expectedText)) {
// there are specific suggestions to show
result = [];
const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText);
const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length);
for (const suggestion of arg.suggestions) {
const suggestionLabel = getLabel(suggestion);
if (suggestionLabel && suggestionLabel.startsWith(currentPrefix)) {
const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' ';
// prefix will be '' if there is a space before the cursor
result.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, arg.name, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument));
}
}
if (result.length) {
return result;
}
}
}
}
}
if (!specCompletions.specificSuggestionsProvided) {
for (const command of commands) {
if (command.startsWith(prefix)) {
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
}
}
}

for (const command of availableCommands) {
if (command.startsWith(prefix)) {
result.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
}
}

if (token.isCancellationRequested) {
return undefined;
}

const uniqueResults = new Map<string, vscode.TerminalCompletionItem>();
for (const item of result) {
for (const item of items) {
if (!uniqueResults.has(item.label)) {
uniqueResults.set(item.label, item);
}
}
return uniqueResults.size ? Array.from(uniqueResults.values()) : undefined;
const resultItems = uniqueResults.size ? Array.from(uniqueResults.values()) : undefined;

// 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 resultItems;
}
}));
}

function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string | undefined {
function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[] | undefined {
if (typeof spec === 'string') {
return spec;
return [spec];
}
if (typeof spec.name === 'string') {
return spec.name;
return [spec.name];
}
if (!Array.isArray(spec.name) || spec.name.length === 0) {
return;
}
return spec.name[0];
return spec.name;
}

function createCompletionItem(cursorPosition: number, prefix: string, label: string, description?: string, hasSpaceBeforeCursor?: boolean, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem {
Expand Down Expand Up @@ -245,3 +206,89 @@ function getPrefix(commandLine: string, cursorPosition: number): string {
return match ? match[0] : '';
}

export function asArray<T>(x: T | T[]): T[];
export function asArray<T>(x: T | readonly T[]): readonly T[];
export function asArray<T>(x: T | T[]): T[] {
return Array.isArray(x) ? x : [x];
}

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[] = [];
let filesRequested = false;
let foldersRequested = false;
for (const spec of specs) {
const specLabels = getLabel(spec);
if (!specLabels) {
continue;
}
for (const specLabel of specLabels) {
if (!availableCommands.has(specLabel) || token.isCancellationRequested) {
continue;
}
if (terminalContext.commandLine.startsWith(specLabel)) {
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));
}
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 };
}
}
}
}
}
}
}
}
}
return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false };
}

1 change: 1 addition & 0 deletions src/vs/workbench/api/common/extHost.api.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1667,6 +1667,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
TerminalShellExecutionCommandLineConfidence: extHostTypes.TerminalShellExecutionCommandLineConfidence,
TerminalCompletionItem: extHostTypes.TerminalCompletionItem,
TerminalCompletionItemKind: extHostTypes.TerminalCompletionItemKind,
TerminalCompletionList: extHostTypes.TerminalCompletionList,
TextDocumentSaveReason: extHostTypes.TextDocumentSaveReason,
TextEdit: extHostTypes.TextEdit,
SnippetTextEdit: extHostTypes.SnippetTextEdit,
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/api/common/extHost.protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from '../../servic
import * as search from '../../services/search/common/search.js';
import { TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js';
import { ISaveProfileResult } from '../../services/userDataProfile/common/userDataProfile.js';
import { TerminalCompletionItem, TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
import { TerminalCompletionItem, TerminalCompletionList, TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
import * as tasks from './shared/tasks.js';

export interface IWorkspaceData extends IStaticWorkspaceData {
Expand Down Expand Up @@ -2430,7 +2430,7 @@ export interface ExtHostTerminalServiceShape {
$acceptDefaultProfile(profile: ITerminalProfile, automationProfile: ITerminalProfile): void;
$createContributedProfileTerminal(id: string, options: ICreateContributedTerminalProfileOptions): Promise<void>;
$provideTerminalQuickFixes(id: string, matchResult: TerminalCommandMatchResultDto, token: CancellationToken): Promise<SingleOrMany<TerminalQuickFix> | undefined>;
$provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto, token: CancellationToken): Promise<TerminalCompletionItem[] | undefined>;
$provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto, token: CancellationToken): Promise<TerminalCompletionItem[] | TerminalCompletionList | undefined>;
}

export interface ExtHostTerminalShellIntegrationShape {
Expand Down
4 changes: 2 additions & 2 deletions src/vs/workbench/api/common/extHostTerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, ID
getEnvironmentVariableCollection(extension: IExtensionDescription): IEnvironmentVariableCollection;
getTerminalById(id: number): ExtHostTerminal | null;
getTerminalIdByApiObject(apiTerminal: vscode.Terminal): number | null;
registerTerminalCompletionProvider<T extends vscode.TerminalCompletionItem[]>(extension: IExtensionDescription, provider: vscode.TerminalCompletionProvider<vscode.TerminalCompletionItem>, ...triggerCharacters: string[]): vscode.Disposable;
registerTerminalCompletionProvider(extension: IExtensionDescription, provider: vscode.TerminalCompletionProvider<vscode.TerminalCompletionItem>, ...triggerCharacters: string[]): vscode.Disposable;
}

interface IEnvironmentVariableCollection extends vscode.EnvironmentVariableCollection {
Expand Down Expand Up @@ -746,7 +746,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I
});
}

public async $provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto): Promise<vscode.TerminalCompletionItem[] | undefined> {
public async $provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto): Promise<vscode.TerminalCompletionItem[] | vscode.TerminalCompletionList | undefined> {
const token = new CancellationTokenSource().token;
if (token.isCancellationRequested || !this.activeTerminal) {
return undefined;
Expand Down
35 changes: 35 additions & 0 deletions src/vs/workbench/api/common/extHostTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2145,6 +2145,41 @@ export class TerminalCompletionItem implements vscode.TerminalCompletionItem {
}


/**
* Represents a collection of {@link CompletionItem completion items} to be presented
* in the editor.
*/
export class TerminalCompletionList<T extends TerminalCompletionItem = TerminalCompletionItem> {

/**
* Resources should be shown in the completions list
*/
resourceRequestConfig?: TerminalResourceRequestConfig;

/**
* The completion items.
*/
items: T[];

/**
* Creates a new completion list.
*
* @param items The completion items.
* @param isIncomplete The list is not complete.
*/
constructor(items?: T[], resourceRequestConfig?: TerminalResourceRequestConfig) {
this.items = items ?? [];
this.resourceRequestConfig = resourceRequestConfig;
}
}

export interface TerminalResourceRequestConfig {
filesRequested?: boolean;
foldersRequested?: boolean;
cwd?: vscode.Uri;
pathSeparator: string;
}

export enum TaskRevealKind {
Always = 1,

Expand Down
Loading

0 comments on commit 78eed5d

Please sign in to comment.