Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions .github/instructions/testing-workflow.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ envConfig.inspect

- Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1)
- Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1)
- Use `sinon.useFakeTimers()` with `clock.tickAsync()` instead of `await new Promise(resolve => setTimeout(resolve, ms))` for debounce/timeout handling - eliminates flakiness and speeds up tests significantly (1)
- Always compile tests (`npm run compile-tests`) before running them after adding new test cases - test counts will be wrong if running against stale compiled output (1)
- Never create "documentation tests" that just `assert.ok(true)` — if mocking limitations prevent testing, either test a different layer that IS mockable, or skip the test entirely with a clear explanation (1)
- **REPEATED**: When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3)
- **REPEATED**: Use OS-agnostic path handling in tests: use `'.'` for relative paths in configs (NOT `/test/workspace`), compare `fsPath` to `Uri.file(expected).fsPath` (NOT raw strings). This breaks on Windows every time! (5)
- When stubbing vscode APIs in tests via wrapper modules (e.g., `workspaceApis`), the production code must also use those wrappers — sinon cannot stub properties directly on the vscode namespace like `workspace.workspaceFolders`, so both production and test code must reference the same stubbable wrapper functions (3)
9 changes: 9 additions & 0 deletions src/common/workspace.apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ConfigurationScope,
Disposable,
FileDeleteEvent,
FileRenameEvent,
FileSystemWatcher,
GlobPattern,
Uri,
Expand Down Expand Up @@ -63,3 +64,11 @@ export function onDidDeleteFiles(
): Disposable {
return workspace.onDidDeleteFiles(listener, thisArgs, disposables);
}

export function onDidRenameFiles(
listener: (e: FileRenameEvent) => any,
thisArgs?: any,
disposables?: Disposable[],
): Disposable {
return workspace.onDidRenameFiles(listener, thisArgs, disposables);
}
59 changes: 59 additions & 0 deletions src/features/projectManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ import {
getWorkspaceFolders,
onDidChangeConfiguration,
onDidChangeWorkspaceFolders,
onDidDeleteFiles,
onDidRenameFiles,
} from '../common/workspace.apis';
import { PythonProjectManager, PythonProjectSettings, PythonProjectsImpl } from '../internal.api';
import {
addPythonProjectSetting,
EditProjectSettings,
getDefaultEnvManagerSetting,
getDefaultPkgManagerSetting,
removePythonProjectSetting,
updatePythonProjectSettingPath,
} from './settings/settingHelpers';

type ProjectArray = PythonProject[];
Expand Down Expand Up @@ -45,9 +49,64 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
this.updateDebounce.trigger();
}
}),
onDidDeleteFiles((e) => {
this.handleDeletedFiles(e.files);
}),
onDidRenameFiles((e) => {
this.handleRenamedFiles(e.files);
}),
);
}

/**
* Handles file deletion events. When a project folder is deleted,
* removes the project from the internal map and cleans up settings.
*/
private async handleDeletedFiles(deletedUris: readonly Uri[]): Promise<void> {
const projectsToRemove: PythonProject[] = [];
const workspaces = getWorkspaceFolders() ?? [];

for (const uri of deletedUris) {
const project = this._projects.get(uri.toString());
if (project) {
// Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders
const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString());
if (!isWorkspaceRoot) {
projectsToRemove.push(project);
}
}
}

if (projectsToRemove.length > 0) {
// Remove from internal map and fire change event
this.remove(projectsToRemove);
// Clean up settings
await removePythonProjectSetting(projectsToRemove.map((p) => ({ project: p })));
}
}

/**
* Handles file rename events. When a project folder is renamed/moved,
* updates the project path in settings.
*/
private async handleRenamedFiles(renamedFiles: readonly { oldUri: Uri; newUri: Uri }[]): Promise<void> {
const workspaces = getWorkspaceFolders() ?? [];

for (const { oldUri, newUri } of renamedFiles) {
const project = this._projects.get(oldUri.toString());
if (project) {
// Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders
const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString());
if (!isWorkspaceRoot) {
// Update settings with new path
await updatePythonProjectSettingPath(oldUri, newUri);
// Trigger update to refresh the in-memory projects
this.updateDebounce.trigger();
}
}
}
}

