diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts index c7af21695a137..f4ad462b82b91 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalCompletionService.ts @@ -2,10 +2,12 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../../base/common/network.js'; import { basename } from '../../../../../base/common/path.js'; +import { isWindows } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -62,6 +64,7 @@ export interface TerminalResourceRequestConfig { foldersRequested?: boolean; cwd?: URI; pathSeparator: string; + shouldNormalizePrefix?: boolean; } @@ -183,6 +186,10 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo } async resolveResources(resourceRequestConfig: TerminalResourceRequestConfig, promptValue: string, cursorPosition: number, provider: string): Promise { + if (resourceRequestConfig.shouldNormalizePrefix) { + // for tests, make sure the right path separator is used + promptValue = promptValue.replaceAll(/[\\/]/g, resourceRequestConfig.pathSeparator); + } const cwd = URI.revive(resourceRequestConfig.cwd); const foldersRequested = resourceRequestConfig.foldersRequested ?? false; const filesRequested = resourceRequestConfig.filesRequested ?? false; @@ -197,46 +204,128 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo const resourceCompletions: ITerminalCompletion[] = []; const cursorPrefix = promptValue.substring(0, cursorPosition); - const endsWithSpace = cursorPrefix.endsWith(' '); - const lastWord = endsWithSpace ? '' : cursorPrefix.split(' ').at(-1) ?? ''; - for (const stat of fileStat.children) { - let kind: TerminalCompletionItemKind | undefined; - if (foldersRequested && stat.isDirectory) { - kind = TerminalCompletionItemKind.Folder; - } - if (filesRequested && !stat.isDirectory && (stat.isFile || stat.resource.scheme === Schemas.file)) { - kind = TerminalCompletionItemKind.File; - } - if (kind === undefined) { - continue; - } - const isDirectory = kind === TerminalCompletionItemKind.Folder; - const fileName = basename(stat.resource.fsPath); - - let label; - if (!lastWord.startsWith('.' + resourceRequestConfig.pathSeparator) && !lastWord.startsWith('..' + resourceRequestConfig.pathSeparator)) { - // add a dot to the beginning of the label if it doesn't already have one - label = `.${resourceRequestConfig.pathSeparator}${fileName}`; - } else { - if (lastWord.endsWith(resourceRequestConfig.pathSeparator)) { - label = `${lastWord}${fileName}`; - } else { - label = `${lastWord}${resourceRequestConfig.pathSeparator}${fileName}`; + const useForwardSlash = !resourceRequestConfig.shouldNormalizePrefix && isWindows; + + // The last word (or argument). When the cursor is following a space it will be the empty + // string + const lastWord = cursorPrefix.endsWith(' ') ? '' : cursorPrefix.split(' ').at(-1) ?? ''; + + // 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) { + lastSlashIndex = Math.max(lastWord.lastIndexOf('\\'), lastWord.lastIndexOf('/')); + } else { + lastSlashIndex = lastWord.lastIndexOf(resourceRequestConfig.pathSeparator); + } + + // The _complete_ folder of the last word. For example if the last word is `./src/file`, + // this will be `./src/`. This also always ends in the path separator if it is not the empty + // string and path separators are normalized on Windows. + let lastWordFolder = lastSlashIndex === -1 ? '' : lastWord.slice(0, lastSlashIndex + 1); + if (isWindows) { + lastWordFolder = lastWordFolder.replaceAll('/', '\\'); + } + + const lastWordFolderHasDotPrefix = lastWordFolder.match(/^\.\.?[\\\/]/); + + // 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: getFriendlyFolderPath(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) { + for (const stat of fileStat.children) { + let kind: TerminalCompletionItemKind | undefined; + if (foldersRequested && stat.isDirectory) { + kind = TerminalCompletionItemKind.Folder; } - if (lastWord.length && lastWord.at(-1) !== resourceRequestConfig.pathSeparator && lastWord.at(-1) !== '.') { - label = `.${resourceRequestConfig.pathSeparator}${fileName}`; + if (filesRequested && !stat.isDirectory && (stat.isFile || stat.resource.scheme === Schemas.file)) { + kind = TerminalCompletionItemKind.File; } + if (kind === undefined) { + continue; + } + const isDirectory = kind === TerminalCompletionItemKind.Folder; + const resourceName = basename(stat.resource.fsPath); + + let label = `${lastWordFolder}${resourceName}`; + + // Normalize suggestion to add a ./ prefix to the start of the path if there isn't + // one already. We may want to change this behavior in the future to go with + // whatever format the user has + if (!lastWordFolderHasDotPrefix) { + label = `.${resourceRequestConfig.pathSeparator}${label}`; + } + + // Ensure directories end with a path separator + if (isDirectory && !label.endsWith(resourceRequestConfig.pathSeparator)) { + label = `${label}${resourceRequestConfig.pathSeparator}`; + } + + // Normalize path separator to `\` on Windows. It should act the exact same as `/` but + // suggestions should all use `\` + if (useForwardSlash) { + label = label.replaceAll('/', '\\'); + } + + resourceCompletions.push({ + label, + provider, + kind, + isDirectory, + isFile: kind === TerminalCompletionItemKind.File, + replacementIndex: cursorPosition - lastWord.length, + replacementLength: lastWord.length + }); } - if (isDirectory && !label.endsWith(resourceRequestConfig.pathSeparator)) { - label = label + resourceRequestConfig.pathSeparator; - } + } + + // 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, + label: lastWordFolder + '..' + resourceRequestConfig.pathSeparator, provider, - kind, - isDirectory, - isFile: kind === TerminalCompletionItemKind.File, + kind: TerminalCompletionItemKind.Folder, + detail: getFriendlyFolderPath(parentDir, resourceRequestConfig.pathSeparator), + isDirectory: true, + isFile: false, replacementIndex: cursorPosition - lastWord.length, replacementLength: lastWord.length }); @@ -245,3 +334,16 @@ export class TerminalCompletionService extends Disposable implements ITerminalCo return resourceCompletions.length ? resourceCompletions : undefined; } } + +function getFriendlyFolderPath(uri: URI, pathSeparator: string): string { + let path = uri.fsPath; + // Ensure folders end with the path separator to differentiate presentation from files + if (!path.endsWith(pathSeparator)) { + path += pathSeparator; + } + // Ensure drive is capitalized on Windows + if (pathSeparator === '\\' && path.match(/^[a-zA-Z]:\\/)) { + path = `${path[0].toUpperCase()}:${path.slice(2)}`; + } + return path; +} diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index dc709289fba69..ce5cf56a980b3 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -27,7 +27,7 @@ import { SimpleCompletionItem } from '../../../../services/suggest/browser/simpl import { LineContext, SimpleCompletionModel } from '../../../../services/suggest/browser/simpleCompletionModel.js'; import { ISimpleSelectedSuggestion, SimpleSuggestWidget } from '../../../../services/suggest/browser/simpleSuggestWidget.js'; import type { ISimpleSuggestWidgetFontInfo } from '../../../../services/suggest/browser/simpleSuggestWidgetRenderer.js'; -import { ITerminalCompletion, ITerminalCompletionService, TerminalCompletionItemKind } from './terminalCompletionService.js'; +import { ITerminalCompletionService, TerminalCompletionItemKind } from './terminalCompletionService.js'; import { TerminalShellType } from '../../../../../platform/terminal/common/terminal.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; @@ -58,7 +58,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest private _enableWidget: boolean = true; private _pathSeparator: string = sep; private _isFilteringDirectories: boolean = false; - private _mostRecentCompletion?: ITerminalCompletion; // TODO: Remove these in favor of prompt input state private _leadingLineContent?: string; @@ -193,12 +192,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._leadingLineContent = this._promptInputModel.prefix; } - if (this._mostRecentCompletion?.isDirectory && completions.every(e => e.isDirectory)) { - completions.push(this._mostRecentCompletion); - } - this._mostRecentCompletion = undefined; - - let normalizedLeadingLineContent = this._leadingLineContent; // If there is a single directory in the completions: @@ -504,8 +497,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest SuggestAddon.lastAcceptedCompletionTimestamp = 0; } - this._mostRecentCompletion = completion; - const commonPrefixLen = commonPrefixLength(replacementText, completionText); const commonPrefix = replacementText.substring(replacementText.length - 1 - commonPrefixLen, replacementText.length - 1); const completionSuffix = completionText.substring(commonPrefixLen); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts index 34f73a15d25cf..174e1ea1e5e2a 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/test/browser/terminalCompletionService.test.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { IFileService, IFileStatWithMetadata, IResolveMetadataFileOptions } from '../../../../../../platform/files/common/files.js'; -import { TerminalCompletionService, TerminalCompletionItemKind, TerminalResourceRequestConfig } from '../../browser/terminalCompletionService.js'; +import { TerminalCompletionService, TerminalCompletionItemKind, TerminalResourceRequestConfig, ITerminalCompletion } from '../../browser/terminalCompletionService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import assert from 'assert'; import { isWindows } from '../../../../../../base/common/platform.js'; @@ -14,15 +14,45 @@ import { createFileStat } from '../../../../../test/common/workbenchTestServices import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +const pathSeparator = isWindows ? '\\' : '/'; + +interface IAssertionTerminalCompletion { + label: string; + detail?: string; + kind?: TerminalCompletionItemKind; +} + +interface IAssertionCommandLineConfig { + replacementIndex: number; + replacementLength: number; +} + +function assertCompletions(actual: ITerminalCompletion[] | undefined, expected: IAssertionTerminalCompletion[], expectedConfig: IAssertionCommandLineConfig) { + assert.deepStrictEqual( + actual?.map(e => ({ + label: e.label, + detail: e.detail ?? '', + kind: e.kind ?? TerminalCompletionItemKind.Folder, + replacementIndex: e.replacementIndex, + replacementLength: e.replacementLength, + })), expected.map(e => ({ + label: e.label.replaceAll('/', pathSeparator), + detail: e.detail ? e.detail.replaceAll('/', pathSeparator) : '', + kind: e.kind ?? TerminalCompletionItemKind.Folder, + replacementIndex: expectedConfig.replacementIndex, + replacementLength: expectedConfig.replacementLength, + })) + ); +} + suite('TerminalCompletionService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; let configurationService: TestConfigurationService; let validResources: URI[]; let childResources: { resource: URI; isFile?: boolean; isDirectory?: boolean }[]; - const pathSeparator = isWindows ? '\\' : '/'; let terminalCompletionService: TerminalCompletionService; - const provider: string = 'testProvider'; + const provider = 'testProvider'; setup(() => { instantiationService = store.add(new TestInstantiationService()); @@ -37,7 +67,7 @@ suite('TerminalCompletionService', () => { }, async resolve(resource: URI, options: IResolveMetadataFileOptions): Promise { return createFileStat(resource, undefined, undefined, undefined, childResources); - } + }, }); terminalCompletionService = store.add(instantiationService.createInstance(TerminalCompletionService)); validResources = []; @@ -46,9 +76,7 @@ suite('TerminalCompletionService', () => { suite('resolveResources should return undefined', () => { test('if cwd is not provided', async () => { - const resourceRequestConfig: TerminalResourceRequestConfig = { - pathSeparator - }; + const resourceRequestConfig: TerminalResourceRequestConfig = { pathSeparator }; const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ', 3, provider); assert(!result); }); @@ -67,129 +95,261 @@ suite('TerminalCompletionService', () => { suite('resolveResources should return folder completions', () => { setup(() => { validResources = [URI.parse('file:///test')]; - const childFolder = { resource: URI.parse('file:///test/folder1/'), name: 'folder1', isDirectory: true, isFile: false }; - const childFile = { resource: URI.parse('file:///test/file1.txt'), name: 'file1.txt', isDirectory: false, isFile: true }; - childResources = [childFolder, childFile]; + childResources = [ + { resource: URI.parse('file:///test/folder1/'), isDirectory: true }, + { resource: URI.parse('file:///test/file1.txt'), isFile: true }, + ]; }); - test('|', async () => { + test('| should return root-level completions', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, pathSeparator }; const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '', 1, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 1, - replacementLength: 0 - }]); + + assertCompletions(result, [ + { label: '.', detail: '/test/' }, + { label: './folder1/' }, + { label: '../', detail: '/' }, + ], { replacementIndex: 1, replacementLength: 0 }); }); - test('.|', async () => { + + test('./| should return folder completions', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '.', 2, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 1, - replacementLength: 1 - }]); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 3, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './folder1/' }, + { label: './../', detail: '/' }, + ], { replacementIndex: 1, replacementLength: 2 }); }); - test('./|', async () => { + + test('cd ./| should return folder completions', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 3, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 1, - replacementLength: 2 - }]); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ./', 5, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './folder1/' }, + { label: './../', detail: '/' }, + ], { replacementIndex: 3, replacementLength: 2 }); }); - test('cd |', async () => { + test('cd ./f| should return folder completions', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ', 3, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 3, - replacementLength: 0 - }]); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ./f', 6, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './folder1/' }, + { label: './../', detail: '/' }, + ], { replacementIndex: 3, replacementLength: 3 }); + }); + }); + + suite('resolveResources should handle file and folder completion requests correctly', () => { + setup(() => { + validResources = [URI.parse('file:///test')]; + childResources = [ + { resource: URI.parse('file:///test/.hiddenFile'), isFile: true }, + { resource: URI.parse('file:///test/.hiddenFolder/'), isDirectory: true }, + { resource: URI.parse('file:///test/folder1/'), isDirectory: true }, + { resource: URI.parse('file:///test/file1.txt'), isFile: true }, + ]; }); - test('cd .|', async () => { + + test('./| should handle hidden files and folders', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + filesRequested: true, + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd .', 4, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 3, - replacementLength: 1 // replacing . - }]); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 2, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './.hiddenFile', kind: TerminalCompletionItemKind.File }, + { label: './.hiddenFolder/' }, + { label: './folder1/' }, + { label: './file1.txt', kind: TerminalCompletionItemKind.File }, + { label: './../', detail: '/' }, + ], { replacementIndex: 0, replacementLength: 2 }); }); - test('cd ./|', async () => { + + test('./h| should handle hidden files and folders', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + filesRequested: true, + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ./', 5, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 3, - replacementLength: 2 // replacing ./ - }]); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './h', 3, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './.hiddenFile', kind: TerminalCompletionItemKind.File }, + { label: './.hiddenFolder/' }, + { label: './folder1/' }, + { label: './file1.txt', kind: TerminalCompletionItemKind.File }, + { label: './../', detail: '/' }, + ], { replacementIndex: 0, replacementLength: 3 }); + }); + }); + suite('resolveResources edge cases and advanced scenarios', () => { + setup(() => { + validResources = []; + childResources = []; }); - test('cd ./f|', async () => { + + if (!isWindows) { + test('/usr/| Missing . should show correct results', 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); + + assertCompletions(result, [ + { label: '/usr/', detail: '/' }, + ], { replacementIndex: 0, replacementLength: 5 }); + }); + } + if (isWindows) { + test('.\\folder | Case insensitivity should resolve correctly on Windows', async () => { + const resourceRequestConfig: TerminalResourceRequestConfig = { + cwd: URI.parse('file:///C:/test'), + foldersRequested: true, + pathSeparator: '\\', + shouldNormalizePrefix: true + }; + + validResources = [URI.parse('file:///C:/test')]; + childResources = [ + { resource: URI.parse('file:///C:/test/FolderA/'), isDirectory: true }, + { resource: URI.parse('file:///C:/test/anotherFolder/'), isDirectory: true } + ]; + + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '.\\folder', 8, provider); + + assertCompletions(result, [ + { label: '.\\', detail: 'C:\\test\\' }, + { label: '.\\FolderA\\' }, + { label: '.\\anotherFolder\\' }, + { label: '.\\..\\', detail: 'C:\\' }, + ], { replacementIndex: 0, replacementLength: 8 }); + }); + } else { + test('./folder | Case sensitivity should resolve correctly on Mac/Unix', async () => { + const resourceRequestConfig: TerminalResourceRequestConfig = { + cwd: URI.parse('file:///test'), + foldersRequested: true, + pathSeparator: '/', + shouldNormalizePrefix: true + }; + validResources = [URI.parse('file:///test')]; + childResources = [ + { resource: URI.parse('file:///test/FolderA/'), isDirectory: true }, + { resource: URI.parse('file:///test/foldera/'), isDirectory: true } + ]; + + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './folder', 8, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './FolderA/' }, + { label: './foldera/' }, + { label: './../', detail: '/' } + ], { replacementIndex: 0, replacementLength: 8 }); + }); + + } + test('| Empty input should resolve to current directory', async () => { const resourceRequestConfig: TerminalResourceRequestConfig = { cwd: URI.parse('file:///test'), foldersRequested: true, - pathSeparator + pathSeparator, + shouldNormalizePrefix: true }; - const result = await terminalCompletionService.resolveResources(resourceRequestConfig, 'cd ./f', 6, provider); - assert.deepEqual(result, [{ - label: `.${pathSeparator}folder1${pathSeparator}`, - provider, - kind: TerminalCompletionItemKind.Folder, - isDirectory: true, - isFile: false, - replacementIndex: 3, - replacementLength: 3 // replacing ./f - }]); + validResources = [URI.parse('file:///test')]; + childResources = [ + { resource: URI.parse('file:///test/folder1/'), isDirectory: true }, + { resource: URI.parse('file:///test/folder2/'), isDirectory: true } + ]; + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, '', 0, provider); + + assertCompletions(result, [ + { label: '.', detail: '/test/' }, + { label: './folder1/' }, + { label: './folder2/' }, + { label: '../', detail: '/' } + ], { replacementIndex: 0, replacementLength: 0 }); + }); + + test('./| Large directory should handle many results gracefully', async () => { + const resourceRequestConfig: TerminalResourceRequestConfig = { + cwd: URI.parse('file:///test'), + foldersRequested: true, + pathSeparator, + shouldNormalizePrefix: true + }; + validResources = [URI.parse('file:///test')]; + childResources = Array.from({ length: 1000 }, (_, i) => ({ + resource: URI.parse(`file:///test/folder${i}/`), + isDirectory: true + })); + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './', 2, provider); + + assert(result); + // includes the 1000 folders + ./ and ./../ + assert.strictEqual(result?.length, 1002); + assert.strictEqual(result[0].label, `.${pathSeparator}`); + assert.strictEqual(result.at(-1)?.label, `.${pathSeparator}..${pathSeparator}`); + }); + + test('./folder| Folders should be resolved even if the trailing / is missing', async () => { + const resourceRequestConfig: TerminalResourceRequestConfig = { + cwd: URI.parse('file:///test'), + foldersRequested: true, + pathSeparator, + shouldNormalizePrefix: true + }; + validResources = [URI.parse('file:///test')]; + childResources = [ + { resource: URI.parse('file:///test/folder1/'), isDirectory: true }, + { resource: URI.parse('file:///test/folder2/'), isDirectory: true } + ]; + const result = await terminalCompletionService.resolveResources(resourceRequestConfig, './folder1', 10, provider); + + assertCompletions(result, [ + { label: './', detail: '/test/' }, + { label: './folder1/' }, + { label: './folder2/' }, + { label: './../', detail: '/' } + ], { replacementIndex: 1, replacementLength: 9 }); }); }); }); diff --git a/src/vs/workbench/services/suggest/browser/simpleCompletionModel.ts b/src/vs/workbench/services/suggest/browser/simpleCompletionModel.ts index c80732669d75f..e7b52f603436a 100644 --- a/src/vs/workbench/services/suggest/browser/simpleCompletionModel.ts +++ b/src/vs/workbench/services/suggest/browser/simpleCompletionModel.ts @@ -208,6 +208,10 @@ export class SimpleCompletionModel { // Then by file extension length ascending score = a.fileExtLow.length - b.fileExtLow.length; } + if (score === 0 || fileExtScore(a.fileExtLow) === 0 && fileExtScore(b.fileExtLow) === 0) { + // both files or directories, sort alphabetically + score = a.completion.label.localeCompare(b.completion.label); + } return score; }); this._refilterKind = Refilter.Nothing;