Skip to content

Commit 58128ae

Browse files
committed
feat(remove-environment): add commands to remove environment and reveal folder
- Implemented `pyneide.removeEnvironment` command to delete the downloaded Python environment and plugins, freeing up disk space. - Added `pyneide.revealEnvironmentFolder` command to open the environment folder in the file manager. - Updated status bar to include options for removing the environment and revealing the folder. - Enhanced changelog to document these new features.
1 parent c811770 commit 58128ae

11 files changed

Lines changed: 357 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- **Remove Environment.** The status bar menu (and the `PyneIDE: Remove
13+
Environment` command) now deletes what PyneIDE downloaded — the Python
14+
environment with its installed plugins, and optionally the Pine language
15+
server — after showing how much each one takes. Until now the only way out
16+
was a clean reinstall, and uninstalling the extension left the whole
17+
environment on disk. **Reveal Environment Folder** opens that location in the
18+
file manager.
19+
1020
### Changed
1121

1222
- **PyneCore pin 6.7.0 → 6.10.2.** The managed environment installs the newer

‎README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,10 @@ view keeps your Scripts, Data, and Output in one place.
189189
2. Open a folder and run **PyneIDE: Initialize Pyne Project** — it scaffolds a
190190
runnable demo plus sample data.
191191
3. When prompted, let PyneIDE **set up the Python environment** (a one-time download
192-
of `uv` + Python + PyneCore into extension storage).
192+
of `uv` + Python + PyneCore into extension storage). It stays there when the
193+
extension is uninstalled, so **PyneIDE: Remove Environment** is there to delete
194+
it again and give the disk space back; **PyneIDE: Reveal Environment Folder**
195+
shows where it lives.
193196
4. Press **Run** on the demo and watch the chart appear.
194197
5. To run your own **Pine**: open a `.pine` file, **Sign In** with your PyneSys API
195198
key when prompted, then Run — it compiles in the cloud and runs like any other

‎package.json‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@
8989
"title": "Get Started",
9090
"category": "PyneIDE"
9191
},
92+
{
93+
"command": "pyneide.removeEnvironment",
94+
"title": "Remove Environment",
95+
"category": "PyneIDE"
96+
},
97+
{
98+
"command": "pyneide.revealEnvironmentFolder",
99+
"title": "Reveal Environment Folder",
100+
"category": "PyneIDE"
101+
},
92102
{
93103
"command": "pyneide.showEnvironmentLog",
94104
"title": "Show Environment Log",

‎src/env/bootstrap.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ function logInterpreterDiag(info: VerifyInfo, log: Logger): void {
115115
}
116116
}
117117