/**
*
* Gathers the projects which are configured in settings and all workspace roots.
Expand Down
76 changes: 57 additions & 19 deletions src/features/settings/settingHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { PythonProject } from '../../api';
import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants';
import { traceError, traceInfo, traceWarn } from '../../common/logging';
import { getConfiguration, getWorkspaceFile, getWorkspaceFolders } from '../../common/workspace.apis';
import * as workspaceApis from '../../common/workspace.apis';
import { PythonProjectManager, PythonProjectSettings } from '../../internal.api';

function getSettings(
Expand Down Expand Up @@ -42,7 +42,7 @@ export function isDefaultEnvManagerBroken(): boolean {
}

export function getDefaultEnvManagerSetting(wm: PythonProjectManager, scope?: Uri): string {
const config = getConfiguration('python-envs', scope);
const config = workspaceApis.getConfiguration('python-envs', scope);
const settings = getSettings(wm, config, scope);
if (settings && settings.envManager.length > 0) {
return settings.envManager;
Expand All @@ -69,7 +69,7 @@ export function getDefaultPkgManagerSetting(
scope?: ConfigurationScope | null,
defaultId?: string,
): string {
const config = getConfiguration('python-envs', scope);
const config = workspaceApis.getConfiguration('python-envs', scope);

const settings = getSettings(wm, config, scope);
if (settings && settings.packageManager.length > 0) {
Expand Down Expand Up @@ -123,11 +123,11 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr
}
});

const workspaceFile = getWorkspaceFile();
const workspaceFile = workspaceApis.getWorkspaceFile();
const promises: Thenable<void>[] = [];

workspaces.forEach((es, w) => {
const config = getConfiguration('python-envs', w);
const config = workspaceApis.getConfiguration('python-envs', w);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
const projectsInspect = config.inspect<PythonProjectSettings[]>('pythonProjects');
const existingProjectsSetting =
Expand Down Expand Up @@ -173,7 +173,7 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr
}
});

const config = getConfiguration('python-envs', undefined);
const config = workspaceApis.getConfiguration('python-envs', undefined);
edits
.filter((e) => !e.project)
.forEach((e) => {
Expand Down Expand Up @@ -221,7 +221,7 @@ export async function setEnvironmentManager(edits: EditEnvManagerSettings[]): Pr
const promises: Thenable<void>[] = [];

workspaces.forEach((es, w) => {
const config = getConfiguration('python-envs', w.uri);
const config = workspaceApis.getConfiguration('python-envs', w.uri);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
const projectsInspect = config.inspect<PythonProjectSettings[]>('pythonProjects');
const existingProjectsSetting = projectsInspect?.workspaceValue ?? undefined;
Expand All @@ -247,7 +247,7 @@ export async function setEnvironmentManager(edits: EditEnvManagerSettings[]): Pr
}
});

const config = getConfiguration('python-envs', undefined);
const config = workspaceApis.getConfiguration('python-envs', undefined);
edits
.filter((e) => !e.project)
.forEach((e) => {
Expand Down Expand Up @@ -295,7 +295,7 @@ export async function setPackageManager(edits: EditPackageManagerSettings[]): Pr
const promises: Thenable<void>[] = [];

workspaces.forEach((es, w) => {
const config = getConfiguration('python-envs', w.uri);
const config = workspaceApis.getConfiguration('python-envs', w.uri);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
const projectsInspect = config.inspect<PythonProjectSettings[]>('pythonProjects');
const existingProjectsSetting = projectsInspect?.workspaceValue ?? undefined;
Expand All @@ -321,7 +321,7 @@ export async function setPackageManager(edits: EditPackageManagerSettings[]): Pr
}
});

const config = getConfiguration('python-envs', undefined);
const config = workspaceApis.getConfiguration('python-envs', undefined);
edits
.filter((e) => !e.project)
.forEach((e) => {
Expand All @@ -343,7 +343,7 @@ export interface EditProjectSettings {
export async function addPythonProjectSetting(edits: EditProjectSettings[]): Promise<void> {
const noWorkspace: EditProjectSettings[] = [];
const workspaces = new Map<WorkspaceFolder, EditProjectSettings[]>();
const globalConfig = getConfiguration('python-envs', undefined);
const globalConfig = workspaceApis.getConfiguration('python-envs', undefined);
const envManager = globalConfig.get<string>('defaultEnvManager', DEFAULT_ENV_MANAGER_ID);
const pkgManager = globalConfig.get<string>('defaultPackageManager', DEFAULT_PACKAGE_MANAGER_ID);

Expand All @@ -360,11 +360,11 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro
traceError(`Unable to find workspace for ${e.project.uri.fsPath}`);
});

const isMultiroot = (getWorkspaceFolders() ?? []).length > 1;
const isMultiroot = (workspaceApis.getWorkspaceFolders() ?? []).length > 1;

const promises: Thenable<void>[] = [];
workspaces.forEach((es, w) => {
const config = getConfiguration('python-envs', w.uri);
const config = workspaceApis.getConfiguration('python-envs', w.uri);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
es.forEach((e) => {
if (isMultiroot) {
Expand All @@ -378,8 +378,9 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro
return path.resolve(w.uri.fsPath, s.path) === pwPath;
});
if (index >= 0) {
overrides[index].envManager = e.envManager ?? envManager;
overrides[index].packageManager = e.packageManager ?? pkgManager;
// Preserve existing manager settings if not explicitly provided
overrides[index].envManager = e.envManager ?? overrides[index].envManager;
overrides[index].packageManager = e.packageManager ?? overrides[index].packageManager;
} else {
overrides.push({
path: path.relative(w.uri.fsPath, pwPath).replace(/\\/g, '/'),
Expand Down Expand Up @@ -412,7 +413,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]):

const promises: Thenable<void>[] = [];
workspaces.forEach((es, w) => {
const config = getConfiguration('python-envs', w.uri);
const config = workspaceApis.getConfiguration('python-envs', w.uri);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
es.forEach((e) => {
const pwPath = path.normalize(e.project.uri.fsPath);
Expand All @@ -430,6 +431,43 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]):
await Promise.all(promises);
}

/**
* Updates the path of a project in pythonProjects settings when a folder is renamed/moved.
* @param oldUri The original URI of the project folder
* @param newUri The new URI of the project folder after rename/move
*/
export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): Promise<void> {
const workspaceFolders = workspaceApis.getWorkspaceFolders() ?? [];

// 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))) {
targetWorkspace = w;
break;
}
}

