Skip to content

Commit 78d7fe9

Browse files
authored
Auto-update project view on folder delete/rename (#1152)
Fixes stale project paths when folders are moved/deleted via VS Code's explorer. Changes: - Listen to onDidDeleteFiles / onDidRenameFiles to auto-remove or update project paths in settings - Fix: Preserve envManager and packageManager on rename - Added unit tests with fake timers (17ms vs 900ms) fixes #570
1 parent ee031ad commit 78d7fe9

File tree

5 files changed

+556
-21
lines changed

5 files changed

+556
-21
lines changed

.github/instructions/testing-workflow.instructions.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -594,7 +594,7 @@ envConfig.inspect
594594

595595
- Avoid testing exact error messages or log output - assert only that errors are thrown or rejection occurs to prevent brittle tests (1)
596596
- Create shared mock helpers (e.g., `createMockLogOutputChannel()`) instead of duplicating mock setup across multiple test files (1)
597+
- 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)
597598
- 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)
598599
- 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)
599-
- **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)
600-
- **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)
600+
- 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)

src/common/workspace.apis.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ConfigurationScope,
66
Disposable,
77
FileDeleteEvent,
8+
FileRenameEvent,
89
FileSystemWatcher,
910
GlobPattern,
1011
Uri,
@@ -63,3 +64,11 @@ export function onDidDeleteFiles(
6364
): Disposable {
6465
return workspace.onDidDeleteFiles(listener, thisArgs, disposables);
6566
}
67+
68+
export function onDidRenameFiles(
69+
listener: (e: FileRenameEvent) => any,
70+
thisArgs?: any,
71+
disposables?: Disposable[],
72+
): Disposable {
73+
return workspace.onDidRenameFiles(listener, thisArgs, disposables);
74+
}

src/features/projectManager.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ import {
88
getWorkspaceFolders,
99
onDidChangeConfiguration,
1010
onDidChangeWorkspaceFolders,
11+
onDidDeleteFiles,
12+
onDidRenameFiles,
1113
} from '../common/workspace.apis';
1214
import { PythonProjectManager, PythonProjectSettings, PythonProjectsImpl } from '../internal.api';
1315
import {
1416
addPythonProjectSetting,
1517
EditProjectSettings,
1618
getDefaultEnvManagerSetting,
1719
getDefaultPkgManagerSetting,
20+
removePythonProjectSetting,
21+
updatePythonProjectSettingPath,
1822
} from './settings/settingHelpers';
1923

2024
type ProjectArray = PythonProject[];
@@ -45,9 +49,64 @@ export class PythonProjectManagerImpl implements PythonProjectManager {
4549
this.updateDebounce.trigger();
4650
}
4751
}),
52+
onDidDeleteFiles((e) => {
53+
this.handleDeletedFiles(e.files);
54+
}),
55+
onDidRenameFiles((e) => {
56+
this.handleRenamedFiles(e.files);
57+
}),
4858
);
4959
}
5060

