Skip to content

Commit cff1a6a

Browse files
authored
add path to details for executable terminal suggestions, prevent duplication (microsoft#238080)
1 parent 87ed97d commit cff1a6a

File tree

2 files changed

+45
-28
lines changed

2 files changed

+45
-28
lines changed

extensions/terminal-suggest/src/terminalSuggestMain.ts

Lines changed: 44 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { isExecutable } from './helpers/executable';
1515

1616
const isWindows = osIsWindows();
1717
let cachedAvailableCommandsPath: string | undefined;
18-
let cachedAvailableCommands: Set<string> | undefined;
19-
const cachedBuiltinCommands: Map<string, string[] | undefined> = new Map();
18+
let cachedAvailableCommands: Set<ICompletionResource> | undefined;
19+
const cachedBuiltinCommands: Map<string, ICompletionResource[] | undefined> = new Map();
2020

2121
export const availableSpecs: Fig.Spec[] = [
2222
cdSpec,
@@ -27,14 +27,14 @@ for (const spec of upstreamSpecs) {
2727
availableSpecs.push(require(`./completions/upstream/${spec}`).default);
2828
}
2929

30-
function getBuiltinCommands(shell: string): string[] | undefined {
30+
function getBuiltinCommands(shell: string, existingCommands?: Set<string>): ICompletionResource[] | undefined {
3131
try {
3232
const shellType = path.basename(shell, path.extname(shell));
3333
const cachedCommands = cachedBuiltinCommands.get(shellType);
3434
if (cachedCommands) {
3535
return cachedCommands;
3636
}
37-
const filter = (cmd: string) => cmd;
37+
const filter = (cmd: string) => cmd && !existingCommands?.has(cmd);
3838
const options: ExecOptionsWithStringEncoding = { encoding: 'utf-8', shell };
3939
let commands: string[] | undefined;
4040
switch (shellType) {
@@ -68,8 +68,10 @@ function getBuiltinCommands(shell: string): string[] | undefined {
6868
break;
6969
}
7070
}
71-
cachedBuiltinCommands.set(shellType, commands);
72-
return commands;
71+
72+
const commandResources = commands?.map(command => ({ label: command }));
73+
cachedBuiltinCommands.set(shellType, commandResources);
74+
return commandResources;
7375

7476
} catch (error) {
7577
console.error('Error fetching builtin commands:', error);
@@ -92,11 +94,11 @@ export async function activate(context: vscode.ExtensionContext) {
9294
}
9395

9496
const commandsInPath = await getCommandsInPath(terminal.shellIntegration?.env);
95-
const builtinCommands = getBuiltinCommands(shellPath);
96-
if (!commandsInPath || !builtinCommands) {
97+
const builtinCommands = getBuiltinCommands(shellPath, commandsInPath?.labels) ?? [];
98+
if (!commandsInPath?.completionResources) {
9799
return;
98100
}
99-
const commands = [...commandsInPath, ...builtinCommands];
101+
const commands = [...commandsInPath.completionResources, ...builtinCommands];
100102

101103
const prefix = getPrefix(terminalContext.commandLine, terminalContext.cursorPosition);
102104

@@ -169,20 +171,24 @@ function getLabel(spec: Fig.Spec | Fig.Arg | Fig.Suggestion | string): string[]
169171
return spec.name;
170172
}
171173

172-
function createCompletionItem(cursorPosition: number, prefix: string, label: string, description?: string, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem {
174+
function createCompletionItem(cursorPosition: number, prefix: string, commandResource: ICompletionResource, description?: string, kind?: vscode.TerminalCompletionItemKind): vscode.TerminalCompletionItem {
173175
const endsWithSpace = prefix.endsWith(' ');
174176
const lastWord = endsWithSpace ? '' : prefix.split(' ').at(-1) ?? '';
175177
return {
176-
label,
177-
detail: description ?? '',
178+
label: commandResource.label,
179+
detail: description ?? commandResource.path ?? '',
178180
replacementIndex: cursorPosition - lastWord.length,
179181
replacementLength: lastWord.length,
180182
kind: kind ?? vscode.TerminalCompletionItemKind.Method
181183
};
182184
}
183185

184-
async function getCommandsInPath(env: { [key: string]: string | undefined } = process.env): Promise<Set<string> | undefined> {
185-
// Get PATH value
186+
interface ICompletionResource {
187+
label: string;
188+
path?: string;
189+
}
190+
async function getCommandsInPath(env: { [key: string]: string | undefined } = process.env): Promise<{ completionResources: Set<ICompletionResource> | undefined; labels: Set<string> | undefined } | undefined> {
191+
const labels: Set<string> = new Set<string>();
186192
let pathValue: string | undefined;
187193
if (isWindows) {
188194
const caseSensitivePathKey = Object.keys(env).find(key => key.toLowerCase() === 'path');
@@ -198,24 +204,26 @@ async function getCommandsInPath(env: { [key: string]: string | undefined } = pr
198204

199205
// Check cache
200206
if (cachedAvailableCommands && cachedAvailableCommandsPath === pathValue) {
201-
return cachedAvailableCommands;
207+
return { completionResources: cachedAvailableCommands, labels };
202208
}
203209

204210
// Extract executables from PATH
205211
const paths = pathValue.split(isWindows ? ';' : ':');
206212
const pathSeparator = isWindows ? '\\' : '/';
207-
const executables = new Set<string>();
213+
const executables = new Set<ICompletionResource>();
208214
for (const path of paths) {
209215
try {
210216
const dirExists = await fs.stat(path).then(stat => stat.isDirectory()).catch(() => false);
211217
if (!dirExists) {
212218
continue;
213219
}
214-
const files = await vscode.workspace.fs.readDirectory(vscode.Uri.file(path));
215-
220+
const fileResource = vscode.Uri.file(path);
221+
const files = await vscode.workspace.fs.readDirectory(fileResource);
216222
for (const [file, fileType] of files) {
217-
if (fileType !== vscode.FileType.Unknown && fileType !== vscode.FileType.Directory && await isExecutable(path + pathSeparator + file)) {
218-
executables.add(file);
223+
const formattedPath = getFriendlyFilePath(vscode.Uri.joinPath(fileResource, file), pathSeparator);
224+
if (!labels.has(file) && fileType !== vscode.FileType.Unknown && fileType !== vscode.FileType.Directory && await isExecutable(formattedPath)) {
225+
executables.add({ label: file, path: formattedPath });
226+
labels.add(file);
219227
}
220228
}
221229
} catch (e) {
@@ -224,7 +232,7 @@ async function getCommandsInPath(env: { [key: string]: string | undefined } = pr
224232
}
225233
}
226234
cachedAvailableCommands = executables;
227-
return executables;
235+
return { completionResources: executables, labels };
228236
}
229237

230238
function getPrefix(commandLine: string, cursorPosition: number): string {
@@ -257,7 +265,7 @@ export function asArray<T>(x: T | T[]): T[] {
257265
export async function getCompletionItemsFromSpecs(
258266
specs: Fig.Spec[],
259267
terminalContext: { commandLine: string; cursorPosition: number },
260-
availableCommands: string[],
268+
availableCommands: ICompletionResource[],
261269
prefix: string,
262270
shellIntegrationCwd?: vscode.Uri,
263271
token?: vscode.CancellationToken
@@ -277,7 +285,7 @@ export async function getCompletionItemsFromSpecs(
277285
}
278286

279287
for (const specLabel of specLabels) {
280-
if (!availableCommands.includes(specLabel) || (token && token.isCancellationRequested)) {
288+
if (!availableCommands.find(command => command.label === specLabel) || (token && token.isCancellationRequested)) {
281289
continue;
282290
}
283291

@@ -288,7 +296,7 @@ export async function getCompletionItemsFromSpecs(
288296
|| !!firstCommand && specLabel.startsWith(firstCommand)
289297
) {
290298
// push it to the completion items
291-
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, specLabel));
299+
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, { label: specLabel }));
292300
}
293301

294302
if (!terminalContext.commandLine.startsWith(specLabel)) {
@@ -323,7 +331,7 @@ export async function getCompletionItemsFromSpecs(
323331
// Include builitin/available commands in the results
324332
const labels = new Set(items.map((i) => i.label));
325333
for (const command of availableCommands) {
326-
if (!labels.has(command)) {
334+
if (!labels.has(command.label)) {
327335
items.push(createCompletionItem(terminalContext.cursorPosition, prefix, command));
328336
}
329337
}
@@ -393,7 +401,7 @@ function handleOptions(specLabel: string, spec: Fig.Spec, terminalContext: { com
393401
createCompletionItem(
394402
terminalContext.cursorPosition,
395403
prefix,
396-
optionLabel,
404+
{ label: optionLabel },
397405
option.description,
398406
vscode.TerminalCompletionItemKind.Flag
399407
)
@@ -455,7 +463,7 @@ function getCompletionItemsFromArgs(args: Fig.SingleOrArray<Fig.Arg> | undefined
455463
}
456464
if (suggestionLabel && suggestionLabel.startsWith(currentPrefix.trim())) {
457465
const description = typeof suggestion !== 'string' ? suggestion.description : '';
458-
items.push(createCompletionItem(terminalContext.cursorPosition, wordBefore ?? '', suggestionLabel, description, vscode.TerminalCompletionItemKind.Argument));
466+
items.push(createCompletionItem(terminalContext.cursorPosition, wordBefore ?? '', { label: suggestionLabel }, description, vscode.TerminalCompletionItemKind.Argument));
459467
}
460468
}
461469
}
@@ -479,3 +487,12 @@ function getFirstCommand(commandLine: string): string | undefined {
479487
}
480488
return firstCommand;
481489
}
490+
491+
function getFriendlyFilePath(uri: vscode.Uri, pathSeparator: string): string {
492+
let path = uri.fsPath;
493+
// Ensure drive is capitalized on Windows
494+
if (pathSeparator === '\\' && path.match(/^[a-zA-Z]:\\/)) {
495+
path = `${path[0].toUpperCase()}:${path.slice(2)}`;
496+
}
497+
return path;
498+
}

extensions/terminal-suggest/src/test/terminalSuggestMain.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ suite('Terminal Suggest', () => {
150150
const prefix = commandLine.slice(0, cursorPosition).split(' ').at(-1) || '';
151151
const filesRequested = testSpec.expectedResourceRequests?.type === 'files' || testSpec.expectedResourceRequests?.type === 'both';
152152
const foldersRequested = testSpec.expectedResourceRequests?.type === 'folders' || testSpec.expectedResourceRequests?.type === 'both';
153-
const result = await getCompletionItemsFromSpecs(completionSpecs, { commandLine, cursorPosition }, availableCommands, prefix, testCwd);
153+
const result = await getCompletionItemsFromSpecs(completionSpecs, { commandLine, cursorPosition }, availableCommands.map(c => { return { label: c }; }), prefix, testCwd);
154154
deepStrictEqual(result.items.map(i => i.label).sort(), (testSpec.expectedCompletions ?? []).sort());
155155
strictEqual(result.filesRequested, filesRequested);
156156
strictEqual(result.foldersRequested, foldersRequested);

0 commit comments

Comments
 (0)