Skip to content

Commit f234e05

Browse files
bricestaceyclaude
andauthored
Add progress and cancellation support to the packages pane (#12553)
This PR adds progress and cancellation support to the package pane actions. The two areas of attention to support were: - Dismissing any quick pick menus - Clicking the Cancel button in the withProgress notification There were four distinct actions to cancel: - session.execution - session.callMethod - HTTP requests - spawning processes in the terminal ### Release Notes #### New Features - Adds Progress/Cancellation support to packages pane actions. #### Bug Fixes - N/A ### QA Notes I testing this by adding a 4 second sleep to `.ps.rpc.pkg_list` in ark... I also added a sleep to the commands used to perform the actual package management. Something like this: `tryCatch({Sys.sleep(4); <the actual command>}, interrupt = function(e) NULL)` You could then use the install, update, or uninstall commands to kick off the request. For update/uninstall it is when the quick pick is first shown it'll refresh (and you can press ESC to cancel it), for install it is when actually peforming a search. I did the same for the version picker call. When actually performing the package management functions, we use the notification service's withProgress function, and there is a cancel button that should also work. We usually end package management functions with a call to refresh, so you could, using that 4 second delay, mutate the package and cancel during the refresh stage, and see nothing is being updated to verify that the cancellation took effect. Then manually click refresh and see ~4 seconds later that it works. With the above `tryCatch` change with a sleep you can also introduce enough time to interrupt the package management call. --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 70308e8 commit f234e05

20 files changed

Lines changed: 755 additions & 351 deletions

extensions/positron-python/src/client/positron/packages/condaPackageManager.ts

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { IProcessServiceFactory } from '../../common/process/types';
99
import { ITerminalServiceFactory } from '../../common/terminal/types';
1010
import { IComponentAdapter, ICondaService } from '../../interpreter/contracts';
1111
import { IServiceContainer } from '../../ioc/types';
12-
import { IPackageManager, MessageEmitter, PackageKernel } from './types';
12+
import { IPackageManager, MessageEmitter, PackageSession } from './types';
1313

1414
/** Package info returned by `conda search --json` */
1515
interface CondaPackageInfo {
@@ -64,11 +64,11 @@ export class CondaPackageManager implements IPackageManager {
6464
private readonly _pythonPath: string,
6565
_messageEmitter: MessageEmitter,
6666
private readonly _serviceContainer: IServiceContainer,
67-
private readonly _kernel: PackageKernel,
67+
private readonly _session: PackageSession,
6868
) {}
6969

70-
async getPackages(): Promise<positron.LanguageRuntimePackage[]> {
71-
return this._kernel.callMethod('getPackagesInstalled');
70+
async getPackages(token?: vscode.CancellationToken): Promise<positron.LanguageRuntimePackage[]> {
71+
return this._callMethod<positron.LanguageRuntimePackage[]>('getPackagesInstalled', token);
7272
}
7373

7474
/**
@@ -83,54 +83,70 @@ export class CondaPackageManager implements IPackageManager {
8383
}
8484
}
8585

86-
async installPackages(packages: positron.PackageSpec[]): Promise<void> {
86+
async installPackages(packages: positron.PackageSpec[], token?: vscode.CancellationToken): Promise<void> {
8787
if (packages.length === 0) {
8888
return;
8989
}
9090

91+
if (token?.isCancellationRequested) {
92+
throw new vscode.CancellationError();
93+
}
94+
9195
await this._ensureConda();
9296

9397
const packageSpecs = this._formatPackageSpecs(packages);
9498
const envPrefix = await this._getEnvironmentPrefix();
9599
const args = ['install', '--prefix', envPrefix, '-y', ...packageSpecs];
96100

97-
await this._executeCondaInTerminal(args);
101+
await this._executeCondaInTerminal(args, token);
98102
}
99103

100-
async uninstallPackages(packages: string[]): Promise<void> {
104+
async uninstallPackages(packages: string[], token?: vscode.CancellationToken): Promise<void> {
101105
if (packages.length === 0) {
102106
return;
103107
}
104108

109+
if (token?.isCancellationRequested) {
110+
throw new vscode.CancellationError();
111+
}
112+
105113
await this._ensureConda();
106114

107115
const envPrefix = await this._getEnvironmentPrefix();
108116
const args = ['remove', '--prefix', envPrefix, '-y', ...packages];
109117

110-
await this._executeCondaInTerminal(args);
118+
await this._executeCondaInTerminal(args, token);
111119
}
112120

113-
async updatePackages(packages: positron.PackageSpec[]): Promise<void> {
121+
async updatePackages(packages: positron.PackageSpec[], token?: vscode.CancellationToken): Promise<void> {
114122
// Use installPackages() because conda update doesn't support version specs.
115123
// conda install will update (or downgrade) to the specified version.
116-
return this.installPackages(packages);
124+
return this.installPackages(packages, token);
117125
}
118126

119-
async updateAllPackages(): Promise<void> {
127+
async updateAllPackages(token?: vscode.CancellationToken): Promise<void> {
128+
if (token?.isCancellationRequested) {
129+
throw new vscode.CancellationError();
130+
}
131+
120132
await this._ensureConda();
121133

122134
const envPrefix = await this._getEnvironmentPrefix();
123135
const args = ['update', '--prefix', envPrefix, '--all', '-y'];
124136

125-
await this._executeCondaInTerminal(args);
137+
await this._executeCondaInTerminal(args, token);
126138
}
127139

128-
async searchPackages(query: string): Promise<positron.LanguageRuntimePackage[]> {
140+
async searchPackages(query: string, token?: vscode.CancellationToken): Promise<positron.LanguageRuntimePackage[]> {
141+
if (token?.isCancellationRequested) {
142+
throw new vscode.CancellationError();
143+
}
144+
129145
await this._ensureConda();
130146

131147
try {
132148
// Use wildcard pattern for partial matching
133-
const result = await this._executeCondaWithOutput(['search', `*${query}*`, '--json']);
149+
const result = await this._executeCondaWithOutput(['search', `*${query}*`, '--json'], token);
134150
const json = parseCondaSearchResult(result);
135151

136152
// Return unique package names with the latest version (sorted by timestamp)
@@ -144,17 +160,24 @@ export class CondaPackageManager implements IPackageManager {
144160
version: latest.version,
145161
};
146162
});
147-
} catch {
163+
} catch (e) {
164+
if (e instanceof vscode.CancellationError) {
165+
throw e;
166+
}
148167
// Return empty array if search fails (e.g., no matches)
149168
return [];
150169
}
151170
}
152171

153-
async searchPackageVersions(name: string): Promise<string[]> {
172+
async searchPackageVersions(name: string, token?: vscode.CancellationToken): Promise<string[]> {
173+
if (token?.isCancellationRequested) {
174+
throw new vscode.CancellationError();
175+
}
176+
154177
await this._ensureConda();
155178

156179
try {
157-
const result = await this._executeCondaWithOutput(['search', name, '--json']);
180+
const result = await this._executeCondaWithOutput(['search', name, '--json'], token);
158181
const json = parseCondaSearchResult(result);
159182

160183
// Get all unique versions for this package
@@ -167,7 +190,10 @@ export class CondaPackageManager implements IPackageManager {
167190
const sorted = [...packageInfo].sort((a, b) => b.timestamp - a.timestamp);
168191
const versions = [...new Set(sorted.map((p) => p.version))];
169192
return versions;
170-
} catch {
193+
} catch (e) {
194+
if (e instanceof vscode.CancellationError) {
195+
throw e;
196+
}
171197
return [];
172198
}
173199
}
@@ -224,31 +250,74 @@ export class CondaPackageManager implements IPackageManager {
224250

225251
/**
226252
* Execute a conda command in the terminal (visible to user).
253+
* @param args The conda arguments to execute
254+
* @param token Optional cancellation token
227255
*/
228-
private async _executeCondaInTerminal(args: string[]): Promise<void> {
256+
private async _executeCondaInTerminal(args: string[], token?: vscode.CancellationToken): Promise<void> {
229257
const condaFile = await this._getCondaFile();
230258
const terminalService = this._serviceContainer
231259
.get<ITerminalServiceFactory>(ITerminalServiceFactory)
232260
.getTerminalService({});
233261
// Ensure terminal is created and ready before sending command
234262
await terminalService.show();
235-
const tokenSource = new vscode.CancellationTokenSource();
263+
264+
const disposable = token?.onCancellationRequested(async () => {
265+
// Send Ctrl+C to interrupt the running command
266+
await terminalService.sendText('\x03');
267+
});
268+
236269
try {
237-
await terminalService.sendCommand(condaFile, args, tokenSource.token);
270+
await terminalService.sendCommand(condaFile, args, token);
238271
} finally {
239-
tokenSource.dispose();
272+
disposable?.dispose();
240273
}
241274
}
242275

243276
/**
244277
* Execute a conda command and capture stdout.
245278
*/
246-
private async _executeCondaWithOutput(args: string[]): Promise<string> {
279+
private async _executeCondaWithOutput(args: string[], token?: vscode.CancellationToken): Promise<string> {
247280
const condaFile = await this._getCondaFile();
248281
const processServiceFactory = this._serviceContainer.get<IProcessServiceFactory>(IProcessServiceFactory);
249282
const processService = await processServiceFactory.create();
250283

251-
const result = await processService.exec(condaFile, args);
284+
const result = await processService.exec(condaFile, args, { token });
252285
return result.stdout;
253286
}
287+
288+
/**
289+
* Call a kernel method with cancellation support.
290+
* If the token is cancelled, interrupts the kernel (if supported).
291+
*/
292+
private async _callMethod<T>(method: string, token?: vscode.CancellationToken, ...args: unknown[]): Promise<T> {
293+
if (token?.isCancellationRequested) {
294+
throw new vscode.CancellationError();
295+
}
296+
297+
const resultPromise = this._session.callMethod(method, ...args) as Promise<T>;
298+
299+
// If no token provided, just return the method result
300+
if (!token) {
301+
return resultPromise;
302+
}
303+
304+
// Wrap callMethod promise with cancellation handling
305+
return new Promise<T>((resolve, reject) => {
306+
const cancelDisp = token.onCancellationRequested(async () => {
307+
// Interrupt the session via the runtime service
308+
await positron.runtime.interruptSession(this._session.metadata.sessionId);
309+
reject(new vscode.CancellationError());
310+
});
311+
312+
resultPromise
313+
.then((result) => {
314+
cancelDisp.dispose();
315+
resolve(result);
316+
})
317+
.catch((err) => {
318+
cancelDisp.dispose();
319+
reject(err);
320+
});
321+
});
322+
}
254323
}

extensions/positron-python/src/client/positron/packages/packageManagerFactory.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { IServiceContainer } from '../../ioc/types';
77
import { EnvironmentType } from '../../pythonEnvironments/info';
88
import { CondaPackageManager } from './condaPackageManager';
99
import { PipPackageManager } from './pipPackageManager';
10-
import { IPackageManager, MessageEmitter, PackageKernel } from './types';
10+
import { IPackageManager, MessageEmitter, PackageSession } from './types';
1111
import { UvPackageManager } from './uvPackageManager';
1212

1313
/**
@@ -24,30 +24,30 @@ export class PackageManagerFactory {
2424
* @param pythonPath The path to the Python interpreter
2525
* @param messageEmitter The emitter for runtime messages
2626
* @param serviceContainer The service container for dependency injection
27-
* @param kernel The kernel for RPC-based package listing
27+
* @param session The session for RPC-based package operations
2828
* @returns The appropriate package manager for the environment
2929
*/
3030
static create(
3131
runtimeSource: EnvironmentType | string | undefined,
3232
pythonPath: string,
3333
messageEmitter: MessageEmitter,
3434
serviceContainer: IServiceContainer,
35-
kernel: PackageKernel,
35+
session: PackageSession,
3636
): IPackageManager {
3737
if (runtimeSource?.toLowerCase() === EnvironmentType.Uv.toLowerCase()) {
38-
return new UvPackageManager(pythonPath, messageEmitter, serviceContainer, kernel);
38+
return new UvPackageManager(pythonPath, messageEmitter, serviceContainer, session);
3939
}
4040

4141
if (runtimeSource?.toLowerCase() === EnvironmentType.Conda.toLowerCase()) {
42-
return new CondaPackageManager(pythonPath, messageEmitter, serviceContainer, kernel);
42+
return new CondaPackageManager(pythonPath, messageEmitter, serviceContainer, session);
4343
}
4444

4545
if (runtimeSource?.toLowerCase() === EnvironmentType.Venv.toLowerCase()) {
46-
return new PipPackageManager(pythonPath, messageEmitter, serviceContainer, kernel);
46+
return new PipPackageManager(pythonPath, messageEmitter, serviceContainer, session);
4747
}
4848

4949
// Default to PipPackageManager for all other environment types
5050
// This includes Pyenv, Global, System, VirtualEnv, etc.
51-
return new PipPackageManager(pythonPath, messageEmitter, serviceContainer, kernel);
51+
return new PipPackageManager(pythonPath, messageEmitter, serviceContainer, session);
5252
}
5353
}

0 commit comments

Comments
 (0)