Skip to content

Commit d79858c

Browse files
authored
add support for file/folder terminal completions (microsoft#234289)
1 parent ee21e63 commit d79858c

File tree

8 files changed

+334
-107
lines changed

8 files changed

+334
-107
lines changed

extensions/terminal-suggest/src/terminalSuggestMain.ts

Lines changed: 127 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ function getBuiltinCommands(shell: string): string[] | undefined {
2020
if (cachedCommands) {
2121
return cachedCommands;
2222
}
23+
// fixes a bug with file/folder completions brought about by the '.' command
24+
const filter = (cmd: string) => cmd && cmd !== '.';
2325
const options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell };
2426
switch (shellType) {
2527
case 'bash': {
2628
const bashOutput = execSync('compgen -b', options);
27-
const bashResult = bashOutput.split('\n').filter(cmd => cmd);
29+
const bashResult = bashOutput.split('\n').filter(filter);
2830
if (bashResult.length) {
2931
cachedBuiltinCommands?.set(shellType, bashResult);
3032
return bashResult;
@@ -33,7 +35,7 @@ function getBuiltinCommands(shell: string): string[] | undefined {
3335
}
3436
case 'zsh': {
3537
const zshOutput = execSync('printf "%s\\n" ${(k)builtins}', options);
36-
const zshResult = zshOutput.split('\n').filter(cmd => cmd);
38+
const zshResult = zshOutput.split('\n').filter(filter);
3739
if (zshResult.length) {
3840
cachedBuiltinCommands?.set(shellType, zshResult);
3941
return zshResult;
@@ -43,7 +45,7 @@ function getBuiltinCommands(shell: string): string[] | undefined {
4345
// TODO: ghost text in the command line prevents
4446
// completions from working ATM for fish
4547
const fishOutput = execSync('functions -n', options);
46-
const fishResult = fishOutput.split(', ').filter(cmd => cmd);
48+
const fishResult = fishOutput.split(', ').filter(filter);
4749
if (fishResult.length) {
4850
cachedBuiltinCommands?.set(shellType, fishResult);
4951
return fishResult;
@@ -64,122 +66,81 @@ function getBuiltinCommands(shell: string): string[] | undefined {
6466
export async function activate(context: vscode.ExtensionContext) {
6567
context.subscriptions.push(vscode.window.registerTerminalCompletionProvider({
6668
id: 'terminal-suggest',
67-
async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: { commandLine: string; cursorPosition: number }, token: vscode.CancellationToken): Promise<vscode.TerminalCompletionItem[] | undefined> {
69+
async provideTerminalCompletions(terminal: vscode.Terminal, terminalContext: { commandLine: string; cursorPosition: number }, token: vscode.CancellationToken): Promise<vscode.TerminalCompletionItem[] | vscode.TerminalCompletionList | undefined> {
6870
if (token.isCancellationRequested) {
6971
return;
7072
}
7173

72-
const availableCommands = await getCommandsInPath();
73-
if (!availableCommands) {
74-
return;
75-
}
76-
7774
// TODO: Leverage shellType when available https://github.com/microsoft/vscode/issues/230165
7875
const shellPath = 'shellPath' in terminal.creationOptions ? terminal.creationOptions.shellPath : vscode.env.shell;
7976
if (!shellPath) {
8077
return;
8178
}
8279

80+
const commandsInPath = await getCommandsInPath();
8381
const builtinCommands = getBuiltinCommands(shellPath);
84-
builtinCommands?.forEach(command => availableCommands.add(command));
82+
if (!commandsInPath || !builtinCommands) {
83+
return;
84+
}
85+
const commands = [...commandsInPath, ...builtinCommands];
8586

87+
const items: vscode.TerminalCompletionItem[] = [];
8688
const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition);
87-
let result: vscode.TerminalCompletionItem[] = [];
88-
const specs = [codeCompletionSpec, codeInsidersCompletionSpec];
89-
for (const spec of specs) {
90-
const specName = getLabel(spec);
91-
if (!specName || !availableCommands.has(specName)) {
92-
continue;
93-
}
94-
if (terminalContext.commandLine.startsWith(specName)) {
95-
if ('options' in codeInsidersCompletionSpec && codeInsidersCompletionSpec.options) {
96-
for (const option of codeInsidersCompletionSpec.options) {
97-
const optionLabel = getLabel(option);
98-
if (!optionLabel) {
99-
continue;
100-
}
10189

102-
if (optionLabel.startsWith(prefix) || (prefix.length > specName.length && prefix.trim() === specName)) {
103-
result.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag));
104-
}
105-
if (option.args !== undefined) {
106-
const args = Array.isArray(option.args) ? option.args : [option.args];
107-
for (const arg of args) {
108-
if (!arg) {
109-
continue;
110-
}
90+
const specs = [codeCompletionSpec, codeInsidersCompletionSpec];
91+
const specCompletions = await getCompletionItemsFromSpecs(specs, terminalContext, new Set(commands), prefix, token);
11192

112-
if (arg.template) {
113-
// TODO: return file/folder completion items
114-
if (arg.template === 'filepaths') {
115-
// if (label.startsWith(prefix+\s*)) {
116-
// result.push(FilePathCompletionItem)
117-
// }
118-
} else if (arg.template === 'folders') {
119-
// if (label.startsWith(prefix+\s*)) {
120-
// result.push(FolderPathCompletionItem)
121-
// }
122-
}
123-
continue;
124-
}
93+
let filesRequested = specCompletions.filesRequested;
94+
let foldersRequested = specCompletions.foldersRequested;
95+
items.push(...specCompletions.items);
12596

126-
const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition);
127-
const expectedText = `${optionLabel} `;
128-
if (arg.suggestions?.length && precedingText.includes(expectedText)) {
129-
// there are specific suggestions to show
130-
result = [];
131-
const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText);
132-
const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length);
133-
for (const suggestion of arg.suggestions) {
134-
const suggestionLabel = getLabel(suggestion);
135-
if (suggestionLabel && suggestionLabel.startsWith(currentPrefix)) {
136-
const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' ';
137-
// prefix will be '' if there is a space before the cursor
138-
result.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, arg.name, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument));
139-
}
140-
}
141-
if (result.length) {
142-
return result;
143-
}
144-
}
145-
}
146-
}
147-
}
97+
if (!specCompletions.specificSuggestionsProvided) {
98+
for (const command of commands) {
99+
if (command.startsWith(prefix)) {
100+
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
148101
}
149102
}
150103
}
151104

152-
for (const command of availableCommands) {
153-
if (command.startsWith(prefix)) {
154-
result.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
155-
}
156-
}
157-
158105
if (token.isCancellationRequested) {
159106
return undefined;
160107
}
108+
161109
const uniqueResults = new Map<string, vscode.TerminalCompletionItem>();
162-
for (const item of result) {
110+
for (const item of items) {
163111
if (!uniqueResults.has(item.label)) {
164112
uniqueResults.set(item.label, item);
165113
}
166114
}
167-
return uniqueResults.size ? Array.from(uniqueResults.values()) : undefined;
115+
const resultItems = uniqueResults.size ? Array.from(uniqueResults.values()) : undefined;
116+
117+
// If no completions are found, the prefix is a path, and neither files nor folders
118+
// are going to be requested (for a specific spec's argument), show file/folder completions
119+
const shouldShowResourceCompletions = !resultItems?.length && prefix.match(/^[./\\ ]/) && !filesRequested && !foldersRequested;
120+
if (shouldShowResourceCompletions) {
121+
filesRequested = true;
122+
foldersRequested = true;
123+
}
124+
125+
if (filesRequested || foldersRequested) {
126+
return new vscode.TerminalCompletionList(resultItems, { filesRequested, foldersRequested, cwd: terminal.shellIntegration?.cwd, pathSeparator: shellPath.includes('/') ? '/' : '\\' });
127+
}
128+
return resultItems;
168129
}
169130
}));
170131
}
171132

172-
function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string | undefined {
133+
function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[] | undefined {
173134
if (typeof spec === 'string') {
174-
return spec;
135+
return [spec];
175136
}
176137
if (typeof spec.name === 'string') {
177-
return spec.name;
138+
return [spec.name];
178139
}
179140
if (!Array.isArray(spec.name) || spec.name.length === 0) {
180141
return;
181142
}
182-
return spec.name[0];
143+
return spec.name;
183144
}
184145

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

209+
export function asArray<T>(x: T | T[]): T[];
210+
export function asArray<T>(x: T | readonly T[]): readonly T[];
211+
export function asArray<T>(x: T | T[]): T[] {
212+
return Array.isArray(x) ? x : [x];
213+
}
214+
215+
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 } {
216+
let items: vscode.TerminalCompletionItem[] = [];
217+
let filesRequested = false;
218+
let foldersRequested = false;
219+
for (const spec of specs) {
220+
const specLabels = getLabel(spec);
221+
if (!specLabels) {
222+
continue;
223+
}
224+
for (const specLabel of specLabels) {
225+
if (!availableCommands.has(specLabel) || token.isCancellationRequested) {
226+
continue;
227+
}
228+
if (terminalContext.commandLine.startsWith(specLabel)) {
229+
if ('options' in spec && spec.options) {
230+
for (const option of spec.options) {
231+
const optionLabels = getLabel(option);
232+
if (!optionLabels) {
233+
continue;
234+
}
235+
for (const optionLabel of optionLabels) {
236+
if (optionLabel.startsWith(prefix) || (prefix.length > specLabel.length && prefix.trim() === specLabel)) {
237+
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, optionLabel, option.description, false, vscode.TerminalCompletionItemKind.Flag));
238+
}
239+
if (!option.args) {
240+
continue;
241+
}
242+
const args = asArray(option.args);
243+
for (const arg of args) {
244+
if (!arg) {
245+
continue;
246+
}
247+
const precedingText = terminalContext.commandLine.slice(0, terminalContext.cursorPosition + 1);
248+
const expectedText = `${specLabel} ${optionLabel} `;
249+
if (!precedingText.includes(expectedText)) {
250+
continue;
251+
}
252+
if (arg.template) {
253+
if (arg.template === 'filepaths') {
254+
if (precedingText.includes(expectedText)) {
255+
filesRequested = true;
256+
}
257+
} else if (arg.template === 'folders') {
258+
if (precedingText.includes(expectedText)) {
259+
foldersRequested = true;
260+
}
261+
}
262+
}
263+
if (arg.suggestions?.length) {
264+
// there are specific suggestions to show
265+
items = [];
266+
const indexOfPrecedingText = terminalContext.commandLine.lastIndexOf(expectedText);
267+
const currentPrefix = precedingText.slice(indexOfPrecedingText + expectedText.length);
268+
for (const suggestion of arg.suggestions) {
269+
const suggestionLabels = getLabel(suggestion);
270+
if (!suggestionLabels) {
271+
continue;
272+
}
273+
for (const suggestionLabel of suggestionLabels) {
274+
if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) {
275+
const hasSpaceBeforeCursor = terminalContext.commandLine[terminalContext.cursorPosition - 1] === ' ';
276+
// prefix will be '' if there is a space before the cursor
277+
items.push(createCompletionItem(terminalContext.cursorPosition, precedingText, suggestionLabel, arg.name, hasSpaceBeforeCursor, vscode.TerminalCompletionItemKind.Argument));
278+
}
279+
}
280+
}
281+
if (items.length) {
282+
return { items, filesRequested, foldersRequested, specificSuggestionsProvided: true };
283+
}
284+
}
285+
}
286+
}
287+
}
288+
}
289+
}
290+
}
291+
}
292+
return { items, filesRequested, foldersRequested, specificSuggestionsProvided: false };
293+
}
294+

src/vs/workbench/api/common/extHost.api.impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1667,6 +1667,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
16671667
TerminalShellExecutionCommandLineConfidence: extHostTypes.TerminalShellExecutionCommandLineConfidence,
16681668
TerminalCompletionItem: extHostTypes.TerminalCompletionItem,
16691669
TerminalCompletionItemKind: extHostTypes.TerminalCompletionItemKind,
1670+
TerminalCompletionList: extHostTypes.TerminalCompletionList,
16701671
TextDocumentSaveReason: extHostTypes.TextDocumentSaveReason,
16711672
TextEdit: extHostTypes.TextEdit,
16721673
SnippetTextEdit: extHostTypes.SnippetTextEdit,

src/vs/workbench/api/common/extHost.protocol.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import { IFileQueryBuilderOptions, ITextQueryBuilderOptions } from '../../servic
8484
import * as search from '../../services/search/common/search.js';
8585
import { TextSearchCompleteMessage } from '../../services/search/common/searchExtTypes.js';
8686
import { ISaveProfileResult } from '../../services/userDataProfile/common/userDataProfile.js';
87-
import { TerminalCompletionItem, TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
87+
import { TerminalCompletionItem, TerminalCompletionList, TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js';
8888
import * as tasks from './shared/tasks.js';
8989

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

24362436
export interface ExtHostTerminalShellIntegrationShape {

src/vs/workbench/api/common/extHostTerminalService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, ID
5656
getEnvironmentVariableCollection(extension: IExtensionDescription): IEnvironmentVariableCollection;
5757
getTerminalById(id: number): ExtHostTerminal | null;
5858
getTerminalIdByApiObject(apiTerminal: vscode.Terminal): number | null;
59-
registerTerminalCompletionProvider<T extends vscode.TerminalCompletionItem[]>(extension: IExtensionDescription, provider: vscode.TerminalCompletionProvider<vscode.TerminalCompletionItem>, ...triggerCharacters: string[]): vscode.Disposable;
59+
registerTerminalCompletionProvider(extension: IExtensionDescription, provider: vscode.TerminalCompletionProvider<vscode.TerminalCompletionItem>, ...triggerCharacters: string[]): vscode.Disposable;
6060
}
6161

6262
interface IEnvironmentVariableCollection extends vscode.EnvironmentVariableCollection {
@@ -746,7 +746,7 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I
746746
});
747747
}
748748

749-
public async $provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto): Promise<vscode.TerminalCompletionItem[] | undefined> {
749+
public async $provideTerminalCompletions(id: string, options: ITerminalCompletionContextDto): Promise<vscode.TerminalCompletionItem[] | vscode.TerminalCompletionList | undefined> {
750750
const token = new CancellationTokenSource().token;
751751
if (token.isCancellationRequested || !this.activeTerminal) {
752752
return undefined;

src/vs/workbench/api/common/extHostTypes.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,6 +2145,41 @@ export class TerminalCompletionItem implements vscode.TerminalCompletionItem {
21452145
}
21462146

21472147

2148+
/**
2149+
* Represents a collection of {@link CompletionItem completion items} to be presented
2150+
* in the editor.
2151+
*/
2152+
export class TerminalCompletionList<T extends TerminalCompletionItem = TerminalCompletionItem> {
2153+
2154+
/**
2155+
* Resources should be shown in the completions list
2156+
*/
2157+
resourceRequestConfig?: TerminalResourceRequestConfig;
2158+
2159+
/**
2160+
* The completion items.
2161+
*/
2162+
items: T[];
2163+
2164+
/**
2165+
* Creates a new completion list.
2166+
*
2167+
* @param items The completion items.
2168+
* @param isIncomplete The list is not complete.
2169+
*/
2170+
constructor(items?: T[], resourceRequestConfig?: TerminalResourceRequestConfig) {
2171+
this.items = items ?? [];
2172+
this.resourceRequestConfig = resourceRequestConfig;
2173+
}
2174+
}
2175+
2176+
export interface TerminalResourceRequestConfig {
2177+
filesRequested?: boolean;
2178+
foldersRequested?: boolean;
2179+
cwd?: vscode.Uri;
2180+
pathSeparator: string;
2181+
}
2182+
21482183
export enum TaskRevealKind {
21492184
Always = 1,
21502185

0 commit comments

Comments
 (0)