Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/extension/extension/vscode-node/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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';

// ###########################################################################################
// ### ###
Expand Down Expand Up @@ -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) {
Expand Down
148 changes: 148 additions & 0 deletions src/platform/prompts/node/promptPathRepresentationServiceNode.ts
Original file line number Diff line number Diff line change
@@ -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;
Copy link

Copilot AI Dec 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The regex pattern for 8.3 short names is incorrect. According to the 8.3 naming convention, the base name can be up to 8 characters (not 1-6), and the tilde-number suffix is part of those 8 characters. For example, "PROGRA1" has 6 chars before the tilde, but valid short names like "ABCDEFG1" would have 7 chars before the tilde. The pattern should be /^[^~]{1,8}~\d+$/i to match the actual 8.3 format, or more precisely /^.{1,6}~\d+$/ since the characters before the tilde (plus the tilde and digit) should total at most 8 characters for the base name.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copilot is wrong here, an 8.3 name can be up to 8 characters and therefore the prefix length can be 6 characters followed by a tilde and a digit


/**
* 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<string, string>(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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* 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<string, string>;

constructor(resolutions: Record<string, string> = {}) {
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');
});
});
}
});
Loading