118-
function markerPath(storageDir: string): string {
118+
/** The `env.json` marker describing what the managed venv was built from. */
119+
export function markerPath(storageDir: string): string {
119120
return path.join(storageDir, 'env.json');
120121
}
121122

‎src/env/remove.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
import { markerPath } from './bootstrap';
5+
import type { Logger } from './constants';
6+
import { managedPythonDir, managedVenvDir, uvDir } from './uv';
7+
8+
/**
9+
* Everything PyneIDE downloads lives in globalStorage, which VS Code does NOT
10+
* delete when the extension is uninstalled — without this module the ~250 MB
11+
* managed environment is only removable by hand. Kept vscode-free like the
12+
* rest of src/env; the command UI lives in `removeCommand.ts`.
13+
*/
14+
15+
export type RemovableId = 'python' | 'pine-ls';
16+
17+
export interface RemovableComponent {
18+
id: RemovableId;
19+
label: string;
20+
/** What removing it costs later, shown as the picker row's detail. */
21+
note: string;
22+
/** Existing paths only — the picker never offers what is not on disk. */
23+
paths: string[];
24+
bytes: number;
25+
}
26+
27+
/**
28+
* Directories owned by other modules. Passed in rather than imported so this
29+
* module does not reach into src/pinels and src/plugins (and so src/env keeps
30+
* its one-way dependencies).
31+
*/
32+
export interface ComponentLocations {
33+
/** `src/pinels/installer.ts` `pineLsRoot()`. */
34+
pineLsRoot: string;
35+
/** `src/plugins/installed.ts` `cliWorkdir()`. */
36+
cliWorkdir: string;
37+
}
38+
39+
/**
40+
* The removable components that currently exist on disk, with their sizes.
41+
* The Python environment is one component on purpose: uv, the standalone
42+
* CPython and the venv are useless without each other.
43+
*/
44+
export async function removableComponents(
45+
storageDir: string,
46+
locations: ComponentLocations
47+
): Promise<RemovableComponent[]> {
48+
const python = [
49+
uvDir(storageDir),
50+
managedPythonDir(storageDir),
51+
managedVenvDir(storageDir),
52+
markerPath(storageDir),
53+
locations.cliWorkdir,
54+
].filter((target) => fs.existsSync(target));
55+
const pineLs = [locations.pineLsRoot].filter((target) => fs.existsSync(target));
56+
57+
const components: RemovableComponent[] = [];
58+
if (python.length > 0) {
59+
components.push({
60+
id: 'python',
61+
label: 'Python environment',
62+
note: 'uv, the standalone Python, the virtual environment and any installed plugins',
63+
paths: python,
64+
bytes: await totalBytes(python),
65+
});
66+
}
67+
if (pineLs.length > 0) {
68+
components.push({
69+
id: 'pine-ls',
70+
label: 'Pine language server',
71+
note: 'Installing it again needs an internet connection',
72+
paths: pineLs,
73+
bytes: await totalBytes(pineLs),
74+
});
75+
}
76+
return components;
77+
}
78+
79+
async function totalBytes(paths: string[]): Promise<number> {
80+
let total = 0;
81+
for (const target of paths) total += await pathBytes(target);
82+
return total;
83+
}
84+
85+
/**
86+
* Size of a file or directory tree. Symlinks are measured, never followed:
87+
* a standalone CPython links the same libraries several times over, so
88+
* following them would report several times the size actually freed.
89+
*/
90+
async function pathBytes(target: string): Promise<number> {
91+
let stat: fs.Stats;
92+
try {
93+
stat = await fs.promises.lstat(target);
94+
} catch {
95+
return 0;
96+
}
97+
if (!stat.isDirectory()) return stat.size;
98+
let total = 0;
99+
let entries: fs.Dirent[];
100+
try {
101+
entries = await fs.promises.readdir(target, { withFileTypes: true });
102+
} catch {
103+
return total;
104+
}
105+
for (const entry of entries) {
106+
total += await pathBytes(path.join(target, entry.name));
107+
}
108+
return total;
109+
}
110+
111+
/**
112+
* Delete the given paths. `maxRetries` is what makes this work on Windows,
113+
* where a file a just-stopped process still holds fails with EBUSY/EPERM for
114+
* a moment; a path that stays locked throws and the caller reports it.
115+
*/
116+
export async function removePaths(paths: string[], log: Logger): Promise<void> {
117+
for (const target of paths) {
118+
log(`Removing ${target}`);
119+
await fs.promises.rm(target, {
120+
recursive: true,
121+
force: true,
122+
maxRetries: 5,
123+
retryDelay: 200,
124+
});
125+
}
126+
}
127+
128+
/** Size for a menu row: whole MB, since these are tens to hundreds of MB. */
129+
export function formatBytes(bytes: number): string {
130+
if (bytes < 1_000_000) return '<1 MB';
131+
return `${Math.round(bytes / 1_000_000)} MB`;
132+
}

‎src/env/removeCommand.ts‎

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as fs from 'node:fs';
2+
3+
import * as vscode from 'vscode';
4+
5+
import { pineLsRoot } from '../pinels/installer';
6+
import type { PineLsService } from '../pinels/service';
7+
import { cliWorkdirPath } from '../plugins/installed';
8+
import { SETUP_DOWNLOAD_MB } from './constants';
9+
import type { EnvManager } from './manager';
10+
import {
11+
formatBytes,
12+
removableComponents,
13+
removePaths,
14+
type RemovableComponent,
15+
} from './remove';
16+
17+
interface PickItem extends vscode.QuickPickItem {
18+
component: RemovableComponent;
19+
}
20+
21+
/**
22+
* "Remove Environment" and "Reveal Environment Folder".
23+
*
24+
* Uninstalling the extension leaves globalStorage behind, so without a remove
25+
* command the only way to get the disk space back is to find the folder by
26+
* hand — which is also why Reveal sits next to it.
27+
*/
28+
export function registerRemoveEnvironment(
29+
context: vscode.ExtensionContext,
30+
manager: EnvManager,
31+
pineLs: PineLsService,
32+
output: vscode.OutputChannel
33+
): vscode.Disposable[] {
34+
const storageDir = context.globalStorageUri.fsPath;
35+
return [
36+
vscode.commands.registerCommand('pyneide.removeEnvironment', () =>
37+
removeEnvironment(storageDir, manager, pineLs, output)
38+
),
39+
vscode.commands.registerCommand('pyneide.revealEnvironmentFolder', async () => {
40+
// globalStorage is created lazily, so it may not exist before a setup.
41+
await fs.promises.mkdir(storageDir, { recursive: true });
42+
await vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(storageDir));
43+
}),
44+
];
45+
}
46+
47+
async function removeEnvironment(
48+
storageDir: string,
49+
manager: EnvManager,
50+
pineLs: PineLsService,
51+
output: vscode.OutputChannel
52+
): Promise<void> {
53+
if (manager.state.kind === 'working') {
54+
void vscode.window.showInformationMessage(
55+
'PyneIDE: the environment is busy. Wait for it to finish (or cancel it) before removing it.'
56+
);
57+
return;
58+
}
59+
60+
const components = await vscode.window.withProgress(
61+
{ location: vscode.ProgressLocation.Window, title: 'PyneIDE: measuring the environment…' },
62+
() =>
63+
removableComponents(storageDir, {
64+
pineLsRoot: pineLsRoot(storageDir),
65+
cliWorkdir: cliWorkdirPath(storageDir),
66+
})
67+
);
68+
if (components.length === 0) {
69+
void vscode.window.showInformationMessage(
70+
'PyneIDE: there is nothing to remove — no downloaded environment was found.'
71+
);
72+
return;
73+
}
74+
75+
const picked = await vscode.window.showQuickPick(
76+
components.map((component) => toItem(component)),
77+
{
78+
title: 'PyneIDE — Remove Environment',
79+
placeHolder: 'Select what to delete from PyneIDE storage',
80+
canPickMany: true,
81+
}
82+
);
83+
if (!picked || picked.length === 0) return;
84+
85+
const selected = picked.map((item) => item.component);
86+
const freed = selected.reduce((sum, component) => sum + component.bytes, 0);
87+
const lines = selected.map(
88+
(component) => `${component.label} — ${formatBytes(component.bytes)}`
89+
);
90+
const reinstall = selected.some((component) => component.id === 'python')
91+
? ` Setting the Python environment up again downloads about ${SETUP_DOWNLOAD_MB} MB.`
92+
: '';
93+
const confirm = await vscode.window.showWarningMessage(
94+
'Remove the downloaded PyneIDE environment?',
95+
{
96+
modal: true,
97+
detail:
98+
`${lines.join('\n')}\n\nFrees about ${formatBytes(freed)}. ` +
99+
`This cannot be undone.${reinstall}\n\n` +
100+
'Your scripts, data and project files are not touched.',
101+
},
102+
'Remove'
103+
);
104+
if (confirm !== 'Remove') return;
105+
106+
const removingPineLs = selected.some((component) => component.id === 'pine-ls');
107+
try {
108+
// The server runs from the directory about to be deleted; on Windows that
109+
// alone makes the removal fail.
110+
if (removingPineLs) await pineLs.stopServer();
111+
await vscode.window.withProgress(
112+
{
113+
location: vscode.ProgressLocation.Notification,
114+
title: 'PyneIDE: removing the environment',
115+
cancellable: false,
116+
},
117+
async () => {
118+
for (const component of selected) {
119+
await removePaths(component.paths, (message) => output.appendLine(message));
120+
}
121+
}
122+
);
123+
} catch (err) {
124+
const message = err instanceof Error ? err.message : String(err);
125+
output.appendLine(`Removing the environment failed: ${message}`);
126+
const choice = await vscode.window.showErrorMessage(
127+
`PyneIDE: removing the environment failed: ${message}. ` +
128+
'A running PyneIDE process may still hold files open — reloading the window releases them.',
129+
'Reload Window',
130+
'Show Log'
131+
);
132+
if (choice === 'Reload Window') {
133+
await vscode.commands.executeCommand('workbench.action.reloadWindow');
134+
} else if (choice === 'Show Log') {
135+
output.show();
136+
}
137+
return;
138+
}
139+
140+
await manager.check();
141+
if (removingPineLs) await pineLs.restart();
142+
void vscode.window.showInformationMessage(
143+
`PyneIDE: removed ${selected.map((component) => component.label.toLowerCase()).join(' and ')}. ` +
144+
`Freed about ${formatBytes(freed)}.`
145+
);
146+
}
147+
148+
function toItem(component: RemovableComponent): PickItem {
149+
return {
150+
label: component.label,
151+
description: formatBytes(component.bytes),
152+
detail: component.note,
153+
// The Python environment is what the disk space is in, and reinstalling it
154+
// is one click; the Pine LS has to be downloaded again, so it is opt-in.
155+
picked: component.id === 'python',
156+
component,
157+
};
158+
}

‎src/env/statusBar.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,10 +245,20 @@ export class EnvStatusBar {
245245
label: '$(tools) Repair Environment (clean reinstall)',
246246
action: () => void this.manager.setup({ recreate: true }),
247247
},
248+
{
249+
label: '$(trash) Remove Environment…',
250+
description: 'Delete what PyneIDE downloaded and free the disk space',
251+
action: () => void vscode.commands.executeCommand('pyneide.removeEnvironment'),
252+
},
248253
{
249254
label: '$(new-folder) Initialize Pyne Project',
250255
action: () => void vscode.commands.executeCommand('pyneide.createWorkspace'),
251256
},
257+
{
258+
label: '$(folder-opened) Reveal Environment Folder',
259+
description: 'Where PyneIDE keeps Python, PyneCore and the Pine language server',
260+
action: () => void vscode.commands.executeCommand('pyneide.revealEnvironmentFolder'),
261+
},
252262
{
253263
label: '$(output) Show Environment Log',
254264
action: () => void vscode.commands.executeCommand('pyneide.showEnvironmentLog'),

0 commit comments

Comments
 (0)