Skip to content

Commit

Permalink
Merge pull request #239412 from microsoft/tyriar/239401_cdpath__237979
Browse files Browse the repository at this point in the history
Terminal suggest absolute path support
  • Loading branch information
Tyriar authored Feb 3, 2025
2 parents a90fa28 + 7aed15b commit 8f0aac0
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -220,15 +220,11 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
return;
}

const fileStat = await this._fileService.resolve(cwd, { resolveSingleChildDescendants: true });
if (!fileStat || !fileStat?.children) {
return;
}

const resourceCompletions: ITerminalCompletion[] = [];
const cursorPrefix = promptValue.substring(0, cursorPosition);

const useForwardSlash = !resourceRequestConfig.shouldNormalizePrefix && isWindows;
// TODO: This should come in through the resourceRequestConfig
const useBackslash = isWindows;

// The last word (or argument). When the cursor is following a space it will be the empty
// string
Expand All @@ -237,7 +233,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
// Get the nearest folder path from the prefix. This ignores everything after the `/` as
// they are what triggers changes in the directory.
let lastSlashIndex: number;
if (useForwardSlash) {
if (useBackslash) {
lastSlashIndex = Math.max(lastWord.lastIndexOf('\\'), lastWord.lastIndexOf('/'));
} else {
lastSlashIndex = lastWord.lastIndexOf(resourceRequestConfig.pathSeparator);
Expand Down Expand Up @@ -271,39 +267,108 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
return resourceCompletions;
}
}
// Add current directory. This should be shown at the top because it will be an exact match
// and therefore highlight the detail, plus it improves the experience when runOnEnter is
// used.
//
// For example:
// - `|` -> `.`, this does not have the trailing `/` intentionally as it's common to
// complete the current working directory and we do not want to complete `./` when
// `runOnEnter` is used.
// - `./src/|` -> `./src/`
if (foldersRequested) {
resourceCompletions.push({
label: lastWordFolder.length === 0 ? '.' : lastWordFolder,
provider,
kind: TerminalCompletionItemKind.Folder,
isDirectory: true,
isFile: false,
detail: getFriendlyPath(cwd, resourceRequestConfig.pathSeparator),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}

// Handle absolute paths differently to avoid adding `./` prefixes
// TODO: Deal with git bash case
const isAbsolutePath = useForwardSlash
? /^[a-zA-Z]:\\/.test(lastWord)
: lastWord.startsWith(resourceRequestConfig.pathSeparator) && lastWord.endsWith(resourceRequestConfig.pathSeparator);

// Add all direct children files or folders
//
// For example:
// - `cd ./src/` -> `cd ./src/folder1`, ...
if (!isAbsolutePath) {
const isAbsolutePath = useBackslash
? /^[a-zA-Z]:[\\\/]/.test(lastWord)
: lastWord.startsWith(resourceRequestConfig.pathSeparator);

if (isAbsolutePath) {

const lastWordResource = URI.file(lastWordFolder);
const fileStat = await this._fileService.resolve(lastWordResource, { resolveSingleChildDescendants: true });
if (!fileStat?.children) {
return;
}

// Add current directory. This should be shown at the top because it will be an exact match
// and therefore highlight the detail, plus it improves the experience when runOnEnter is
// used.
//
// For example:
// - `c:/foo/|` -> `c:/foo/`
if (foldersRequested) {
resourceCompletions.push({
label: lastWordFolder,
provider,
kind: TerminalCompletionItemKind.Folder,
isDirectory: true,
isFile: false,
detail: getFriendlyPath(lastWordResource, resourceRequestConfig.pathSeparator),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}

// Add all direct children files or folders
//
// For example:
// - `cd c:/src/` -> `cd c:/src/folder1/`, ...
for (const child of fileStat.children) {
if (
(child.isDirectory && !foldersRequested) ||
(child.isFile && !filesRequested)
) {
continue;
}

let label = lastWordFolder;
if (!label.endsWith(resourceRequestConfig.pathSeparator)) {
label += resourceRequestConfig.pathSeparator;
}
label += child.name;
if (child.isDirectory) {
label += resourceRequestConfig.pathSeparator;
}

const kind = child.isDirectory ? TerminalCompletionItemKind.Folder : TerminalCompletionItemKind.File;

resourceCompletions.push({
label,
provider,
kind,
detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind),
isDirectory: child.isDirectory,
isFile: child.isFile,
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}

} else { // !isAbsolutePath

const fileStat = await this._fileService.resolve(cwd, { resolveSingleChildDescendants: true });
if (!fileStat?.children) {
return;
}

// Add current directory. This should be shown at the top because it will be an exact match
// and therefore highlight the detail, plus it improves the experience when runOnEnter is
// used.
//
// For example:
// - `|` -> `.`, this does not have the trailing `/` intentionally as it's common to
// complete the current working directory and we do not want to complete `./` when
// `runOnEnter` is used.
// - `./src/|` -> `./src/`
if (foldersRequested) {
resourceCompletions.push({
label: lastWordFolder.length === 0 ? '.' : lastWordFolder,
provider,
kind: TerminalCompletionItemKind.Folder,
isDirectory: true,
isFile: false,
detail: getFriendlyPath(cwd, resourceRequestConfig.pathSeparator),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}

// Add all direct children files or folders
//
// For example:
// - `cd ./src/` -> `cd ./src/folder1/`, ...
for (const stat of fileStat.children) {
let kind: TerminalCompletionItemKind | undefined;
if (foldersRequested && stat.isDirectory) {
Expand Down Expand Up @@ -334,7 +399,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo

// Normalize path separator to `\` on Windows. It should act the exact same as `/` but
// suggestions should all use `\`
if (useForwardSlash) {
if (useBackslash) {
label = label.replaceAll('/', '\\');
}

Expand All @@ -349,64 +414,65 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
replacementLength: lastWord.length
});
}
}

// Support $CDPATH specially for the `cd` command only
if (promptValue.startsWith('cd ')) {
const config = this._configurationService.getValue(TerminalSuggestSettingId.CdPath);
if (config === 'absolute' || config === 'relative') {
const cdPath = capabilities.get(TerminalCapability.ShellEnvDetection)?.env?.get('CDPATH');
if (cdPath) {
const cdPathEntries = cdPath.split(useForwardSlash ? ';' : ':');
for (const cdPathEntry of cdPathEntries) {
try {
const fileStat = await this._fileService.resolve(URI.file(cdPathEntry), { resolveSingleChildDescendants: true });
if (fileStat?.children) {
for (const child of fileStat.children) {
if (!child.isDirectory) {
continue;
// Support $CDPATH specially for the `cd` command only
if (promptValue.startsWith('cd ')) {
const config = this._configurationService.getValue(TerminalSuggestSettingId.CdPath);
if (config === 'absolute' || config === 'relative') {
const cdPath = capabilities.get(TerminalCapability.ShellEnvDetection)?.env?.get('CDPATH');
if (cdPath) {
const cdPathEntries = cdPath.split(useBackslash ? ';' : ':');
for (const cdPathEntry of cdPathEntries) {
try {
const fileStat = await this._fileService.resolve(URI.file(cdPathEntry), { resolveSingleChildDescendants: true });
if (fileStat?.children) {
for (const child of fileStat.children) {
if (!child.isDirectory) {
continue;
}
const useRelative = config === 'relative';
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator);
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator)}` : `CDPATH`;
resourceCompletions.push({
label,
provider,
kind: TerminalCompletionItemKind.Folder,
isDirectory: child.isDirectory,
isFile: child.isFile,
detail,
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}
const useRelative = config === 'relative';
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator);
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator)}` : `CDPATH`;
resourceCompletions.push({
label,
provider,
kind: TerminalCompletionItemKind.Folder,
isDirectory: child.isDirectory,
isFile: child.isFile,
detail,
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}
}
} catch { /* ignore */ }
} catch { /* ignore */ }
}
}
}
}
}

// Add parent directory to the bottom of the list because it's not as useful as other suggestions
//
// For example:
// - `|` -> `../`
// - `./src/|` -> `./src/../`
//
// On Windows, the path seprators are normalized to `\`:
// - `./src/|` -> `.\src\..\`
if (!isAbsolutePath && foldersRequested) {
const parentDir = URI.joinPath(cwd, '..' + resourceRequestConfig.pathSeparator);
resourceCompletions.push({
label: lastWordFolder + '..' + resourceRequestConfig.pathSeparator,
provider,
kind: TerminalCompletionItemKind.Folder,
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator),
isDirectory: true,
isFile: false,
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
// Add parent directory to the bottom of the list because it's not as useful as other suggestions
//
// For example:
// - `|` -> `../`
// - `./src/|` -> `./src/../`
//
// On Windows, the path seprators are normalized to `\`:
// - `./src/|` -> `.\src\..\`
if (foldersRequested) {
const parentDir = URI.joinPath(cwd, '..' + resourceRequestConfig.pathSeparator);
resourceCompletions.push({
label: lastWordFolder + '..' + resourceRequestConfig.pathSeparator,
provider,
kind: TerminalCompletionItemKind.Folder,
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator),
isDirectory: true,
isFile: false,
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
}

}

return resourceCompletions.length ? resourceCompletions : undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,23 +241,67 @@ suite('TerminalCompletionService', () => {
childResources = [];
});

if (!isWindows) {
test('/usr/| Missing . should show correct results', async () => {
if (isWindows) {
test('C:/Foo/| absolute paths on Windows', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
cwd: URI.parse('file:///C:'),
foldersRequested: true,
pathSeparator,
shouldNormalizePrefix: true,
};
validResources = [URI.parse('file:///C:/Foo')];
childResources = [
{ resource: URI.parse('file:///C:/Foo/Bar'), isDirectory: true, isFile: false },
{ resource: URI.parse('file:///C:/Foo/Baz.txt'), isDirectory: false, isFile: true }
];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'C:/Foo/', 7, provider, capabilities);

assertCompletions(result, [
{ label: 'C:/Foo/', detail: 'C:/Foo/' },
{ label: 'C:/Foo/Bar/', detail: 'C:/Foo/Bar/' },
], { replacementIndex: 0, replacementLength: 7 });
});
test('c:/foo/| case insensitivity on Windows', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
cwd: URI.parse('file:///c:'),
foldersRequested: true,
pathSeparator,
shouldNormalizePrefix: true,
};
validResources = [URI.parse('file:///c:/foo')];
childResources = [
{ resource: URI.parse('file:///c:/foo/Bar'), isDirectory: true, isFile: false }
];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'c:/foo/', 7, provider, capabilities);

assertCompletions(result, [
// Note that the detail is normalizes drive letters to capital case intentionally
{ label: 'c:/foo/', detail: 'C:/foo/' },
{ label: 'c:/foo/Bar/', detail: 'C:/foo/Bar/' },
], { replacementIndex: 0, replacementLength: 7 });
});
} else {
test('/foo/| absolute paths NOT on Windows', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
cwd: URI.parse('file:///'),
foldersRequested: true,
pathSeparator,
shouldNormalizePrefix: true
};
validResources = [URI.parse('file:///usr')];
childResources = [];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '/usr/', 5, provider, capabilities);
validResources = [URI.parse('file:///foo')];
childResources = [
{ resource: URI.parse('file:///foo/Bar'), isDirectory: true, isFile: false },
{ resource: URI.parse('file:///foo/Baz.txt'), isDirectory: false, isFile: true }
];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '/foo/', 5, provider, capabilities);

assertCompletions(result, [
{ label: '/usr/', detail: '/' },
{ label: '/foo/', detail: '/foo/' },
{ label: '/foo/Bar/', detail: '/foo/Bar/' },
], { replacementIndex: 0, replacementLength: 5 });
});
}

if (isWindows) {
test('.\\folder | Case insensitivity should resolve correctly on Windows', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
Expand Down

0 comments on commit 8f0aac0

Please sign in to comment.