Skip to content

Commit 23f0f36

Browse files
authored
support resource completions for Git Bash (#251120)
1 parent f5c3e1c commit 23f0f36

File tree

5 files changed

+142
-24
lines changed

5 files changed

+142
-24
lines changed

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { serializeEnvironmentDescriptionMap, serializeEnvironmentVariableCollect
1818
import { CancellationTokenSource } from '../../../base/common/cancellation.js';
1919
import { generateUuid } from '../../../base/common/uuid.js';
2020
import { IEnvironmentVariableCollectionDescription, IEnvironmentVariableMutator, ISerializableEnvironmentVariableCollection } from '../../../platform/terminal/common/environmentVariable.js';
21-
import { ICreateContributedTerminalProfileOptions, IProcessReadyEvent, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalLaunchError, ITerminalProfile, TerminalIcon, TerminalLocation, IProcessProperty, ProcessPropertyType, IProcessPropertyMap, TerminalShellType } from '../../../platform/terminal/common/terminal.js';
21+
import { ICreateContributedTerminalProfileOptions, IProcessReadyEvent, IShellLaunchConfigDto, ITerminalChildProcess, ITerminalLaunchError, ITerminalProfile, TerminalIcon, TerminalLocation, IProcessProperty, ProcessPropertyType, IProcessPropertyMap, TerminalShellType, WindowsShellType } from '../../../platform/terminal/common/terminal.js';
2222
import { TerminalDataBufferer } from '../../../platform/terminal/common/terminalDataBuffering.js';
2323
import { ThemeColor } from '../../../base/common/themables.js';
2424
import { Promises } from '../../../base/common/async.js';
@@ -27,6 +27,7 @@ import { TerminalCompletionList, TerminalQuickFix, ViewColumn } from './extHostT
2727
import { IExtHostCommands } from './extHostCommands.js';
2828
import { MarshalledId } from '../../../base/common/marshallingIds.js';
2929
import { ISerializedTerminalInstanceContext } from '../../contrib/terminal/common/terminal.js';
30+
import { isWindows } from '../../../base/common/platform.js';
3031

3132
export interface IExtHostTerminalService extends ExtHostTerminalServiceShape, IDisposable {
3233

@@ -781,7 +782,8 @@ export abstract class BaseExtHostTerminalService extends Disposable implements I
781782
if (completions === null || completions === undefined) {
782783
return undefined;
783784
}
784-
return TerminalCompletionList.from(completions);
785+
const pathSeparator = !isWindows || this.activeTerminal.state?.shell === WindowsShellType.GitBash ? '/' : '\\';
786+
return TerminalCompletionList.from(completions, pathSeparator);
785787
}
786788

787789
public $acceptTerminalShellType(id: number, shellType: TerminalShellType | undefined): void {

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import { parse, revive } from '../../../base/common/marshalling.js';
1616
import { MarshalledId } from '../../../base/common/marshallingIds.js';
1717
import { Mimes } from '../../../base/common/mime.js';
1818
import { cloneAndChange } from '../../../base/common/objects.js';
19-
import { isWindows } from '../../../base/common/platform.js';
2019
import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js';
2120
import { basename } from '../../../base/common/resources.js';
2221
import { ThemeIcon } from '../../../base/common/themables.js';
@@ -3188,24 +3187,24 @@ export namespace TerminalCompletionItemDto {
31883187
}
31893188

31903189
export namespace TerminalCompletionList {
3191-
export function from(completions: vscode.TerminalCompletionList | vscode.TerminalCompletionItem[]): extHostProtocol.TerminalCompletionListDto {
3190+
export function from(completions: vscode.TerminalCompletionList | vscode.TerminalCompletionItem[], pathSeparator: string): extHostProtocol.TerminalCompletionListDto {
31923191
if (Array.isArray(completions)) {
31933192
return {
31943193
items: completions.map(i => TerminalCompletionItemDto.from(i)),
31953194
};
31963195
}
31973196
return {
31983197
items: completions.items.map(i => TerminalCompletionItemDto.from(i)),
3199-
resourceRequestConfig: completions.resourceRequestConfig ? TerminalResourceRequestConfig.from(completions.resourceRequestConfig) : undefined,
3198+
resourceRequestConfig: completions.resourceRequestConfig ? TerminalResourceRequestConfig.from(completions.resourceRequestConfig, pathSeparator) : undefined,
32003199
};
32013200
}
32023201
}
32033202

32043203
export namespace TerminalResourceRequestConfig {
3205-
export function from(resourceRequestConfig: vscode.TerminalResourceRequestConfig): extHostProtocol.TerminalResourceRequestConfigDto {
3204+
export function from(resourceRequestConfig: vscode.TerminalResourceRequestConfig, pathSeparator: string): extHostProtocol.TerminalResourceRequestConfigDto {
32063205
return {
32073206
...resourceRequestConfig,
3208-
pathSeparator: isWindows ? '\\' : '/',
3207+
pathSeparator,
32093208
cwd: resourceRequestConfig.cwd ? URI.revive(resourceRequestConfig.cwd) : undefined,
32103209
};
32113210
}

src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ import { IConfigurationService } from '../../../../../platform/configuration/com
1111
import { IFileService } from '../../../../../platform/files/common/files.js';
1212
import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js';
1313
import { TerminalCapability, type ITerminalCapabilityStore } from '../../../../../platform/terminal/common/capabilities/capabilities.js';
14-
import { GeneralShellType, TerminalShellType } from '../../../../../platform/terminal/common/terminal.js';
14+
import { GeneralShellType, TerminalShellType, WindowsShellType } from '../../../../../platform/terminal/common/terminal.js';
1515
import { TerminalSuggestSettingId } from '../common/terminalSuggestConfiguration.js';
1616
import { TerminalCompletionItemKind, type ITerminalCompletion } from './terminalCompletionItem.js';
1717
import { env as processEnv } from '../../../../../base/common/process.js';
1818
import type { IProcessEnvironment } from '../../../../../base/common/platform.js';
1919
import { timeout } from '../../../../../base/common/async.js';
20+
import { gitBashPathToWindows } from './terminalGitBashHelpers.js';
2021

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

@@ -190,7 +191,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
190191
return completionItems;
191192
}
192193
if (completions.resourceRequestConfig) {
193-
const resourceCompletions = await this.resolveResources(completions.resourceRequestConfig, promptValue, cursorPosition, provider.id, capabilities);
194+
const resourceCompletions = await this.resolveResources(completions.resourceRequestConfig, promptValue, cursorPosition, provider.id, capabilities, shellType);
194195
if (resourceCompletions) {
195196
completionItems.push(...resourceCompletions);
196197
}
@@ -202,7 +203,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
202203
return results.filter(result => !!result).flat();
203204
}
204205

205-
async resolveResources(resourceRequestConfig: TerminalResourceRequestConfig, promptValue: string, cursorPosition: number, provider: string, capabilities: ITerminalCapabilityStore): Promise<ITerminalCompletion[] | undefined> {
206+
async resolveResources(resourceRequestConfig: TerminalResourceRequestConfig, promptValue: string, cursorPosition: number, provider: string, capabilities: ITerminalCapabilityStore, shellType?: TerminalShellType): Promise<ITerminalCompletion[] | undefined> {
206207
const useWindowsStylePath = resourceRequestConfig.pathSeparator === '\\';
207208
if (useWindowsStylePath) {
208209
// for tests, make sure the right path separator is used
@@ -280,7 +281,11 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
280281
break;
281282
}
282283
case 'absolute': {
283-
lastWordFolderResource = URI.file(lastWordFolder.replaceAll('\\ ', ' '));
284+
if (shellType === WindowsShellType.GitBash) {
285+
lastWordFolderResource = URI.file(gitBashPathToWindows(lastWordFolder, this._processEnv.SystemDrive));
286+
} else {
287+
lastWordFolderResource = URI.file(lastWordFolder.replaceAll('\\ ', ' '));
288+
}
284289
break;
285290
}
286291
case 'relative': {
@@ -349,7 +354,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
349354
label,
350355
provider,
351356
kind: TerminalCompletionItemKind.Folder,
352-
detail: getFriendlyPath(lastWordFolderResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
357+
detail: getFriendlyPath(lastWordFolderResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
353358
replacementIndex: cursorPosition - lastWord.length,
354359
replacementLength: lastWord.length
355360
});
@@ -394,7 +399,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
394399
label,
395400
provider,
396401
kind,
397-
detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind),
402+
detail: getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType),
398403
replacementIndex: cursorPosition - lastWord.length,
399404
replacementLength: lastWord.length
400405
});
@@ -420,8 +425,8 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
420425
}
421426
const useRelative = config === 'relative';
422427
const kind = TerminalCompletionItemKind.Folder;
423-
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind);
424-
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind)}` : `CDPATH`;
428+
const label = useRelative ? basename(child.resource.fsPath) : getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType);
429+
const detail = useRelative ? `CDPATH ${getFriendlyPath(child.resource, resourceRequestConfig.pathSeparator, kind, shellType)}` : `CDPATH`;
425430
resourceCompletions.push({
426431
label,
427432
provider,
@@ -453,7 +458,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
453458
label,
454459
provider,
455460
kind: TerminalCompletionItemKind.Folder,
456-
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
461+
detail: getFriendlyPath(parentDir, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
457462
replacementIndex: cursorPosition - lastWord.length,
458463
replacementLength: lastWord.length
459464
});
@@ -478,7 +483,7 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
478483
label: '~',
479484
provider,
480485
kind: TerminalCompletionItemKind.Folder,
481-
detail: typeof homeResource === 'string' ? homeResource : getFriendlyPath(homeResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder),
486+
detail: typeof homeResource === 'string' ? homeResource : getFriendlyPath(homeResource, resourceRequestConfig.pathSeparator, TerminalCompletionItemKind.Folder, shellType),
482487
replacementIndex: cursorPosition - lastWord.length,
483488
replacementLength: lastWord.length
484489
});
@@ -500,14 +505,15 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo
500505
}
501506
}
502507

503-
function getFriendlyPath(uri: URI, pathSeparator: string, kind: TerminalCompletionItemKind): string {
508+
function getFriendlyPath(uri: URI, pathSeparator: string, kind: TerminalCompletionItemKind, shellType?: TerminalShellType): string {
504509
let path = uri.fsPath;
510+
const sep = shellType === WindowsShellType.GitBash ? '\\' : pathSeparator;
505511
// Ensure folders end with the path separator to differentiate presentation from files
506-
if (kind === TerminalCompletionItemKind.Folder && !path.endsWith(pathSeparator)) {
507-
path += pathSeparator;
512+
if (kind === TerminalCompletionItemKind.Folder && !path.endsWith(sep)) {
513+
path += sep;
508514
}
509515
// Ensure drive is capitalized on Windows
510-
if (pathSeparator === '\\' && path.match(/^[a-zA-Z]:\\/)) {
516+
if (sep === '\\' && path.match(/^[a-zA-Z]:\\/)) {
511517
path = `${path[0].toUpperCase()}:${path.slice(2)}`;
512518
}
513519
return path;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
/**
7+
* Converts a Git Bash absolute path to a Windows absolute path.
8+
* Examples:
9+
* "/" => "C:\\"
10+
* "/c/" => "C:\\"
11+
* "/c/Users/foo" => "C:\\Users\\foo"
12+
* "/d/bar" => "D:\\bar"
13+
*/
14+
export function gitBashPathToWindows(path: string, driveLetter?: string): string {
15+
// Dynamically determine the system drive (default to 'C:' if not set)
16+
const systemDrive = (driveLetter || 'C:').toUpperCase();
17+
// Handle root "/"
18+
if (path === '/') {
19+
return `${systemDrive}\\`;
20+
}
21+
const match = path.match(/^\/([a-zA-Z])(\/.*)?$/);
22+
if (match) {
23+
const drive = match[1].toUpperCase();
24+
const rest = match[2] ? match[2].replace(/\//g, '\\') : '\\';
25+
return `${drive}:${rest}`;
26+
}
27+
// Fallback: just replace slashes
28+
return path.replace(/\//g, '\\');
29+
}
30+
31+
/**
32+
*
33+
* @param path A Windows-style absolute path (e.g., "C:\Users\foo").
34+
* Converts it to a Git Bash-style absolute path (e.g., "/c/Users/foo").
35+
* @returns The Git Bash-style absolute path.
36+
*/
37+
export function windowsToGitBashPath(path: string): string {
38+
// Convert Windows path (e.g. C:\Users\foo) to Git Bash path (e.g. /c/Users/foo)
39+
return path
40+
.replace(/^[a-zA-Z]:\\/, match => `/${match[0].toLowerCase()}/`)
41+
.replace(/\\/g, '/');
42+
}

src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { ShellEnvDetectionCapability } from '../../../../../../platform/terminal
1818
import { TerminalCapability } from '../../../../../../platform/terminal/common/capabilities/capabilities.js';
1919
import { ITerminalCompletion, TerminalCompletionItemKind } from '../../browser/terminalCompletionItem.js';
2020
import { count } from '../../../../../../base/common/strings.js';
21+
import { WindowsShellType } from '../../../../../../platform/terminal/common/terminal.js';
22+
import { gitBashPathToWindows, windowsToGitBashPath } from '../../browser/terminalGitBashHelpers.js';
2123

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

@@ -35,7 +37,8 @@ interface IAssertionCommandLineConfig {
3537
/**
3638
* Assert the set of completions exist exactly, including their order.
3739
*/
38-
function assertCompletions(actual: ITerminalCompletion[] | undefined, expected: IAssertionTerminalCompletion[], expectedConfig: IAssertionCommandLineConfig) {
40+
function assertCompletions(actual: ITerminalCompletion[] | undefined, expected: IAssertionTerminalCompletion[], expectedConfig: IAssertionCommandLineConfig, pathSep?: string) {
41+
const sep = pathSep ?? pathSeparator;
3942
assert.deepStrictEqual(
4043
actual?.map(e => ({
4144
label: e.label,
@@ -44,8 +47,8 @@ function assertCompletions(actual: ITerminalCompletion[] | undefined, expected:
4447
replacementIndex: e.replacementIndex,
4548
replacementLength: e.replacementLength,
4649
})), expected.map(e => ({
47-
label: e.label.replaceAll('/', pathSeparator),
48-
detail: e.detail ? e.detail.replaceAll('/', pathSeparator) : '',
50+
label: e.label.replaceAll('/', sep),
51+
detail: e.detail ? e.detail.replaceAll('/', sep) : '',
4952
kind: e.kind ?? TerminalCompletionItemKind.Folder,
5053
replacementIndex: expectedConfig.replacementIndex,
5154
replacementLength: expectedConfig.replacementLength,
@@ -608,4 +611,70 @@ suite('TerminalCompletionService', () => {
608611
], { replacementIndex: 3, replacementLength: 0 });
609612
});
610613
});
614+
615+
if (isWindows) {
616+
suite('gitbash', () => {
617+
test('should convert Git Bash absolute path to Windows absolute path', () => {
618+
assert.strictEqual(gitBashPathToWindows('/'), 'C:\\');
619+
assert.strictEqual(gitBashPathToWindows('/c/'), 'C:\\');
620+
assert.strictEqual(gitBashPathToWindows('/c/Users/foo'), 'C:\\Users\\foo');
621+
assert.strictEqual(gitBashPathToWindows('/d/bar'), 'D:\\bar');
622+
});
623+
624+
test('should convert Windows absolute path to Git Bash absolute path', () => {
625+
assert.strictEqual(windowsToGitBashPath('C:\\'), '/c/');
626+
assert.strictEqual(windowsToGitBashPath('C:\\Users\\foo'), '/c/Users/foo');
627+
assert.strictEqual(windowsToGitBashPath('D:\\bar'), '/d/bar');
628+
assert.strictEqual(windowsToGitBashPath('E:\\some\\path'), '/e/some/path');
629+
});
630+
test('resolveResources with cwd as Windows path (relative)', async () => {
631+
const resourceRequestConfig: TerminalResourceRequestConfig = {
632+
cwd: URI.file('C:\\Users\\foo'),
633+
foldersRequested: true,
634+
filesRequested: true,
635+
pathSeparator: '/'
636+
};
637+
validResources = [
638+
URI.file('C:\\Users\\foo'),
639+
URI.file('C:\\Users\\foo\\bar'),
640+
URI.file('C:\\Users\\foo\\baz.txt')
641+
];
642+
childResources = [
643+
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
644+
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
645+
];
646+
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 2, provider, capabilities, WindowsShellType.GitBash);
647+
assertCompletions(result, [
648+
{ label: './', detail: 'C:\\Users\\foo\\' },
649+
{ label: './bar/', detail: 'C:\\Users\\foo\\bar\\' },
650+
{ label: './baz.txt', detail: 'C:\\Users\\foo\\baz.txt', kind: TerminalCompletionItemKind.File },
651+
{ label: './../', detail: 'C:\\Users\\' }
652+
], { replacementIndex: 0, replacementLength: 2 }, '/');
653+
});
654+
655+
test('resolveResources with cwd as Windows path (absolute)', async () => {
656+
const resourceRequestConfig: TerminalResourceRequestConfig = {
657+
cwd: URI.file('C:\\Users\\foo'),
658+
foldersRequested: true,
659+
filesRequested: true,
660+
pathSeparator: '/'
661+
};
662+
validResources = [
663+
URI.file('C:\\Users\\foo'),
664+
URI.file('C:\\Users\\foo\\bar'),
665+
URI.file('C:\\Users\\foo\\baz.txt')
666+
];
667+
childResources = [
668+
{ resource: URI.file('C:\\Users\\foo\\bar'), isDirectory: true },
669+
{ resource: URI.file('C:\\Users\\foo\\baz.txt'), isFile: true }
670+
];
671+
const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '/c/Users/foo/', 13, provider, capabilities, WindowsShellType.GitBash);
672+
assertCompletions(result, [
673+
{ label: '/c/Users/foo/', detail: 'C:\\Users\\foo\\' },
674+
{ label: '/c/Users/foo/bar/', detail: 'C:\\Users\\foo\\bar\\' },
675+
{ label: '/c/Users/foo/baz.txt', detail: 'C:\\Users\\foo\\baz.txt', kind: TerminalCompletionItemKind.File },
676+
], { replacementIndex: 0, replacementLength: 13 }, '/');
677+
});
678+
});
679+
}
611680
});

0 commit comments

Comments
 (0)