From 36883086e954a9c03196617594a83dcfd777795d Mon Sep 17 00:00:00 2001 From: "vs-code-engineering[bot]" Date: Mon, 31 Aug 2026 18:22:41 +0000 Subject: [PATCH] [cherry-pick] nes: fix: normalize Windows workspace path casing --- .../xtab/common/promptCraftingUtils.ts | 14 +++-- .../node/xtabPatchResponseHandler.spec.ts | 39 +++++++++++++ .../xtab/test/node/xtabProvider.spec.ts | 55 +++++++++++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/xtab/common/promptCraftingUtils.ts b/extensions/copilot/src/extension/xtab/common/promptCraftingUtils.ts index 7d97eca9457582..aff58c53711be1 100644 --- a/extensions/copilot/src/extension/xtab/common/promptCraftingUtils.ts +++ b/extensions/copilot/src/extension/xtab/common/promptCraftingUtils.ts @@ -5,20 +5,26 @@ import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId'; import { Schemas } from '../../../util/vs/base/common/network'; +import { isWindows } from '../../../util/vs/base/common/platform'; export function toUniquePath(documentId: DocumentId, workspaceRootPath: string | undefined): string { const filePath = documentId.path; - // remove prefix from path if defined const workspaceRootPathWithSlash = workspaceRootPath === undefined ? undefined : (workspaceRootPath.endsWith('/') ? workspaceRootPath : workspaceRootPath + '/'); + const documentScheme = documentId.toUri().scheme; + const isWorkspaceRelative = workspaceRootPathWithSlash !== undefined + && normalizeWindowsDriveLetter(filePath).startsWith(normalizeWindowsDriveLetter(workspaceRootPathWithSlash)); - const updatedFilePath = workspaceRootPathWithSlash !== undefined && filePath.startsWith(workspaceRootPathWithSlash) + const updatedFilePath = isWorkspaceRelative ? filePath.substring(workspaceRootPathWithSlash.length) : filePath; - return documentId.toUri().scheme === Schemas.vscodeNotebookCell ? `${updatedFilePath}#${documentId.fragment}` : updatedFilePath; + return documentScheme === Schemas.vscodeNotebookCell ? `${updatedFilePath}#${documentId.fragment}` : updatedFilePath; +} + +function normalizeWindowsDriveLetter(path: string): string { + return isWindows && /^\/[a-zA-Z]:/.test(path) ? `/${path[1].toLowerCase()}${path.substring(2)}` : path; } export function countTokensForLines(page: string[], computeTokens: (s: string) => number): number { return page.reduce((sum, line) => sum + computeTokens(line) + 1 /* \n */, 0); } - diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index 3eddd1dc2fc364..ee5cf4b37d0727 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -9,6 +9,8 @@ import { NoNextEditReason, StreamedEdit } from '../../../../platform/inlineEdits import { TestLogService } from '../../../../platform/testing/common/testLogService'; import { AsyncIterUtils } from '../../../../util/common/asyncIterableUtils'; import { AsyncIterableSource } from '../../../../util/vs/base/common/async'; +import { isWindows } from '../../../../util/vs/base/common/platform'; +import { URI } from '../../../../util/vs/base/common/uri'; import { LineReplacement } from '../../../../util/vs/editor/common/core/edits/lineEdit'; import { Position } from '../../../../util/vs/editor/common/core/position'; import { LineRange } from '../../../../util/vs/editor/common/core/ranges/lineRange'; @@ -97,6 +99,43 @@ relative/path/to/another_file.js:42 expect(patches).toEqual(patchText); }); + it.skipIf(!isWindows)('applies a patch for a Windows path whose parent folder contains a space', async () => { + const workspaceRoot = URI.file('C:\\workspace'); + const documentId = DocumentId.create(URI.file('C:\\workspace\\space folder\\test.py').toString()); + const document = new CurrentDocument(new StringText('def my_function'), new Position(1, 16)); + const lines = AsyncIterUtils.fromArray([ + 'space folder/test.py:0', + '-def my_function', + '+def my_function():', + '+ pass', + ]); + + const { edits } = await consumeHandleResponse( + lines, + document, + documentId, + workspaceRoot, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + true, + ); + + expect(edits.map(edit => ({ + targetDocument: edit.targetDocument.toString(), + lineRange: edit.edit.lineRange.toString(), + newLines: edit.edit.newLines, + }))).toEqual([{ + targetDocument: 'file:///c%3A/workspace/space%20folder/test.py', + lineRange: '[1,2)', + newLines: ['def my_function():'], + }, { + targetDocument: 'file:///c%3A/workspace/space%20folder/test.py', + lineRange: '[2,2)', + newLines: [' pass'], + }]); + }); + it('discard a patch if no valid header', async () => { const patchText = `myFile.ts: +New line 1 diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index 0e7984ae343716..076dfe62e4bae2 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -31,6 +31,8 @@ import { CancellationToken, CancellationTokenSource } from '../../../../util/vs/ import { Emitter, Event } from '../../../../util/vs/base/common/event'; import { constObservable } from '../../../../util/vs/base/common/observable'; import { DisposableStore } from '../../../../util/vs/base/common/lifecycle'; +import { Schemas } from '../../../../util/vs/base/common/network'; +import { isWindows } from '../../../../util/vs/base/common/platform'; import { URI } from '../../../../util/vs/base/common/uri'; import { LineEdit, LineReplacement } from '../../../../util/vs/editor/common/core/edits/lineEdit'; import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit'; @@ -525,6 +527,59 @@ describe('getPredictionContents', () => { expect(result.endsWith(':')).toBe(true); }); + it.skipIf(!isWindows)('preserves spaces in a workspace-relative Windows path', () => { + const lines = ['def my_function']; + const text = new StringText(lines.join('\n')); + const workspaceRoot = URI.file('C:\\workspace'); + const doc = new StatelessNextEditDocument( + DocumentId.create(URI.file('C:\\workspace\\space folder\\test.py').toString()), + workspaceRoot, + LanguageId.create('python'), + lines, + LineEdit.empty, + text, + new Edits(StringEdit, []), + ); + + expect(call(lines, ResponseFormat.CustomDiffPatch, { doc })).toBe('space folder/test.py:'); + }); + + it.skipIf(!isWindows)('normalizes the drive letter for a file-backed notebook cell', () => { + const lines = ['print("hello")']; + const text = new StringText(lines.join('\n')); + const workspaceRoot = URI.file('C:\\workspace'); + const cellUri = URI.file('C:\\workspace\\notebook.ipynb').with({ scheme: Schemas.vscodeNotebookCell, fragment: 'ch000001' }); + const doc = new StatelessNextEditDocument( + DocumentId.create(cellUri.toString()), + workspaceRoot, + LanguageId.create('python'), + lines, + LineEdit.empty, + text, + new Edits(StringEdit, []), + ); + + expect(call(lines, ResponseFormat.CustomDiffPatch, { doc })).toBe('notebook.ipynb#ch000001:'); + }); + + it.skipIf(!isWindows)('preserves path casing for a virtual notebook cell', () => { + const lines = ['print("hello")']; + const text = new StringText(lines.join('\n')); + const workspaceRoot = URI.from({ scheme: 'test-notebook', path: '/repo' }); + const cellUri = URI.from({ scheme: Schemas.vscodeNotebookCell, path: '/Repo/notebook.ipynb', fragment: 'ch000001' }); + const doc = new StatelessNextEditDocument( + DocumentId.create(cellUri.toString()), + workspaceRoot, + LanguageId.create('python'), + lines, + LineEdit.empty, + text, + new Edits(StringEdit, []), + ); + + expect(call(lines, ResponseFormat.CustomDiffPatch, { doc })).toBe('/Repo/notebook.ipynb#ch000001:'); + }); + it('returns correct content for CustomDiffPatch without workspace root', () => { const result = call(editWindowLines, ResponseFormat.CustomDiffPatch); expect(result.endsWith(':')).toBe(true);