diff --git a/src/extension/extension/vscode-node/services.ts b/src/extension/extension/vscode-node/services.ts index e376417326..9ebfb56351 100644 --- a/src/extension/extension/vscode-node/services.ts +++ b/src/extension/extension/vscode-node/services.ts @@ -44,6 +44,8 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ import { FetcherService } from '../../../platform/networking/vscode-node/fetcherServiceImpl'; import { IParserService } from '../../../platform/parser/node/parserService'; import { ParserServiceImpl } from '../../../platform/parser/node/parserServiceImpl'; +import { IPromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService'; +import { PromptPathRepresentationServiceNode } from '../../../platform/prompts/node/promptPathRepresentationServiceNode'; import { IProxyModelsService } from '../../../platform/proxyModels/common/proxyModelsService'; import { ProxyModelsService } from '../../../platform/proxyModels/node/proxyModelsService'; import { AdoCodeSearchService, IAdoCodeSearchService } from '../../../platform/remoteCodeSearch/common/adoCodeSearchService'; @@ -75,6 +77,8 @@ import { IWorkspaceFileIndex, WorkspaceFileIndex } from '../../../platform/works import { IInstantiationServiceBuilder } from '../../../util/common/services'; import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors'; import { CommandServiceImpl, ICommandService } from '../../commands/node/commandService'; +import { ICopilotInlineCompletionItemProviderService } from '../../completions/common/copilotInlineCompletionItemProviderService'; +import { CopilotInlineCompletionItemProviderService } from '../../completions/vscode-node/copilotInlineCompletionItemProviderService'; import { ApiEmbeddingsIndex, IApiEmbeddingsIndex } from '../../context/node/resolvers/extensionApi'; import { IPromptWorkspaceLabels, PromptWorkspaceLabels } from '../../context/node/resolvers/promptWorkspaceLabels'; import { ChatAgentService } from '../../conversation/vscode-node/chatParticipants'; @@ -111,8 +115,6 @@ import { LanguageContextServiceImpl } from '../../typescriptContext/vscode-node/ import { IWorkspaceListenerService } from '../../workspaceRecorder/common/workspaceListenerService'; import { WorkspacListenerService } from '../../workspaceRecorder/vscode-node/workspaceListenerService'; import { registerServices as registerCommonServices } from '../vscode/services'; -import { ICopilotInlineCompletionItemProviderService } from '../../completions/common/copilotInlineCompletionItemProviderService'; -import { CopilotInlineCompletionItemProviderService } from '../../completions/vscode-node/copilotInlineCompletionItemProviderService'; // ########################################################################################### // ### ### @@ -213,6 +215,7 @@ export function registerServices(builder: IInstantiationServiceBuilder, extensio builder.define(IProxyModelsService, new SyncDescriptor(ProxyModelsService)); builder.define(IInlineEditsModelService, new SyncDescriptor(InlineEditsModelService)); builder.define(ICopilotInlineCompletionItemProviderService, new SyncDescriptor(CopilotInlineCompletionItemProviderService)); + builder.define(IPromptPathRepresentationService, new SyncDescriptor(PromptPathRepresentationServiceNode)); } function setupMSFTExperimentationService(builder: IInstantiationServiceBuilder, extensionContext: ExtensionContext) { diff --git a/src/platform/prompts/node/promptPathRepresentationServiceNode.ts b/src/platform/prompts/node/promptPathRepresentationServiceNode.ts new file mode 100644 index 0000000000..de5b5a4850 --- /dev/null +++ b/src/platform/prompts/node/promptPathRepresentationServiceNode.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { execFileSync } from 'child_process'; +import type { Uri } from 'vscode'; +import { ILogService } from '../../../platform/log/common/logService'; +import { LRUCache } from '../../../util/vs/base/common/map'; +import { PromptPathRepresentationService } from '../common/promptPathRepresentationService'; + +/** + * Pattern to detect 8.3 short filename segments (e.g., PROGRA~1, DOCUME~2) + * Format: 1-6 characters, followed by tilde and 1+ digits + */ +const SHORT_NAME_SEGMENT_PATTERN = /^[^~]{1,6}~\d+$/i; + +/** + * Checks if a path segment looks like an 8.3 short filename + */ +function isShortNameSegment(segment: string): boolean { + // Remove extension if present for checking the base name + const dotIndex = segment.lastIndexOf('.'); + const baseName = dotIndex > 0 ? segment.substring(0, dotIndex) : segment; + return SHORT_NAME_SEGMENT_PATTERN.test(baseName); +} + +/** + * Checks if a Windows path contains any 8.3 short name segments + */ +function containsShortNameSegments(filePath: string): boolean { + const segments = filePath.split(/[\\/]/); + return segments.some(isShortNameSegment); +} + +export class PromptPathRepresentationServiceNode extends PromptPathRepresentationService { + /** + * Cache mapping short path prefixes to their resolved long form. + * For example: "C:\PROGRA~1" -> "C:\Program Files" + * This allows reuse across files in the same short-named directory. + */ + private readonly _shortPrefixToLongPath = new LRUCache(64); + + constructor( + @ILogService private readonly _logService: ILogService, + ) { + super(); + } + + override getFilePath(uri: Uri): string { + let filePath = super.getFilePath(uri); + if (this.isWindows()) { + filePath = this._tryResolveLongPath(filePath); + } + return filePath; + } + + override resolveFilePath(filepath: string, predominantScheme?: string): Uri | undefined { + if (this.isWindows()) { + filepath = this._tryResolveLongPath(filepath); + } + return super.resolveFilePath(filepath, predominantScheme); + } + + /** + * Attempts to resolve 8.3 short paths to their long form by resolving each + * short segment individually. This allows caching of directory prefixes + * for reuse across multiple files. + * + * For example, given paths: + * - `C:\PROGRA~1\foo.txt` + * - `C:\PROGRA~1\bar.txt` + * + * Only one shell call is needed because `C:\PROGRA~1` is cached after the first resolution. + */ + private _tryResolveLongPath(filePath: string): string { + if (!containsShortNameSegments(filePath)) { + return filePath; + } + + // Detect the separator used in the path + const separator = filePath.includes('/') ? '/' : '\\'; + const segments = filePath.split(/[\\/]/); + const resolvedSegments: string[] = []; + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + resolvedSegments.push(segment); + + if (!isShortNameSegment(segment)) { + continue; + } + + // Build the current prefix path (with short segment) + const shortPrefix = resolvedSegments.join(separator); + const cacheKey = shortPrefix.toLowerCase(); + + // Check cache first + const cached = this._shortPrefixToLongPath.get(cacheKey); + if (cached !== undefined) { + // Replace resolvedSegments with the cached long path segments + resolvedSegments.length = 0; + resolvedSegments.push(...cached.split(/[\\/]/)); + continue; + } + + // Resolve via shell + try { + const longPrefix = this._resolveLongPathViaShell(shortPrefix); + this._shortPrefixToLongPath.set(cacheKey, longPrefix); + // Replace resolvedSegments with the resolved long path segments + resolvedSegments.length = 0; + resolvedSegments.push(...longPrefix.split(/[\\/]/)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this._logService.warn(`Failed to resolve 8.3 short path "${shortPrefix}": ${errorMessage}`); + // Cache the short prefix to avoid repeated failures + this._shortPrefixToLongPath.set(cacheKey, shortPrefix); + } + } + + return resolvedSegments.join(separator); + } + + /** + * Uses PowerShell to resolve an 8.3 short path to its long form. + * Uses environment variables to safely pass the path without shell injection risks. + * Protected to allow overriding in tests. + */ + protected _resolveLongPathViaShell(shortPath: string): string { + const result = execFileSync('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-NoLogo', + '-Command', + '(Get-Item $env:VSCODE_SHORT_PATH).FullName' + ], { + encoding: 'utf8', + timeout: 5000, + env: { + ...process.env, + VSCODE_SHORT_PATH: shortPath + }, + windowsHide: true + }); + + return result.trim(); + } +} diff --git a/src/platform/prompts/test/node/promptPathRepresentationServiceNode.spec.ts b/src/platform/prompts/test/node/promptPathRepresentationServiceNode.spec.ts new file mode 100644 index 0000000000..4961a3a925 --- /dev/null +++ b/src/platform/prompts/test/node/promptPathRepresentationServiceNode.spec.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { isWindows } from '../../../../util/vs/base/common/platform'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { TestLogService } from '../../../testing/common/testLogService'; +import { PromptPathRepresentationServiceNode } from '../../node/promptPathRepresentationServiceNode'; + +/** + * Test subclass that mocks shell resolution and simulates Windows environment. + */ +class TestPromptPathRepresentationServiceNode extends PromptPathRepresentationServiceNode { + readonly shellCalls: string[] = []; + private readonly _resolutions: Map; + + constructor(resolutions: Record = {}) { + super(new TestLogService()); + this._resolutions = new Map(Object.entries(resolutions)); + } + + override isWindows(): boolean { + return true; + } + + protected override _resolveLongPathViaShell(shortPath: string): string { + this.shellCalls.push(shortPath); + const resolved = this._resolutions.get(shortPath); + if (resolved === undefined) { + throw new Error(`No resolution configured for: ${shortPath}`); + } + return resolved; + } +} + +describe('PromptPathRepresentationServiceNode', () => { + if (isWindows) { // on posix the fsPath transformations are different + describe('8.3 short path resolution', () => { + it('returns path unchanged when no short segments present', () => { + const service = new TestPromptPathRepresentationServiceNode(); + const result = service.resolveFilePath('C:\\Program Files\\app\\file.txt'); + + expect(result?.fsPath).toBe('c:\\Program Files\\app\\file.txt'); + expect(service.shellCalls).toHaveLength(0); + }); + + it('resolves single short segment', () => { + const service = new TestPromptPathRepresentationServiceNode({ + 'C:\\PROGRA~1': 'C:\\Program Files', + }); + + const result = service.resolveFilePath('C:\\PROGRA~1\\app\\file.txt'); + + expect(result?.fsPath).toBe('c:\\Program Files\\app\\file.txt'); + expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); + }); + + it('resolves multiple short segments with separate shell calls', () => { + const service = new TestPromptPathRepresentationServiceNode({ + 'C:\\PROGRA~1': 'C:\\Program Files', + 'C:\\Program Files\\MICROS~1': 'C:\\Program Files\\Microsoft Office', + }); + + const result = service.resolveFilePath('C:\\PROGRA~1\\MICROS~1\\file.txt'); + + expect(result?.fsPath).toBe('c:\\Program Files\\Microsoft Office\\file.txt'); + expect(service.shellCalls).toEqual([ + 'C:\\PROGRA~1', + 'C:\\Program Files\\MICROS~1', + ]); + }); + + it('caches resolved prefixes across multiple calls', () => { + const service = new TestPromptPathRepresentationServiceNode({ + 'C:\\PROGRA~1': 'C:\\Program Files', + }); + + service.resolveFilePath('C:\\PROGRA~1\\foo.txt'); + service.resolveFilePath('C:\\PROGRA~1\\bar.txt'); + service.resolveFilePath('C:\\PROGRA~1\\subdir\\baz.txt'); + + expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); + }); + + it('handles short segment with file extension', () => { + const service = new TestPromptPathRepresentationServiceNode({ + 'C:\\MYFILE~1.TXT': 'C:\\MyLongFileName.txt', + }); + + const result = service.resolveFilePath('C:\\MYFILE~1.TXT'); + + expect(result?.fsPath).toBe('c:\\MyLongFileName.txt'); + }); + + it('falls back to original path on resolution error', () => { + const service = new TestPromptPathRepresentationServiceNode({}); + + const result = service.resolveFilePath('C:\\PROGRA~1\\file.txt'); + + expect(result?.fsPath).toBe('c:\\PROGRA~1\\file.txt'); + expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); + }); + + it('caches failed resolutions to avoid repeated attempts', () => { + const service = new TestPromptPathRepresentationServiceNode({}); + + service.resolveFilePath('C:\\PROGRA~1\\foo.txt'); + service.resolveFilePath('C:\\PROGRA~1\\bar.txt'); + + expect(service.shellCalls).toEqual(['C:\\PROGRA~1']); + }); + + it('works with getFilePath for file URIs', () => { + const service = new TestPromptPathRepresentationServiceNode({ + 'c:\\PROGRA~1': 'c:\\Program Files', + }); + + const uri = URI.file('C:\\PROGRA~1\\app\\file.txt'); + const result = service.getFilePath(uri); + + expect(result).toBe('c:\\Program Files\\app\\file.txt'); + }); + }); + } else { + it('nothing', () => { + // avoid failing on posix + }); + } +});