Skip to content

support resource completions for Git Bash #251120

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

Merged
merged 11 commits into from
Jun 10, 2025
6 changes: 4 additions & 2 deletions src/vs/workbench/api/common/extHostTerminalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { serializeEnvironmentDescriptionMap, serializeEnvironmentVariableCollect
import { CancellationTokenSource } from '../../../base/common/cancellation.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { IEnvironmentVariableCollectionDescription, IEnvironmentVariableMutator, ISerializableEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariable.js';
import { ICreateContributedTerminalProfileOptions, IProcessReadyEvent, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalLaunchError, ITerminalProfile, TerminalIcon, TerminalLocation, IProcessProperty, ProcessPropertyType, IProcessPropertyMap, TerminalShellType } from '../../../platform/terminal/common/terminal.js';
import { ICreateContributedTerminalProfileOptions, IProcessReadyEvent, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalLaunchError, ITerminalProfile, TerminalIcon, TerminalLocation, IProcessProperty, ProcessPropertyType, IProcessPropertyMap, TerminalShellType, WindowsShellType } from '../../../platform/terminal/common/terminal.js';
import { TerminalDataBufferer } from '../../../platform/terminal/common/terminalDataBuffering.js';
import { ThemeColor } from '../../../base/common/themables.js';
import { Promises } from '../../../base/common/async.js';
Expand All @@ -27,6 +27,7 @@ import { TerminalCompletionList, TerminalQuickFix, ViewColumn } from './extHostT
import { IExtHostCommands } from './extHostCommands.js';
import { MarshalledId } from '../../../base/common/marshallingIds.js';
import { ISerializedTerminalInstanceContext } from '../../contrib/terminal/common/terminal.js';
import { isWindows } from '../../../base/common/platform.js';

export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, IDisposable {

Expand Down Expand Up @@ -776,7 +777,8 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I
if (completions === null || completions === undefined) {
return undefined;
}
return TerminalCompletionList.from(completions);
const pathSeparator = !isWindows || this.activeTerminal.state?.shell === WindowsShellType.GitBash ? '/' : '\\';
return TerminalCompletionList.from(completions, pathSeparator);
}

public $acceptTerminalShellType(id: number, shellType: TerminalShellType | undefined): void {
Expand Down
9 changes: 4 additions & 5 deletions src/vs/workbench/api/common/extHostTypeConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import { parse, revive } from '../../../base/common/marshalling.js';
import { MarshalledId } from '../../../base/common/marshallingIds.js';
import { Mimes } from '../../../base/common/mime.js';
import { cloneAndChange } from '../../../base/common/objects.js';
import { isWindows } from '../../../base/common/platform.js';
import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js';
import { basename } from '../../../base/common/resources.js';
import { ThemeIcon } from '../../../base/common/themables.js';
Expand Down Expand Up @@ -3188,24 +3187,24 @@ export namespace TerminalCompletionItemDto {
}

export namespace TerminalCompletionList {
export function from(completions: vscode.TerminalCompletionList | vscode.TerminalCompletionItem[]): extHostProtocol.TerminalCompletionListDto {
export function from(completions: vscode.TerminalCompletionList | vscode.TerminalCompletionItem[], pathSeparator: string): extHostProtocol.TerminalCompletionListDto {
if (Array.isArray(completions)) {
return {
items: completions.map(i => TerminalCompletionItemDto.from(i)),
};
}
return {
items: completions.items.map(i => TerminalCompletionItemDto.from(i)),
resourceRequestConfig: completions.resourceRequestConfig ? TerminalResourceRequestConfig.from(completions.resourceRequestConfig) : undefined,
resourceRequestConfig: completions.resourceRequestConfig ? TerminalResourceRequestConfig.from(completions.resourceRequestConfig, pathSeparator) : undefined,
};
}
}

export namespace TerminalResourceRequestConfig {
export function from(resourceRequestConfig: vscode.TerminalResourceRequestConfig): extHostProtocol.TerminalResourceRequestConfigDto {
export function from(resourceRequestConfig: vscode.TerminalResourceRequestConfig, pathSeparator: string): extHostProtocol.TerminalResourceRequestConfigDto {
return {
...resourceRequestConfig,
pathSeparator: isWindows ? '\\' : '/',
pathSeparator,
cwd: resourceRequestConfig.cwd ? URI.revive(resourceRequestConfig.cwd) : undefined,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ import { IConfigurationService } from '../../../../../platform/configuration/com
import { IFileService } from '../../../../../platform/files/common/files.js';
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
import { TerminalCapability, type ITerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
import { GeneralShellType, TerminalShellType } from '../../../../../platform/terminal/common/terminal.js';
import { GeneralShellType, TerminalShellType, WindowsShellType } from '../../../../../platform/terminal/common/terminal.js';
import { TerminalSuggestSettingId } from '../common/terminalSuggestConfiguration.js';
import { TerminalCompletionItemKind, type ITerminalCompletion } from './terminalCompletionItem.js';
import { env as processEnv } from '../../../../../base/common/process.js';
import type { IProcessEnvironment } from '../../../../../base/common/platform.js';
import { timeout } from '../../../../../base/common/async.js';
import { gitBashPathToWindows } from './terminalGitBashHelpers.js';

export const ITerminalCompletionService = createDecorator<ITerminalCompletionService>('terminalCompletionService');

Expand Down Expand Up @@ -190,7 +191,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
return completionItems;
}
if (completions.resourceRequestConfig) {
const resourceCompletions = await this.resolveResources(completions.resourceRequestConfig, promptValue, cursorPosition, provider.id, capabilities);
const resourceCompletions = await this.resolveResources(completions.resourceRequestConfig, promptValue, cursorPosition, provider.id, capabilities, shellType);
if (resourceCompletions) {
completionItems.push(...resourceCompletions);
}
Expand All @@ -202,7 +203,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
return results.filter(result => !!result).flat();
}

async resolveResources(resourceRequestConfig: TerminalResourceRequestConfig, promptValue: string, cursorPosition: number, provider: string, capabilities: ITerminalCapabilityStore): Promise<ITerminalCompletion[] | undefined> {
async resolveResources(resourceRequestConfig: TerminalResourceRequestConfig, promptValue: string, cursorPosition: number, provider: string, capabilities: ITerminalCapabilityStore, shellType?: TerminalShellType): Promise<ITerminalCompletion[] | undefined> {
const useWindowsStylePath = resourceRequestConfig.pathSeparator === '\\';
if (useWindowsStylePath) {
// for tests, make sure the right path separator is used
Expand Down Expand Up @@ -280,7 +281,11 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
break;
}
case 'absolute': {
lastWordFolderResource = URI.file(lastWordFolder.replaceAll('\\ ', ' '));
if (shellType === WindowsShellType.GitBash) {
lastWordFolderResource = URI.file(gitBashPathToWindows(lastWordFolder, this._processEnv.SystemDrive));
} else {
lastWordFolderResource = URI.file(lastWordFolder.replaceAll('\\ ', ' '));
}
break;
}
case 'relative': {
Expand Down Expand Up @@ -349,7 +354,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
label,
provider,
kind: TerminalCompletionItemKind.Folder,
detail: getFriendlyPath(lastWordFolderResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
detail: getFriendlyPath(lastWordFolderResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
Expand Down Expand Up @@ -394,7 +399,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
label,
provider,
kind,
detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind),
detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
Expand All @@ -420,8 +425,8 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
}
const useRelative = config === 'relative';
const kind = TerminalCompletionItemKind.Folder;
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind);
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind)}` : `CDPATH`;
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType);
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType)}` : `CDPATH`;
resourceCompletions.push({
label,
provider,
Expand Down Expand Up @@ -453,7 +458,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
label,
provider,
kind: TerminalCompletionItemKind.Folder,
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
Expand All @@ -478,7 +483,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
label: '~',
provider,
kind: TerminalCompletionItemKind.Folder,
detail: typeof homeResource === 'string' ? homeResource : getFriendlyPath(homeResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
detail: typeof homeResource === 'string' ? homeResource : getFriendlyPath(homeResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
replacementIndex: cursorPosition - lastWord.length,
replacementLength: lastWord.length
});
Expand All @@ -500,14 +505,15 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
}
}

function getFriendlyPath(uri: URI, pathSeparator: string, kind: TerminalCompletionItemKind): string {
function getFriendlyPath(uri: URI, pathSeparator: string, kind: TerminalCompletionItemKind, shellType?: TerminalShellType): string {
let path = uri.fsPath;
const sep = shellType === WindowsShellType.GitBash ? '\\' : pathSeparator;
// Ensure folders end with the path separator to differentiate presentation from files
if (kind === TerminalCompletionItemKind.Folder && !path.endsWith(pathSeparator)) {
path += pathSeparator;
if (kind === TerminalCompletionItemKind.Folder && !path.endsWith(sep)) {
path += sep;
}
// Ensure drive is capitalized on Windows
if (pathSeparator === '\\' && path.match(/^[a-zA-Z]:\\/)) {
if (sep === '\\' && path.match(/^[a-zA-Z]:\\/)) {
path = `${path[0].toUpperCase()}:${path.slice(2)}`;
}
return path;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

/**
* Converts a Git Bash absolute path to a Windows absolute path.
* Examples:
* "/" => "C:\\"
* "/c/" => "C:\\"
* "/c/Users/foo" => "C:\\Users\\foo"
* "/d/bar" => "D:\\bar"
*/
export function gitBashPathToWindows(path: string, driveLetter?: string): string {
// Dynamically determine the system drive (default to 'C:' if not set)
const systemDrive = (driveLetter || 'C:').toUpperCase();
// Handle root "/"
if (path === '/') {
return `${systemDrive}\\`;
}
const match = path.match(/^\/([a-zA-Z])(\/.*)?$/);
if (match) {
const drive = match[1].toUpperCase();
const rest = match[2] ? match[2].replace(/\//g, '\\') : '\\';
return `${drive}:${rest}`;
}
// Fallback: just replace slashes
return path.replace(/\//g, '\\');
}

/**
*
* @param path A Windows-style absolute path (e.g., "C:\Users\foo").
* Converts it to a Git Bash-style absolute path (e.g., "/c/Users/foo").
* @returns The Git Bash-style absolute path.
*/
export function windowsToGitBashPath(path: string): string {
// Convert Windows path (e.g. C:\Users\foo) to Git Bash path (e.g. /c/Users/foo)
return path
.replace(/^[a-zA-Z]:\\/, match => `/${match[0].toLowerCase()}/`)
.replace(/\\/g, '/');
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { ShellEnvDetectionCapability } from '../../../../../../platform/terminal
import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
import { ITerminalCompletion, TerminalCompletionItemKind } from '../../browser/terminalCompletionItem.js';
import { count } from '../../../../../../base/common/strings.js';
import { WindowsShellType } from '../../../../../../platform/terminal/common/terminal.js';
import { gitBashPathToWindows, windowsToGitBashPath } from '../../browser/terminalGitBashHelpers.js';

const pathSeparator = isWindows ? '\\' : '/';

Expand All @@ -35,7 +37,8 @@ interface IAssertionCommandLineConfig {
/**
* Assert the set of completions exist exactly, including their order.
*/
function assertCompletions(actual: ITerminalCompletion[] | undefined, expected: IAssertionTerminalCompletion[], expectedConfig: IAssertionCommandLineConfig) {
function assertCompletions(actual: ITerminalCompletion[] | undefined, expected: IAssertionTerminalCompletion[], expectedConfig: IAssertionCommandLineConfig, pathSep?: string) {
const sep = pathSep ?? pathSeparator;
assert.deepStrictEqual(
actual?.map(e => ({
label: e.label,
Expand All @@ -44,8 +47,8 @@ function assertCompletions(actual: ITerminalCompletion[] | undefined, expected:
replacementIndex: e.replacementIndex,
replacementLength: e.replacementLength,
})), expected.map(e => ({
label: e.label.replaceAll('/', pathSeparator),
detail: e.detail ? e.detail.replaceAll('/', pathSeparator) : '',
label: e.label.replaceAll('/', sep),
detail: e.detail ? e.detail.replaceAll('/', sep) : '',
kind: e.kind ?? TerminalCompletionItemKind.Folder,
replacementIndex: expectedConfig.replacementIndex,
replacementLength: expectedConfig.replacementLength,
Expand Down Expand Up @@ -608,4 +611,70 @@ suite('TerminalCompletionService', () => {
], { replacementIndex: 3, replacementLength: 0 });
});
});

if (isWindows) {
suite('gitbash', () => {
test('should convert Git Bash absolute path to Windows absolute path', () => {
assert.strictEqual(gitBashPathToWindows('/'), 'C:\\');
assert.strictEqual(gitBashPathToWindows('/c/'), 'C:\\');
assert.strictEqual(gitBashPathToWindows('/c/Users/foo'), 'C:\\Users\\foo');
assert.strictEqual(gitBashPathToWindows('/d/bar'), 'D:\\bar');
});

test('should convert Windows absolute path to Git Bash absolute path', () => {
assert.strictEqual(windowsToGitBashPath('C:\\'), '/c/');
assert.strictEqual(windowsToGitBashPath('C:\\Users\\foo'), '/c/Users/foo');
assert.strictEqual(windowsToGitBashPath('D:\\bar'), '/d/bar');
assert.strictEqual(windowsToGitBashPath('E:\\some\\path'), '/e/some/path');
});
test('resolveResources with cwd as Windows path (relative)', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
cwd: URI.file('C:\\Users\\foo'),
foldersRequested: true,
filesRequested: true,
pathSeparator: '/'
};
validResources = [
URI.file('C:\\Users\\foo'),
URI.file('C:\\Users\\foo\\bar'),
URI.file('C:\\Users\\foo\\baz.txt')
];
childResources = [
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 2, provider, capabilities, WindowsShellType.GitBash);
assertCompletions(result, [
{ label: './', detail: 'C:\\Users\\foo\\' },
{ label: './bar/', detail: 'C:\\Users\\foo\\bar\\' },
{ label: './baz.txt', detail: 'C:\\Users\\foo\\baz.txt', kind: TerminalCompletionItemKind.File },
{ label: './../', detail: 'C:\\Users\\' }
], { replacementIndex: 0, replacementLength: 2 }, '/');
});

test('resolveResources with cwd as Windows path (absolute)', async () => {
const resourceRequestConfig: TerminalResourceRequestConfig = {
cwd: URI.file('C:\\Users\\foo'),
foldersRequested: true,
filesRequested: true,
pathSeparator: '/'
};
validResources = [
URI.file('C:\\Users\\foo'),
URI.file('C:\\Users\\foo\\bar'),
URI.file('C:\\Users\\foo\\baz.txt')
];
childResources = [
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
];
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '/c/Users/foo/', 13, provider, capabilities, WindowsShellType.GitBash);
assertCompletions(result, [
{ label: '/c/Users/foo/', detail: 'C:\\Users\\foo\\' },
{ label: '/c/Users/foo/bar/', detail: 'C:\\Users\\foo\\bar\\' },
{ label: '/c/Users/foo/baz.txt', detail: 'C:\\Users\\foo\\baz.txt', kind: TerminalCompletionItemKind.File },
], { replacementIndex: 0, replacementLength: 13 }, '/');
});
});
}
});
Loading