if (!targetWorkspace) {
traceError(`Unable to find workspace for ${oldUri.fsPath}`);
return;
}

const config = workspaceApis.getConfiguration('python-envs', targetWorkspace.uri);
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
const oldNormalizedPath = path.normalize(oldUri.fsPath);

const index = overrides.findIndex((s) => 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, '/');
overrides[index].path = newRelativePath;
await config.update('pythonProjects', overrides, ConfigurationTarget.Workspace);
traceInfo(`Updated project path from ${oldUri.fsPath} to ${newUri.fsPath}`);
}
}

/**
* Gets user-configured setting for window-scoped settings.
* Priority order: globalRemoteValue > globalLocalValue > globalValue
Expand All @@ -438,7 +476,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]):
* @returns The user-configured value or undefined if not set by user
*/
export function getSettingWindowScope<T>(section: string, key: string): T | undefined {
const config = getConfiguration(section);
const config = workspaceApis.getConfiguration(section);
const inspect = config.inspect<T>(key);
if (!inspect) {
return undefined;
Expand Down Expand Up @@ -466,7 +504,7 @@ export function getSettingWindowScope<T>(section: string, key: string): T | unde
* @returns The user-configured value or undefined if not set by user
*/
export function getSettingWorkspaceScope<T>(section: string, key: string, scope?: Uri): T | undefined {
const config = getConfiguration(section, scope);
const config = workspaceApis.getConfiguration(section, scope);
const inspect = config.inspect<T>(key);
if (!inspect) {
return undefined;
Expand All @@ -492,7 +530,7 @@ export function getSettingWorkspaceScope<T>(section: string, key: string, scope?
* @returns The user-configured value or undefined if not set by user
*/
export function getSettingUserScope<T>(section: string, key: string): T | undefined {
const config = getConfiguration(section);
const config = workspaceApis.getConfiguration(section);
const inspect = config.inspect<T>(key);
if (!inspect) {
return undefined;
Expand Down
Loading