61+
/**
62+
* Handles file deletion events. When a project folder is deleted,
63+
* removes the project from the internal map and cleans up settings.
64+
*/
65+
private async handleDeletedFiles(deletedUris: readonly Uri[]): Promise<void> {
66+
const projectsToRemove: PythonProject[] = [];
67+
const workspaces = getWorkspaceFolders() ?? [];
68+
69+
for (const uri of deletedUris) {
70+
const project = this._projects.get(uri.toString());
71+
if (project) {
72+
// Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders
73+
const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString());
74+
if (!isWorkspaceRoot) {
75+
projectsToRemove.push(project);
76+
}
77+
}
78+
}
79+
80+
if (projectsToRemove.length > 0) {
81+
// Remove from internal map and fire change event
82+
this.remove(projectsToRemove);
83+
// Clean up settings
84+
await removePythonProjectSetting(projectsToRemove.map((p) => ({ project: p })));
85+
}
86+
}
87+
88+
/**
89+
* Handles file rename events. When a project folder is renamed/moved,
90+
* updates the project path in settings.
91+
*/
92+
private async handleRenamedFiles(renamedFiles: readonly { oldUri: Uri; newUri: Uri }[]): Promise<void> {
93+
const workspaces = getWorkspaceFolders() ?? [];
94+
95+
for (const { oldUri, newUri } of renamedFiles) {
96+
const project = this._projects.get(oldUri.toString());
97+
if (project) {
98+
// Skip workspace root folders - they're handled by onDidChangeWorkspaceFolders
99+
const isWorkspaceRoot = workspaces.some((w) => w.uri.toString() === project.uri.toString());
100+
if (!isWorkspaceRoot) {
101+
// Update settings with new path
102+
await updatePythonProjectSettingPath(oldUri, newUri);
103+
// Trigger update to refresh the in-memory projects
104+
this.updateDebounce.trigger();
105+
}
106+
}
107+
}
108+
}
109+
51110
/**
52111
*
53112
* Gathers the projects which are configured in settings and all workspace roots.

src/features/settings/settingHelpers.ts

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import { PythonProject } from '../../api';
1111
import { DEFAULT_ENV_MANAGER_ID, DEFAULT_PACKAGE_MANAGER_ID } from '../../common/constants';
1212
import { traceError, traceInfo, traceWarn } from '../../common/logging';
13-
import { getConfiguration, getWorkspaceFile, getWorkspaceFolders } from '../../common/workspace.apis';
13+
import * as workspaceApis from '../../common/workspace.apis';
1414
import { PythonProjectManager, PythonProjectSettings } from '../../internal.api';
1515

1616
function getSettings(
@@ -42,7 +42,7 @@ export function isDefaultEnvManagerBroken(): boolean {
4242
}
4343

4444
export function getDefaultEnvManagerSetting(wm: PythonProjectManager, scope?: Uri): string {
45-
const config = getConfiguration('python-envs', scope);
45+
const config = workspaceApis.getConfiguration('python-envs', scope);
4646
const settings = getSettings(wm, config, scope);
4747
if (settings && settings.envManager.length > 0) {
4848
return settings.envManager;
@@ -69,7 +69,7 @@ export function getDefaultPkgManagerSetting(
6969
scope?: ConfigurationScope | null,
7070
defaultId?: string,
7171
): string {
72-
const config = getConfiguration('python-envs', scope);
72+
const config = workspaceApis.getConfiguration('python-envs', scope);
7373

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

126-
const workspaceFile = getWorkspaceFile();
126+
const workspaceFile = workspaceApis.getWorkspaceFile();
127127
const promises: Thenable<void>[] = [];
128128

129129
workspaces.forEach((es, w) => {
130-
const config = getConfiguration('python-envs', w);
130+
const config = workspaceApis.getConfiguration('python-envs', w);
131131
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
132132
const projectsInspect = config.inspect<PythonProjectSettings[]>('pythonProjects');
133133
const existingProjectsSetting =
@@ -173,7 +173,7 @@ export async function setAllManagerSettings(edits: EditAllManagerSettings[]): Pr
173173
}
174174
});
175175

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

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

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

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

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

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

363-
const isMultiroot = (getWorkspaceFolders() ?? []).length > 1;
363+
const isMultiroot = (workspaceApis.getWorkspaceFolders() ?? []).length > 1;
364364

365365
const promises: Thenable<void>[] = [];
366366
workspaces.forEach((es, w) => {
367-
const config = getConfiguration('python-envs', w.uri);
367+
const config = workspaceApis.getConfiguration('python-envs', w.uri);
368368
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
369369
es.forEach((e) => {
370370
if (isMultiroot) {
@@ -378,8 +378,9 @@ export async function addPythonProjectSetting(edits: EditProjectSettings[]): Pro
378378
return path.resolve(w.uri.fsPath, s.path) === pwPath;
379379
});
380380
if (index >= 0) {
381-
overrides[index].envManager = e.envManager ?? envManager;
382-
overrides[index].packageManager = e.packageManager ?? pkgManager;
381+
// Preserve existing manager settings if not explicitly provided
382+
overrides[index].envManager = e.envManager ?? overrides[index].envManager;
383+
overrides[index].packageManager = e.packageManager ?? overrides[index].packageManager;
383384
} else {
384385
overrides.push({
385386
path: path.relative(w.uri.fsPath, pwPath).replace(/\\/g, '/'),
@@ -412,7 +413,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]):
412413

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

434+
/**
435+
* Updates the path of a project in pythonProjects settings when a folder is renamed/moved.
436+
* @param oldUri The original URI of the project folder
437+
* @param newUri The new URI of the project folder after rename/move
438+
*/
439+
export async function updatePythonProjectSettingPath(oldUri: Uri, newUri: Uri): Promise<void> {
440+
const workspaceFolders = workspaceApis.getWorkspaceFolders() ?? [];
441+
442+
// Find the workspace folder that contains the old path
443+
let targetWorkspace: WorkspaceFolder | undefined;
444+
for (const w of workspaceFolders) {
445+
const oldPath = path.normalize(oldUri.fsPath);
446+
if (oldPath.startsWith(path.normalize(w.uri.fsPath))) {
447+
targetWorkspace = w;
448+
break;
449+
}
450+
}
451+
452+
if (!targetWorkspace) {
453+
traceError(`Unable to find workspace for ${oldUri.fsPath}`);
454+
return;
455+
}
456+
457+
const config = workspaceApis.getConfiguration('python-envs', targetWorkspace.uri);
458+
const overrides = config.get<PythonProjectSettings[]>('pythonProjects', []);
459+
const oldNormalizedPath = path.normalize(oldUri.fsPath);
460+
461+
const index = overrides.findIndex((s) => path.resolve(targetWorkspace!.uri.fsPath, s.path) === oldNormalizedPath);
462+
if (index >= 0) {
463+
// Update the path to the new location
464+
const newRelativePath = path.relative(targetWorkspace.uri.fsPath, newUri.fsPath).replace(/\\/g, '/');
465+
overrides[index].path = newRelativePath;
466+
await config.update('pythonProjects', overrides, ConfigurationTarget.Workspace);
467+
traceInfo(`Updated project path from ${oldUri.fsPath} to ${newUri.fsPath}`);
468+
}
469+
}
470+
433471
/**
434472
* Gets user-configured setting for window-scoped settings.
435473
* Priority order: globalRemoteValue > globalLocalValue > globalValue
@@ -438,7 +476,7 @@ export async function removePythonProjectSetting(edits: EditProjectSettings[]):
438476
* @returns The user-configured value or undefined if not set by user
439477
*/
440478
export function getSettingWindowScope<T>(section: string, key: string): T | undefined {
441-
const config = getConfiguration(section);
479+
const config = workspaceApis.getConfiguration(section);
442480
const inspect = config.inspect<T>(key);
443481
if (!inspect) {
444482
return undefined;
@@ -466,7 +504,7 @@ export function getSettingWindowScope<T>(section: string, key: string): T | unde
466504
* @returns The user-configured value or undefined if not set by user
467505
*/
468506
export function getSettingWorkspaceScope<T>(section: string, key: string, scope?: Uri): T | undefined {
469-
const config = getConfiguration(section, scope);
507+
const config = workspaceApis.getConfiguration(section, scope);
470508
const inspect = config.inspect<T>(key);
471509
if (!inspect) {
472510
return undefined;
@@ -492,7 +530,7 @@ export function getSettingWorkspaceScope<T>(section: string, key: string, scope?
492530
* @returns The user-configured value or undefined if not set by user
493531
*/
494532
export function getSettingUserScope<T>(section: string, key: string): T | undefined {
495-
const config = getConfiguration(section);
533+
const config = workspaceApis.getConfiguration(section);
496534
const inspect = config.inspect<T>(key);
497535
if (!inspect) {
498536
return undefined;

0 commit comments

Comments
 (0)