jupyterlab_nb_venv_kernels_ui_extension 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Stellars Henson
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # jupyterlab_nb_venv_kernels_ui_extension
2
+
3
+ [![GitHub Actions](https://github.com/stellarshenson/jupyterlab_nb_venv_kernels_ui_extension/actions/workflows/build.yml/badge.svg)](https://github.com/stellarshenson/jupyterlab_nb_venv_kernels_ui_extension/actions/workflows/build.yml)
4
+ [![npm version](https://img.shields.io/npm/v/jupyterlab_nb_venv_kernels_ui_extension.svg)](https://www.npmjs.com/package/jupyterlab_nb_venv_kernels_ui_extension)
5
+ [![PyPI version](https://img.shields.io/pypi/v/jupyterlab-nb-venv-kernels-ui-extension.svg)](https://pypi.org/project/jupyterlab-nb-venv-kernels-ui-extension/)
6
+ [![Total PyPI downloads](https://static.pepy.tech/badge/jupyterlab-nb-venv-kernels-ui-extension)](https://pepy.tech/project/jupyterlab-nb-venv-kernels-ui-extension)
7
+ [![JupyterLab 4](https://img.shields.io/badge/JupyterLab-4-orange.svg)](https://jupyterlab.readthedocs.io/en/stable/)
8
+ [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
+ [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
+
11
+ > [!IMPORTANT]
12
+ > **Package Renamed**: This package was renamed from `jupyterlab_launcher_navigate_to_kernel_extension` to `jupyterlab_nb_venv_kernels_ui_extension` in version 1.2.8. If you have the old package installed, please uninstall it first: `pip uninstall jupyterlab-launcher-navigate-to-kernel-extension`
13
+
14
+ Right-click on any kernel launcher card to navigate to its project directory or open a terminal there. Intended to help navigate around a busy workspace with many projects. Similar to [jupyterlab_terminal_show_in_file_browser_extension](https://github.com/stellarshenson/jupyterlab_terminal_show_in_file_browser_extension).
15
+
16
+ ![](.resources/screenshot.png)
17
+
18
+ ## Features
19
+
20
+ **Context Menu (right-click on kernel launcher card)**:
21
+ - **Show in File Browser** - Navigate to the kernel's project root
22
+ - **Open Terminal at Location** - Open terminal at the kernel's project directory
23
+ - **Unregister Kernel** - Remove kernel from `nb_venv_kernels` registry (requires `nb_venv_kernels`)
24
+ - **Remove Environment** - Permanently delete local `.venv` environments with confirmation (requires `nb_venv_kernels`)
25
+
26
+ **Kernel Menu**:
27
+ - **Scan for Virtual Environments** - Discover and register new virtual environments in your workspace (requires `nb_venv_kernels`)
28
+
29
+ **General**:
30
+ - **Project-aware navigation** - For `.venv` environments, navigates to project root (one level up from `.venv`)
31
+ - **Dynamic kernel support** - Works with `nb_conda_kernels` and `nb_venv_kernels` providers
32
+
33
+ ## Requirements
34
+
35
+ - JupyterLab >= 4.0.0
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install jupyterlab-nb-venv-kernels-ui-extension
41
+ ```
42
+
43
+ ## Uninstall
44
+
45
+ ```bash
46
+ pip uninstall jupyterlab_nb_venv_kernels_ui_extension
47
+ ```
package/lib/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * Initialization data for the jupyterlab_nb_venv_kernels_ui_extension extension.
4
+ */
5
+ declare const plugin: JupyterFrontEndPlugin<void>;
6
+ export default plugin;
package/lib/index.js ADDED
@@ -0,0 +1,518 @@
1
+ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
2
+ import { ILauncher } from '@jupyterlab/launcher';
3
+ import { ITerminalTracker } from '@jupyterlab/terminal';
4
+ import { showErrorMessage, showDialog, Dialog } from '@jupyterlab/apputils';
5
+ import { ServerConnection } from '@jupyterlab/services';
6
+ import { URLExt, PageConfig } from '@jupyterlab/coreutils';
7
+ import { Widget } from '@lumino/widgets';
8
+ /**
9
+ * Command IDs for the extension.
10
+ */
11
+ const SHOW_IN_BROWSER_CMD = 'launcher:show-kernel-in-file-browser';
12
+ const OPEN_TERMINAL_CMD = 'launcher:open-terminal-at-kernel';
13
+ const UNREGISTER_KERNEL_CMD = 'launcher:unregister-venv-kernel';
14
+ const REMOVE_ENVIRONMENT_CMD = 'launcher:remove-venv-environment';
15
+ /**
16
+ * Command ID for nb_venv_kernels refresh (provided by that extension).
17
+ */
18
+ const NB_VENV_KERNELS_REFRESH_CMD = 'nb_venv_kernels:refresh';
19
+ /**
20
+ * Store for the last right-clicked kernel display name.
21
+ * This is set when a context menu is opened on a launcher card.
22
+ */
23
+ let lastClickedKernelName = null;
24
+ /**
25
+ * Flag indicating whether nb_venv_kernels extension is available.
26
+ * Checked once at startup.
27
+ */
28
+ let nbVenvKernelsAvailable = false;
29
+ /**
30
+ * Fetch the kernel path information from the server.
31
+ *
32
+ * @param displayName - The display name of the kernel
33
+ * @returns Promise resolving to kernel path info or null if not available
34
+ */
35
+ async function fetchKernelPath(displayName) {
36
+ const settings = ServerConnection.makeSettings();
37
+ const url = URLExt.join(settings.baseUrl, 'api', 'kernel-path', encodeURIComponent(displayName));
38
+ try {
39
+ const response = await ServerConnection.makeRequest(url, {}, settings);
40
+ if (!response.ok) {
41
+ const data = (await response.json());
42
+ console.warn(`Failed to get kernel path: ${data.error}`);
43
+ return null;
44
+ }
45
+ const data = (await response.json());
46
+ return data;
47
+ }
48
+ catch (error) {
49
+ console.error('Error fetching kernel path:', error);
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Check if nb_venv_kernels extension is available.
55
+ *
56
+ * @returns Promise resolving to true if available, false otherwise
57
+ */
58
+ async function checkNbVenvKernelsAvailable() {
59
+ const settings = ServerConnection.makeSettings();
60
+ const url = URLExt.join(settings.baseUrl, 'nb-venv-kernels', 'environments');
61
+ try {
62
+ const response = await ServerConnection.makeRequest(url, {}, settings);
63
+ return response.ok;
64
+ }
65
+ catch (error) {
66
+ console.debug('nb_venv_kernels extension not installed:', error);
67
+ return false;
68
+ }
69
+ }
70
+ /**
71
+ * Fetch list of venv environments from nb_venv_kernels API.
72
+ *
73
+ * @returns Promise resolving to environments list or null if not available
74
+ */
75
+ async function fetchVenvEnvironments() {
76
+ const settings = ServerConnection.makeSettings();
77
+ const url = URLExt.join(settings.baseUrl, 'nb-venv-kernels', 'environments');
78
+ try {
79
+ const response = await ServerConnection.makeRequest(url, {}, settings);
80
+ if (!response.ok) {
81
+ console.warn('nb_venv_kernels API not available');
82
+ return null;
83
+ }
84
+ const data = (await response.json());
85
+ return data;
86
+ }
87
+ catch (error) {
88
+ console.debug('nb_venv_kernels extension not installed:', error);
89
+ return null;
90
+ }
91
+ }
92
+ /**
93
+ * Find a venv environment matching the given display name.
94
+ *
95
+ * @param displayName - The kernel display name to match
96
+ * @returns The matching environment or null
97
+ */
98
+ async function findVenvEnvironment(displayName) {
99
+ const envData = await fetchVenvEnvironments();
100
+ if (!envData) {
101
+ return null;
102
+ }
103
+ // Find matching environment by name
104
+ // Display name patterns: "Python (envname)", "envname", "Python 3 (envname)"
105
+ for (const env of envData.environments) {
106
+ // Skip conda environments - they can't be unregistered via nb_venv_kernels
107
+ if (env.type === 'conda') {
108
+ continue;
109
+ }
110
+ const envName = env.name || '';
111
+ const customName = env.custom_name || '';
112
+ // Check if display_name contains the environment name
113
+ if ((envName && displayName.includes(envName)) ||
114
+ (customName && displayName.includes(customName))) {
115
+ return env;
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ /**
121
+ * Unregister a venv environment via nb_venv_kernels API.
122
+ *
123
+ * @param envPath - The path of the environment to unregister
124
+ * @returns Promise resolving to unregister result
125
+ */
126
+ async function unregisterVenvKernel(envPath) {
127
+ const settings = ServerConnection.makeSettings();
128
+ const url = URLExt.join(settings.baseUrl, 'nb-venv-kernels', 'unregister');
129
+ try {
130
+ const response = await ServerConnection.makeRequest(url, {
131
+ method: 'POST',
132
+ body: JSON.stringify({ path: envPath })
133
+ }, settings);
134
+ const data = (await response.json());
135
+ if (!response.ok) {
136
+ return {
137
+ success: false,
138
+ error: data.error || 'Failed to unregister kernel'
139
+ };
140
+ }
141
+ return { success: true, message: data.message };
142
+ }
143
+ catch (error) {
144
+ console.error('Error unregistering kernel:', error);
145
+ return {
146
+ success: false,
147
+ error: `Network error: ${error}`
148
+ };
149
+ }
150
+ }
151
+ /**
152
+ * Check if an environment path is a local .venv environment.
153
+ * Local environments have .venv in their path.
154
+ *
155
+ * @param envPath - The environment path to check
156
+ * @returns true if the environment is local (.venv based)
157
+ */
158
+ function isLocalVenvEnvironment(envPath) {
159
+ return envPath.includes('/.venv') || envPath.includes('\\.venv');
160
+ }
161
+ /**
162
+ * Remove a directory via the Jupyter server contents API.
163
+ *
164
+ * @param dirPath - The absolute path to the directory to remove
165
+ * @param serverRoot - The server root directory
166
+ * @returns Promise resolving to success status and optional error message
167
+ */
168
+ async function removeDirectory(dirPath, serverRoot) {
169
+ const settings = ServerConnection.makeSettings();
170
+ // Determine relative path for the contents API
171
+ let relativePath = null;
172
+ // If path doesn't start with '/', it's already relative - use it directly
173
+ if (!dirPath.startsWith('/')) {
174
+ relativePath = dirPath;
175
+ console.debug(`Path is already relative: ${relativePath}`);
176
+ }
177
+ else {
178
+ // Convert absolute path to relative path
179
+ relativePath = toRelativePath(dirPath, serverRoot);
180
+ // If standard conversion fails, try alternative approaches
181
+ if (relativePath === null) {
182
+ console.debug(`Path conversion failed: dirPath="${dirPath}", serverRoot="${serverRoot}"`);
183
+ // Fallback: if serverRoot is empty or '~', try using home-relative path
184
+ if (!serverRoot || serverRoot === '~' || serverRoot === '') {
185
+ // Extract path from after '/home/username' or similar
186
+ const homeMatch = dirPath.match(/^\/(?:home|Users)\/[^/]+\/(.+)$/);
187
+ if (homeMatch) {
188
+ relativePath = homeMatch[1];
189
+ console.debug(`Using home-relative fallback path: ${relativePath}`);
190
+ }
191
+ }
192
+ // If still null, the path is truly outside workspace
193
+ if (relativePath === null) {
194
+ return {
195
+ success: false,
196
+ error: `Environment path "${dirPath}" is outside the workspace (root: "${serverRoot}") and cannot be removed.`
197
+ };
198
+ }
199
+ }
200
+ }
201
+ const url = URLExt.join(settings.baseUrl, 'api', 'contents', relativePath);
202
+ try {
203
+ const response = await ServerConnection.makeRequest(url, { method: 'DELETE' }, settings);
204
+ if (!response.ok) {
205
+ const text = await response.text();
206
+ return {
207
+ success: false,
208
+ error: `Server error: ${response.status} ${text}`
209
+ };
210
+ }
211
+ return { success: true };
212
+ }
213
+ catch (error) {
214
+ console.error('Error removing directory:', error);
215
+ return {
216
+ success: false,
217
+ error: `Network error: ${error}`
218
+ };
219
+ }
220
+ }
221
+ /**
222
+ * Expand tilde in path using the home directory extracted from absolutePath.
223
+ *
224
+ * @param path - Path that may contain ~
225
+ * @param absolutePath - An absolute path to extract home directory from
226
+ * @returns Path with ~ expanded, or original if expansion not possible
227
+ */
228
+ function expandTilde(path, absolutePath) {
229
+ if (!path.startsWith('~')) {
230
+ return path;
231
+ }
232
+ // Extract home directory from absolute path
233
+ // Matches /home/username or /Users/username
234
+ const match = absolutePath.match(/^(\/(?:home|Users)\/[^/]+)/);
235
+ if (!match) {
236
+ return path;
237
+ }
238
+ const homedir = match[1];
239
+ if (path === '~') {
240
+ return homedir;
241
+ }
242
+ if (path.startsWith('~/')) {
243
+ return homedir + path.slice(1);
244
+ }
245
+ return path;
246
+ }
247
+ /**
248
+ * Convert an absolute filesystem path to a path relative to the server root.
249
+ *
250
+ * @param absolutePath - The absolute filesystem path
251
+ * @param serverRoot - The server's root directory (may contain ~)
252
+ * @returns The relative path for the file browser, or null if outside server root
253
+ */
254
+ function toRelativePath(absolutePath, serverRoot) {
255
+ // Normalize path - ensure no trailing slashes
256
+ const normalizedPath = absolutePath.replace(/\/+$/, '');
257
+ // Expand tilde in serverRoot and normalize
258
+ const normalizedRoot = expandTilde(serverRoot, absolutePath).replace(/\/+$/, '');
259
+ // Check if path is the server root
260
+ if (normalizedPath === normalizedRoot) {
261
+ return '';
262
+ }
263
+ // Check if path is inside the server root
264
+ const rootPrefix = normalizedRoot + '/';
265
+ if (normalizedPath.startsWith(rootPrefix)) {
266
+ return normalizedPath.slice(rootPrefix.length);
267
+ }
268
+ // Path is outside the server root
269
+ return null;
270
+ }
271
+ /**
272
+ * Extract kernel display name from a launcher card element.
273
+ *
274
+ * @param element - The clicked element or its parent launcher card
275
+ * @returns The kernel display name or null if not found
276
+ */
277
+ function extractKernelNameFromCard(element) {
278
+ // Find the launcher card (might be the element itself or a parent)
279
+ const card = element.closest('.jp-LauncherCard');
280
+ if (!card) {
281
+ return null;
282
+ }
283
+ // Find the label element within the card
284
+ const label = card.querySelector('.jp-LauncherCard-label');
285
+ if (label && label.textContent) {
286
+ return label.textContent.trim();
287
+ }
288
+ // Fallback: try the title attribute
289
+ if (card.title) {
290
+ return card.title;
291
+ }
292
+ return null;
293
+ }
294
+ /**
295
+ * Setup event listener to capture right-clicked kernel name.
296
+ */
297
+ function setupContextMenuCapture() {
298
+ document.addEventListener('contextmenu', (event) => {
299
+ const target = event.target;
300
+ if (target) {
301
+ const card = target.closest('.jp-LauncherCard');
302
+ if (card) {
303
+ lastClickedKernelName = extractKernelNameFromCard(target);
304
+ }
305
+ }
306
+ }, true); // Use capture phase to get event before context menu
307
+ }
308
+ /**
309
+ * Initialization data for the jupyterlab_nb_venv_kernels_ui_extension extension.
310
+ */
311
+ const plugin = {
312
+ id: 'jupyterlab_nb_venv_kernels_ui_extension:plugin',
313
+ description: "Right-click kernel launcher cards to navigate file browser to kernel's location or open terminal there",
314
+ autoStart: true,
315
+ requires: [IDefaultFileBrowser],
316
+ optional: [ILauncher, ITerminalTracker],
317
+ activate: (app, fileBrowser, launcher, terminalTracker) => {
318
+ console.log('JupyterLab extension jupyterlab_nb_venv_kernels_ui_extension is activated!');
319
+ const { commands } = app;
320
+ // Get the server root directory from PageConfig
321
+ const serverRoot = PageConfig.getOption('serverRoot');
322
+ // Setup event listener to capture kernel name on right-click
323
+ setupContextMenuCapture();
324
+ // Check if nb_venv_kernels is available (for unregister feature)
325
+ checkNbVenvKernelsAvailable().then(available => {
326
+ nbVenvKernelsAvailable = available;
327
+ if (available) {
328
+ console.log('nb_venv_kernels extension detected - unregister feature enabled');
329
+ }
330
+ });
331
+ // Add the "Show in File Browser" command
332
+ commands.addCommand(SHOW_IN_BROWSER_CMD, {
333
+ label: 'Show in File Browser',
334
+ caption: "Navigate file browser to kernel's directory",
335
+ isEnabled: () => lastClickedKernelName !== null,
336
+ execute: async () => {
337
+ if (!lastClickedKernelName) {
338
+ await showErrorMessage('No Kernel Selected', 'Could not determine which kernel was selected.');
339
+ return;
340
+ }
341
+ const kernelInfo = await fetchKernelPath(lastClickedKernelName);
342
+ if (!kernelInfo) {
343
+ await showErrorMessage('Kernel Not Found', `Could not find path information for kernel "${lastClickedKernelName}".`);
344
+ return;
345
+ }
346
+ // Prefer env_path (conda environment) if available, otherwise use resource_dir
347
+ const targetPath = kernelInfo.env_path || kernelInfo.resource_dir;
348
+ // Convert to relative path for file browser
349
+ const relativePath = toRelativePath(targetPath, serverRoot);
350
+ // If outside workspace, fallback to workspace root
351
+ const navigatePath = relativePath === null ? '' : relativePath;
352
+ try {
353
+ const absolutePath = navigatePath === '' ? '/' : '/' + navigatePath;
354
+ await fileBrowser.model.cd(absolutePath);
355
+ }
356
+ catch (error) {
357
+ console.error('Failed to navigate file browser:', error);
358
+ await showErrorMessage('Navigation Error', `Failed to navigate to: ${targetPath}\nError: ${error}`);
359
+ }
360
+ }
361
+ });
362
+ // Add the "Open Terminal at location" command
363
+ commands.addCommand(OPEN_TERMINAL_CMD, {
364
+ label: 'Open Terminal at Location',
365
+ caption: "Open a terminal at the kernel's directory",
366
+ isEnabled: () => lastClickedKernelName !== null && terminalTracker !== null,
367
+ execute: async () => {
368
+ if (!lastClickedKernelName) {
369
+ await showErrorMessage('No Kernel Selected', 'Could not determine which kernel was selected.');
370
+ return;
371
+ }
372
+ const kernelInfo = await fetchKernelPath(lastClickedKernelName);
373
+ if (!kernelInfo) {
374
+ await showErrorMessage('Kernel Not Found', `Could not find path information for kernel "${lastClickedKernelName}".`);
375
+ return;
376
+ }
377
+ // Prefer env_path (conda environment) if available, otherwise use resource_dir
378
+ const targetPath = kernelInfo.env_path || kernelInfo.resource_dir;
379
+ // Convert to relative path for terminal (terminal:create-new requires relative path)
380
+ const relativePath = toRelativePath(targetPath, serverRoot);
381
+ // If outside workspace, use empty string (workspace root)
382
+ const terminalCwd = relativePath === null ? '' : relativePath;
383
+ try {
384
+ // Open a new terminal with relative path
385
+ await commands.execute('terminal:create-new', {
386
+ cwd: terminalCwd
387
+ });
388
+ }
389
+ catch (error) {
390
+ console.error('Failed to open terminal:', error);
391
+ await showErrorMessage('Terminal Error', `Failed to open terminal at: ${targetPath}\nError: ${error}`);
392
+ }
393
+ }
394
+ });
395
+ // Add the "Unregister Kernel" command (shown when nb_venv_kernels is available)
396
+ commands.addCommand(UNREGISTER_KERNEL_CMD, {
397
+ label: 'Unregister Kernel',
398
+ caption: 'Remove this kernel from nb_venv_kernels registry',
399
+ isEnabled: () => lastClickedKernelName !== null && nbVenvKernelsAvailable,
400
+ isVisible: () => nbVenvKernelsAvailable,
401
+ execute: async () => {
402
+ if (!lastClickedKernelName) {
403
+ await showErrorMessage('No Kernel Selected', 'Could not determine which kernel was selected.');
404
+ return;
405
+ }
406
+ // Find the venv environment for this kernel
407
+ const env = await findVenvEnvironment(lastClickedKernelName);
408
+ if (!env) {
409
+ await showErrorMessage('Cannot Unregister', `"${lastClickedKernelName}" is not managed by nb_venv_kernels.`);
410
+ return;
411
+ }
412
+ // Unregister the kernel
413
+ const result = await unregisterVenvKernel(env.path);
414
+ if (result.success) {
415
+ console.log(`Kernel unregistered: ${env.path}`);
416
+ // Refresh kernel list so changes are visible immediately
417
+ await commands.execute(NB_VENV_KERNELS_REFRESH_CMD).catch(() => {
418
+ // Ignore if refresh command not available
419
+ });
420
+ const bodyWidget = new Widget();
421
+ const p1 = document.createElement('p');
422
+ p1.textContent = `Successfully unregistered "${env.name}" (${env.path}).`;
423
+ const p2 = document.createElement('p');
424
+ p2.textContent = `To re-register, run: nb_venv_kernels register ${env.path}`;
425
+ bodyWidget.node.appendChild(p1);
426
+ bodyWidget.node.appendChild(p2);
427
+ await showDialog({
428
+ title: 'Kernel Unregistered',
429
+ body: bodyWidget,
430
+ buttons: [Dialog.okButton()]
431
+ });
432
+ }
433
+ else {
434
+ await showErrorMessage('Unregister Failed', `Failed to unregister kernel: ${result.error}`);
435
+ }
436
+ }
437
+ });
438
+ // Add the "Remove Environment" command (dangerous - physically removes .venv folder)
439
+ commands.addCommand(REMOVE_ENVIRONMENT_CMD, {
440
+ label: 'Remove Environment (dangerous)',
441
+ caption: 'Physically remove the .venv folder containing this environment',
442
+ isEnabled: () => lastClickedKernelName !== null && nbVenvKernelsAvailable,
443
+ isVisible: () => nbVenvKernelsAvailable,
444
+ execute: async () => {
445
+ if (!lastClickedKernelName) {
446
+ await showErrorMessage('No Kernel Selected', 'Could not determine which kernel was selected.');
447
+ return;
448
+ }
449
+ // Find the venv environment for this kernel
450
+ const env = await findVenvEnvironment(lastClickedKernelName);
451
+ if (!env) {
452
+ await showErrorMessage('Cannot Remove', `"${lastClickedKernelName}" is not managed by nb_venv_kernels.`);
453
+ return;
454
+ }
455
+ // Check if this is a local .venv environment
456
+ if (!isLocalVenvEnvironment(env.path)) {
457
+ const notLocalWidget = new Widget();
458
+ const notLocalP1 = document.createElement('p');
459
+ notLocalP1.textContent = `"${env.name}" is not a local .venv environment.`;
460
+ const notLocalP2 = document.createElement('p');
461
+ notLocalP2.textContent = 'Only local environments (with .venv in their path) can be removed.';
462
+ notLocalWidget.node.appendChild(notLocalP1);
463
+ notLocalWidget.node.appendChild(notLocalP2);
464
+ await showDialog({
465
+ title: 'Cannot Remove',
466
+ body: notLocalWidget,
467
+ buttons: [Dialog.okButton()]
468
+ });
469
+ return;
470
+ }
471
+ // Show confirmation dialog
472
+ const confirmWidget = new Widget();
473
+ const confirmP1 = document.createElement('p');
474
+ confirmP1.textContent = `Are you sure you want to permanently remove virtual environment "${env.name}"?`;
475
+ const confirmP2 = document.createElement('p');
476
+ confirmP2.textContent = `This will delete: ${env.path}`;
477
+ const confirmP3 = document.createElement('p');
478
+ confirmP3.style.fontWeight = 'bold';
479
+ confirmP3.textContent = 'This action cannot be undone!';
480
+ confirmWidget.node.appendChild(confirmP1);
481
+ confirmWidget.node.appendChild(confirmP2);
482
+ confirmWidget.node.appendChild(confirmP3);
483
+ const result = await showDialog({
484
+ title: 'Remove Environment',
485
+ body: confirmWidget,
486
+ buttons: [
487
+ Dialog.cancelButton(),
488
+ Dialog.warnButton({ label: 'Remove' })
489
+ ]
490
+ });
491
+ if (!result.button.accept) {
492
+ return;
493
+ }
494
+ // First unregister the kernel
495
+ const unregisterResult = await unregisterVenvKernel(env.path);
496
+ if (!unregisterResult.success) {
497
+ await showErrorMessage('Remove Failed', `Failed to unregister kernel before removal: ${unregisterResult.error}`);
498
+ return;
499
+ }
500
+ // Then remove the directory
501
+ const removeResult = await removeDirectory(env.path, serverRoot);
502
+ if (removeResult.success) {
503
+ console.log(`Environment removed: ${env.path}`);
504
+ // Refresh kernel list so changes are visible immediately
505
+ await commands.execute(NB_VENV_KERNELS_REFRESH_CMD).catch(() => {
506
+ // Ignore if refresh command not available
507
+ });
508
+ await showErrorMessage('Environment Removed', `Successfully removed "${env.name}" (${env.path}).`);
509
+ }
510
+ else {
511
+ await showErrorMessage('Remove Failed', `Kernel was unregistered but failed to remove directory: ${removeResult.error}`);
512
+ }
513
+ }
514
+ });
515
+ console.log(`Commands registered: ${SHOW_IN_BROWSER_CMD}, ${OPEN_TERMINAL_CMD}, ${UNREGISTER_KERNEL_CMD}, ${REMOVE_ENVIRONMENT_CMD}`);
516
+ }
517
+ };
518
+ export default plugin;