From 76e6409f4fa3193a8f60359d14414acaf3a9eab8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:52:27 +0000 Subject: [PATCH 1/2] Initial plan From 4de666c9663b624fdd27736836a29eeb82b25ad3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:08:21 +0000 Subject: [PATCH 2/2] Use normalizePath for cross-platform path comparisons --- src/features/creators/autoFindProjects.ts | 5 +- src/features/creators/existingProjects.ts | 5 +- src/features/interpreterSelection.ts | 5 +- src/features/projectManager.ts | 5 +- src/features/settings/settingHelpers.ts | 45 ++++++++------ src/features/terminal/terminalManager.ts | 5 +- src/features/views/utils.ts | 4 +- src/test/features/views/utils.unit.test.ts | 72 ++++++++++++++++++++++ 8 files changed, 114 insertions(+), 32 deletions(-) create mode 100644 src/test/features/views/utils.unit.test.ts diff --git a/src/features/creators/autoFindProjects.ts b/src/features/creators/autoFindProjects.ts index 7d3987c9..9953e878 100644 --- a/src/features/creators/autoFindProjects.ts +++ b/src/features/creators/autoFindProjects.ts @@ -6,6 +6,7 @@ import { traceInfo } from '../../common/logging'; import { showErrorMessage, showQuickPickWithButtons, showWarningMessage } from '../../common/window.apis'; import { findFiles } from '../../common/workspace.apis'; import { PythonProjectManager, PythonProjectsImpl } from '../../internal.api'; +import { normalizePath } from '../../common/utils/pathUtils'; function getUniqueUri(uris: Uri[]): { label: string; @@ -73,8 +74,8 @@ export class AutoFindProjects implements PythonProjectCreator { // Skip this project if: // 1. There's already a project registered with exactly the same path // 2. There's already a project registered with this project's parent directory path - const np = path.normalize(p.uri.fsPath); - const nf = path.normalize(uri.fsPath); + const np = normalizePath(p.uri.fsPath); + const nf = normalizePath(uri.fsPath); const nfp = path.dirname(nf); return np !== nf && np !== nfp; } diff --git a/src/features/creators/existingProjects.ts b/src/features/creators/existingProjects.ts index 5c66d29b..60120290 100644 --- a/src/features/creators/existingProjects.ts +++ b/src/features/creators/existingProjects.ts @@ -5,6 +5,7 @@ import { ProjectCreatorString } from '../../common/localize'; import { traceInfo, traceLog } from '../../common/logging'; import { showOpenDialog, showWarningMessage } from '../../common/window.apis'; import { PythonProjectManager, PythonProjectsImpl } from '../../internal.api'; +import { normalizePath } from '../../common/utils/pathUtils'; export class ExistingProjects implements PythonProjectCreator { public readonly name = 'existingProjects'; @@ -47,8 +48,8 @@ export class ExistingProjects implements PythonProjectCreator { const p = this.pm.get(uri); if (p) { // Skip this project if there's already a project registered with exactly the same path - const np = path.normalize(p.uri.fsPath); - const nf = path.normalize(uri.fsPath); + const np = normalizePath(p.uri.fsPath); + const nf = normalizePath(uri.fsPath); return np !== nf; } return true; diff --git a/src/features/interpreterSelection.ts b/src/features/interpreterSelection.ts index 1e96ea7b..5e55adf7 100644 --- a/src/features/interpreterSelection.ts +++ b/src/features/interpreterSelection.ts @@ -10,6 +10,7 @@ import { StopWatch } from '../common/stopWatch'; import { EventNames } from '../common/telemetry/constants'; import { sendTelemetryEvent } from '../common/telemetry/sender'; import { resolveVariables } from '../common/utils/internalVariables'; +import { normalizePath } from '../common/utils/pathUtils'; import { showWarningMessage } from '../common/window.apis'; import { getConfiguration, @@ -534,8 +535,8 @@ function getProjectSpecificEnvManager(projectManager: PythonProjectManager, scop const pw = projectManager.get(scope); const w = getWorkspaceFolder(scope); if (pw && w) { - const pwPath = path.resolve(pw.uri.fsPath); - const matching = overrides.find((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(path.resolve(pw.uri.fsPath)); + const matching = overrides.find((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); if (matching && matching.envManager && matching.envManager.length > 0) { return matching.envManager; } diff --git a/src/features/projectManager.ts b/src/features/projectManager.ts index 3d2f9d02..9c1cf7bb 100644 --- a/src/features/projectManager.ts +++ b/src/features/projectManager.ts @@ -12,6 +12,7 @@ import { onDidRenameFiles, } from '../common/workspace.apis'; import { PythonProjectManager, PythonProjectSettings, PythonProjectsImpl } from '../internal.api'; +import { normalizePath } from '../common/utils/pathUtils'; import { addPythonProjectSetting, EditProjectSettings, @@ -276,9 +277,9 @@ export class PythonProjectManagerImpl implements PythonProjectManager { private findProjectByUri(uri: Uri): PythonProject | undefined { const _projects = Array.from(this._projects.values()).sort((a, b) => b.uri.fsPath.length - a.uri.fsPath.length); - const normalizedUriPath = path.normalize(uri.fsPath); + const normalizedUriPath = normalizePath(uri.fsPath); for (const p of _projects) { - const normalizedProjectPath = path.normalize(p.uri.fsPath); + const normalizedProjectPath = normalizePath(p.uri.fsPath); if (this.isUriMatching(normalizedUriPath, normalizedProjectPath)) { return p; } diff --git a/src/features/settings/settingHelpers.ts b/src/features/settings/settingHelpers.ts index 2e4857ea..3a9470cd 100644 --- a/src/features/settings/settingHelpers.ts +++ b/src/features/settings/settingHelpers.ts @@ -4,6 +4,7 @@ import { PythonProject } from '../../api'; import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID, SYSTEM_MANAGER_ID } from '../../common/constants'; import { traceError, traceInfo, traceVerbose, traceWarn } from '../../common/logging'; import { getGlobalPersistentState } from '../../common/persistentState'; +import { normalizePath } from '../../common/utils/pathUtils'; import { EventNames } from '../../common/telemetry/constants'; import { sendTelemetryEvent } from '../../common/telemetry/sender'; import * as workspaceApis from '../../common/workspace.apis'; @@ -20,8 +21,8 @@ function getSettings( const pw = wm.get(scope); const w = workspaceApis.getWorkspaceFolder(scope); if (pw && w) { - const pwPath = path.normalize(pw.uri.fsPath); - return overrides.find((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(pw.uri.fsPath); + return overrides.find((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); } } return undefined; @@ -140,9 +141,9 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr const originalOverridesLength = overrides.length; es.forEach((e) => { - const pwPath = path.normalize(e.project.uri.fsPath); - const isRoot = path.normalize(w.uri.fsPath) === pwPath; - const index = overrides.findIndex((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(e.project.uri.fsPath); + const isRoot = normalizePath(w.uri.fsPath) === pwPath; + const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); // For workspace root in single-folder workspaces (no workspaceFile), // use default settings instead of pythonProjects entries @@ -250,8 +251,8 @@ export async function setEnvironmentManager(edits: EditEnvManagerSettings[]): Pr let projectsModified = false; es.forEach((e) => { - const pwPath = path.normalize(e.project.uri.fsPath); - const index = overrides.findIndex((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(e.project.uri.fsPath); + const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); if (index >= 0) { overrides[index].envManager = e.envManager; projectsModified = true; @@ -311,8 +312,8 @@ export async function setPackageManager(edits: EditPackageManagerSettings[]): Pr let projectsModified = false; es.forEach((e) => { - const pwPath = path.normalize(e.project.uri.fsPath); - const index = overrides.findIndex((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(e.project.uri.fsPath); + const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); if (index >= 0) { overrides[index].packageManager = e.packageManager; projectsModified = true; @@ -367,14 +368,16 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro const overrides = config.get('pythonProjects', []); let overridesModified = false; es.forEach((e) => { - const pwPath = path.normalize(e.project.uri.fsPath); - const isRoot = path.normalize(w.uri.fsPath) === pwPath; + const pwPath = normalizePath(e.project.uri.fsPath); + const isRoot = normalizePath(w.uri.fsPath) === pwPath; // For workspace root projects in single-folder workspaces, use default settings // instead of adding to pythonProjects with empty path if (isRoot && !isMultiroot) { // Remove existing entry if present (migration from buggy empty path) - const existingIndex = overrides.findIndex((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const existingIndex = overrides.findIndex( + (s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath, + ); if (existingIndex >= 0) { overrides.splice(existingIndex, 1); overridesModified = true; @@ -398,9 +401,9 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro const index = overrides.findIndex((s) => { if (s.workspace) { // If the workspace is set, check workspace and path in existing overrides - return s.workspace === w.name && path.resolve(w.uri.fsPath, s.path) === pwPath; + return s.workspace === w.name && normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath; } - return path.resolve(w.uri.fsPath, s.path) === pwPath; + return normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath; }); if (index >= 0) { // Preserve existing manager settings if not explicitly provided @@ -454,8 +457,8 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]): const config = workspaceApis.getConfiguration('python-envs', w.uri); const overrides = config.get('pythonProjects', []); es.forEach((e) => { - const pwPath = path.normalize(e.project.uri.fsPath); - const index = overrides.findIndex((s) => path.resolve(w.uri.fsPath, s.path) === pwPath); + const pwPath = normalizePath(e.project.uri.fsPath); + const index = overrides.findIndex((s) => normalizePath(path.resolve(w.uri.fsPath, s.path)) === pwPath); if (index >= 0) { overrides.splice(index, 1); } @@ -480,8 +483,8 @@ export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): // Find the workspace folder that contains the old path let targetWorkspace: WorkspaceFolder | undefined; for (const w of workspaceFolders) { - const oldPath = path.normalize(oldUri.fsPath); - if (oldPath.startsWith(path.normalize(w.uri.fsPath))) { + const oldPath = normalizePath(oldUri.fsPath); + if (oldPath.startsWith(normalizePath(w.uri.fsPath))) { targetWorkspace = w; break; } @@ -494,9 +497,11 @@ export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): const config = workspaceApis.getConfiguration('python-envs', targetWorkspace.uri); const overrides = config.get('pythonProjects', []); - const oldNormalizedPath = path.normalize(oldUri.fsPath); + const oldNormalizedPath = normalizePath(oldUri.fsPath); - const index = overrides.findIndex((s) => path.resolve(targetWorkspace!.uri.fsPath, s.path) === oldNormalizedPath); + const index = overrides.findIndex( + (s) => normalizePath(path.resolve(targetWorkspace!.uri.fsPath, s.path)) === oldNormalizedPath, + ); if (index >= 0) { // Update the path to the new location const newRelativePath = path.relative(targetWorkspace.uri.fsPath, newUri.fsPath).replace(/\\/g, '/'); diff --git a/src/features/terminal/terminalManager.ts b/src/features/terminal/terminalManager.ts index 645c7a39..6564309e 100644 --- a/src/features/terminal/terminalManager.ts +++ b/src/features/terminal/terminalManager.ts @@ -14,6 +14,7 @@ import { withProgress, } from '../../common/window.apis'; import { getConfiguration, onDidChangeConfiguration } from '../../common/workspace.apis'; +import { normalizePath } from '../../common/utils/pathUtils'; import { isActivatableEnvironment } from '../common/activation'; import { identifyTerminalShell } from '../common/shellDetector'; import { getPythonApi } from '../pythonApi'; @@ -323,7 +324,7 @@ export class TerminalManagerImpl implements TerminalManager { environment: PythonEnvironment, createNew: boolean = false, ): Promise { - const part = terminalKey instanceof Uri ? path.normalize(terminalKey.fsPath) : terminalKey; + const part = terminalKey instanceof Uri ? normalizePath(terminalKey.fsPath) : terminalKey; const key = `${environment.envId.id}:${part}`; if (!createNew) { const terminal = this.dedicatedTerminals.get(key); @@ -373,7 +374,7 @@ export class TerminalManagerImpl implements TerminalManager { createNew: boolean = false, ): Promise { const uri = project instanceof Uri ? project : project.uri; - const key = `${environment.envId.id}:${path.normalize(uri.fsPath)}`; + const key = `${environment.envId.id}:${normalizePath(uri.fsPath)}`; if (!createNew) { const terminal = this.projectTerminals.get(key); if (terminal) { diff --git a/src/features/views/utils.ts b/src/features/views/utils.ts index 6f033140..200963c6 100644 --- a/src/features/views/utils.ts +++ b/src/features/views/utils.ts @@ -1,12 +1,12 @@ -import * as path from 'path'; import { PythonProject } from '../../api'; import { getWorkspaceFolder } from '../../common/workspace.apis'; +import { normalizePath } from '../../common/utils/pathUtils'; export function removable(project: PythonProject): boolean { const workspace = getWorkspaceFolder(project.uri); if (workspace) { // If the project path is same as the workspace path, then we cannot remove the project. - return path.normalize(workspace?.uri.fsPath).toLowerCase() !== path.normalize(project.uri.fsPath).toLowerCase(); + return normalizePath(workspace?.uri.fsPath) !== normalizePath(project.uri.fsPath); } return true; } diff --git a/src/test/features/views/utils.unit.test.ts b/src/test/features/views/utils.unit.test.ts new file mode 100644 index 00000000..50e7ddfa --- /dev/null +++ b/src/test/features/views/utils.unit.test.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { PythonProject } from '../../../api'; +import * as platformUtils from '../../../common/utils/platformUtils'; +import * as workspaceApis from '../../../common/workspace.apis'; +import { removable } from '../../../features/views/utils'; + +/** + * Builds a minimal PythonProject-like object for the given filesystem path. + */ +function makeProject(fsPath: string): PythonProject { + return { name: fsPath, uri: { fsPath } } as any; +} + +/** + * Stubs getWorkspaceFolder to return a workspace folder with the given path. + */ +function stubWorkspaceFolder(getWsStub: sinon.SinonStub, fsPath: string | undefined): void { + getWsStub.returns(fsPath === undefined ? undefined : ({ uri: { fsPath } } as any)); +} + +suite('Views utils - removable', () => { + let getWorkspaceFolderStub: sinon.SinonStub; + let isWindowsStub: sinon.SinonStub; + + setup(() => { + getWorkspaceFolderStub = sinon.stub(workspaceApis, 'getWorkspaceFolder'); + isWindowsStub = sinon.stub(platformUtils, 'isWindows'); + }); + + teardown(() => { + sinon.restore(); + }); + + test('returns true when the project has no workspace folder', () => { + stubWorkspaceFolder(getWorkspaceFolderStub, undefined); + assert.strictEqual(removable(makeProject('/home/user/project')), true); + }); + + test('returns false when project path equals workspace path', () => { + isWindowsStub.returns(false); + stubWorkspaceFolder(getWorkspaceFolderStub, '/home/user/project'); + assert.strictEqual(removable(makeProject('/home/user/project')), false); + }); + + test('returns true when project is nested inside the workspace folder', () => { + isWindowsStub.returns(false); + stubWorkspaceFolder(getWorkspaceFolderStub, '/home/user/workspace'); + assert.strictEqual(removable(makeProject('/home/user/workspace/project')), true); + }); + + test('Windows: matches paths that differ only by drive-letter case', () => { + // Regression: path.normalize() does not lowercase, so 'C:\\ws' and 'c:\\ws' + // would not match and the workspace root would be wrongly reported removable. + isWindowsStub.returns(true); + stubWorkspaceFolder(getWorkspaceFolderStub, 'C:\\Users\\test\\project'); + assert.strictEqual(removable(makeProject('c:\\users\\test\\project')), false); + }); + + test('Windows: matches paths that differ only by slash direction', () => { + isWindowsStub.returns(true); + stubWorkspaceFolder(getWorkspaceFolderStub, 'C:\\Users\\test\\project'); + assert.strictEqual(removable(makeProject('C:/Users/test/project')), false); + }); + + test('non-Windows comparison stays case-sensitive', () => { + isWindowsStub.returns(false); + stubWorkspaceFolder(getWorkspaceFolderStub, '/home/user/Project'); + assert.strictEqual(removable(makeProject('/home/user/project')), true); + }); +});