vscode: Remove Project command, project deletion detection

This commit is contained in:
Steven Hugg committed 2026-09-29 12:50:31 +02:00
1 parent cdc6687e50
commit 28aaf16b36
7 files changed
+178 -5

No files matched your search

+2
View File
@@ -80,6 +80,8 @@ Click the status bar, then choose one of these:
- **Change Platform...** to pick another platform.
- **Detect Again** to look at the clues again.
- **Remove Project...** to clear its settings, so it isn't a project at
all. See [Remove a project](projects.md#remove-a-project).
## From the command line
+19
View File
@@ -74,6 +74,25 @@ folder already has a project, you choose one of these:
Or click the status bar and choose **Change Main File...**.
## Remove a project
Click the status bar and choose **Remove Project...**. It clears the
project's settings, so the files stop being built, and detection can
offer the project again the next time you open the folder.
- A top-level project clears the folder's `platform`, `mainFile`, and
`tool` settings. Other projects in `8bitworkshop.folders` stay.
- A project in a subfolder removes just its `8bitworkshop.folders`
entry.
- A project that comes from a README badge isn't in settings, so the
command offers to open the README instead.
Editing `.vscode/settings.json` by hand does the same thing: when the
last of a project's settings goes away, its errors in **Problems**
clear, its language highlighting ends, and a running program for it
stops. There's no **Undo**; set a main file or accept a detection prompt
to make the project again.
## Keep a list of programs to run
To switch between programs with <kbd>F5</kbd>:
+9
View File
@@ -141,6 +141,11 @@
"title": "Detect Projects",
"category": "8bitworkshop"
},
{
"command": "8bitworkshop.removeProject",
"title": "Remove Project...",
"category": "8bitworkshop"
},
{
"command": "8bitworkshop.addLaunchConfiguration",
"title": "Add Launch Configuration",
@@ -424,6 +429,10 @@
"command": "8bitworkshop.copyToWorkspace",
"when": "resourceScheme == 8bws-preset"
},
{
"command": "8bitworkshop.removeProject",
"when": "8bitworkshop.canRun"
},
{
"command": "8bitworkshop.pause",
"when": "8bitworkshop.emuRunning"
+52 -1
View File
@@ -94,6 +94,7 @@ export function activate(ctx: vscode.ExtensionContext) {
command('setMainFile', (uri?: vscode.Uri) => setMainFile(uri));
command('changeMainFile', () => changeMainFile());
command('selectPlatform', () => changePlatform());
command('removeProject', () => removeProject());
command('newProject', (platformId?: string) => newProject(platformId));
command('openExample', () => openExample());
command('copyToWorkspace', (uri?: vscode.Uri) => copyToWorkspace(uri));
@@ -135,7 +136,7 @@ export function activate(ctx: vscode.ExtensionContext) {
vscode.workspace.onDidChangeTextDocument(e => onChange(e)),
vscode.window.onDidChangeActiveTextEditor(() => { updateStatus(); detectForActiveFile(); }),
vscode.workspace.onDidOpenTextDocument(doc => assignLanguage(doc)),
scope.onDidChange(() => { updateStatus(); vscode.workspace.textDocuments.forEach(assignLanguage); }),
scope.onDidChange(() => onProjectsChanged()),
vscode.workspace.onDidChangeWorkspaceFolders(() => detectProjects(false)),
{ dispose: () => { builds?.dispose(); emu?.dispose(); } },
);
@@ -370,6 +371,26 @@ function lastTool(t: Target): string | undefined {
return scope.lastBuildOf(t.main.fsPath)?.tool;
}
/** Settings changed, in the editor or by us: re-read, and drop what's stale. */
function onProjectsChanged() {
// a project removed through settings.json shouldn't leave errors behind
releaseHeldDiagnostics(false);
clearOrphanDiagnostics();
// a project that no longer exists can't keep running
if (running?.project && !scope.projectFor(running.main)) panel?.dispose();
updateStatus();
vscode.workspace.textDocuments.forEach(assignLanguage);
}
/** Delete diagnostics for files no project owns any more. */
function clearOrphanDiagnostics() {
var orphans: vscode.Uri[] = [];
diagnostics.forEach(uri => {
if (uri.scheme === 'file' && !scope.projectFor(uri)) orphans.push(uri);
});
for (var uri of orphans) diagnostics.delete(uri);
}
async function projectMenu() {
var target = currentTarget();
var project = target?.project;
@@ -400,6 +421,7 @@ async function projectMenu() {
if (project.mainFile && target.main.fsPath !== project.mainFile)
items.push({ label: '$(debug-alt) Add Launch Configuration', description: `for ${describeTarget(target)}`, run: () => addLaunchConfiguration() });
items.push({ label: '$(search) Detect Again', run: () => detectAgain(project) });
items.push({ label: '$(trash) Remove Project...', run: () => removeProject() });
}
var picked = await vscode.window.showQuickPick(items, { title: '8bitworkshop' });
await picked?.run?.();
@@ -889,6 +911,35 @@ async function detectAgain(project: Project) {
if (choice) await scope.updateProject(project, { platform: choice.platform, tool: choice.tool || null });
}
/**
* Remove a project's settings, so it stops building and detection can offer
* it again. A project that comes from a README badge lives in the README.
*/
async function removeProject() {
var uri = activeUri();
var project = currentTarget()?.project || scope.projectFor(uri);
if (!project) {
vscode.window.showInformationMessage("8bitworkshop: this file isn't part of a project.");
return;
}
if (project.origin === 'readme') {
var readme = vscode.Uri.joinPath(vscode.Uri.file(project.folder || project.scope), 'README.md');
var open = await vscode.window.showInformationMessage(
'This project comes from the 8bitworkshop link in README.md, not from settings.', 'Open README.md');
if (open === 'Open README.md') vscode.window.showTextDocument(readme).then(undefined, e => output.appendLine(`readme: ${e}`));
return;
}
var where = project.origin === 'settings' ? 'this folder'
: project.folder ? path.relative(project.folder, project.scope) : project.scope;
var answer = await vscode.window.showWarningMessage(
`Remove the ${platformName(templates, project.platform)} project for ${where}?`,
{ modal: true, detail: 'Build and Run stop treating these files as 8bitworkshop. Detection may offer the project again when the folder reopens.' },
'Remove Project');
if (answer !== 'Remove Project') return;
if (await scope.removeProject(project)) vscode.window.showInformationMessage('8bitworkshop: project removed.');
else vscode.window.showWarningMessage("8bitworkshop: couldn't remove this project from settings.");
}
////// templates
async function newProject(platformId?: string) {
+17
View File
@@ -141,6 +141,23 @@ export function listProjects(ctx: ProjectContext): Project[] {
return out;
}
/**
* A folder's settings with project `p` removed: the settings and folders to
* persist. Undefined when `p` isn't stored in this folder -- it comes from a
* README badge, a window with no folder, or another folder. A top-level
* project clears the folder's settings; a subproject drops its `folders` entry.
*/
export function withoutProject(folder: FolderInfo, p: Project): { settings: ProjectSettings, folders: { [dir: string]: ProjectSettings } } | undefined {
if (p.folder !== folder.path) return undefined;
if (p.origin === 'settings') return { settings: {}, folders: folder.folders || {} };
if (p.origin === 'folders' && p.folderKey) {
var folders = { ...(folder.folders || {}) };
delete folders[p.folderKey];
return { settings: folder.settings, folders };
}
return undefined;
}
/**
* The project that owns `file`, or undefined if it isn't ours. In order: the
* most specific 8bitworkshop.folders entry, the folder's settings, its README
+47 -3
View File
@@ -5,7 +5,7 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { parseReadmeBadge } from '../../src/common/detect';
import { FolderInfo, Project, ProjectContext, ProjectSettings, RunTargetChoice, isInside, listProjects, projectFor, resolveRunTarget } from './projectinfo';
import { FolderInfo, Project, ProjectContext, ProjectSettings, RunTargetChoice, isInside, listProjects, projectFor, resolveRunTarget, withoutProject } from './projectinfo';
export const CONFIG = '8bitworkshop';
const RECENT_BUILDS = 6;
@@ -70,8 +70,8 @@ export class ProjectScope implements vscode.Disposable {
this.emitter.fire();
}
context(): ProjectContext {
var folders: FolderInfo[] = (vscode.workspace.workspaceFolders || [])
private folderInfos(): FolderInfo[] {
return (vscode.workspace.workspaceFolders || [])
.filter(f => f.uri.scheme === 'file')
.map(f => {
var cfg = vscode.workspace.getConfiguration(CONFIG, f.uri);
@@ -86,6 +86,15 @@ export class ProjectScope implements vscode.Disposable {
readme: this.readmes.get(f.uri.fsPath),
};
});
}
context(): ProjectContext {
var folders = this.folderInfos();
// a build record outlives a project edited out of settings.json; drop it,
// so removing the settings really removes the project
var known = new Set(listProjects({ folders }).map(p => p.scope));
for (var w of this.windowProjects) known.add(w.scope);
this.builds = this.builds.filter(b => known.has(b.project.scope));
return { folders, window: this.windowProjects, builds: this.builds };
}
@@ -187,6 +196,41 @@ export class ProjectScope implements vscode.Disposable {
return true;
}
/**
* Forget a project: clear its settings, so detection can offer it again.
* A project from a README badge or with no folder can't be cleared here.
* Returns true if the project is gone.
*/
async removeProject(p: Project): Promise<boolean> {
if (p.origin === 'window') {
this.windowProjects = this.windowProjects.filter(w => !(w.origin === 'window' && w.scope === p.scope));
this.memoryTargets.delete(p.scope);
this.emitter.fire();
return true;
}
var folder = this.folderInfos().find(f => f.path === p.folder);
if (!folder) return false;
var next = withoutProject(folder, p);
if (!next) return false;
var wsFolder = (vscode.workspace.workspaceFolders || []).find(f => f.uri.fsPath === p.folder);
if (!wsFolder) return false;
var cfg = vscode.workspace.getConfiguration(CONFIG, wsFolder.uri);
var target = (vscode.workspace.workspaceFolders || []).length > 1 ? vscode.ConfigurationTarget.WorkspaceFolder : vscode.ConfigurationTarget.Workspace;
await cfg.update('platform', next.settings.platform || undefined, target);
await cfg.update('mainFile', next.settings.mainFile || undefined, target);
await cfg.update('tool', next.settings.tool || undefined, target);
await cfg.update('folders', Object.keys(next.folders).length ? next.folders : undefined, target);
await this.forgetTarget(p);
this.emitter.fire();
return true;
}
/** Drop the run target stored for a project that no longer exists. */
private async forgetTarget(p: Project) {
if (p.origin === 'window' || !p.folder) this.memoryTargets.delete(p.scope);
else await this.state.update(this.targetKey(p), undefined);
}
/** Change one setting of an existing project, where it came from. */
async updateProject(p: Project, change: { platform?: string, mainFile?: string | null, tool?: string | null }) {
if (p.origin === 'window') {
+32 -1
View File
@@ -1,6 +1,6 @@
import * as assert from 'assert';
import * as path from 'path';
import { FolderInfo, Project, asmLanguageFor, isOwnExtension, projectFor, resolveRunTarget } from '../src/projectinfo';
import { FolderInfo, Project, asmLanguageFor, isOwnExtension, projectFor, resolveRunTarget, withoutProject } from '../src/projectinfo';
import { BuildReason, BuildScheduler, Clock } from '../src/autobuild';
const WS = path.resolve('/work/game');
@@ -83,6 +83,37 @@ describe('projectFor', function () {
});
});
describe('withoutProject', function () {
var folder: FolderInfo = {
path: WS,
settings: { platform: 'nes', mainFile: 'src/main.c', tool: 'cc65' },
folders: {
'tests': { platform: 'nes' },
'tests/c64': { platform: 'c64', mainFile: 'demo.c', tool: 'cc65' },
},
};
var project = (over: Partial<Project>): Project => ({ scope: WS, root: WS, platform: 'nes', origin: 'settings', folder: WS, ...over });
it('clears a top-level project but keeps subproject entries', function () {
var next = withoutProject(folder, project({ origin: 'settings' }));
assert.deepEqual(next.settings, {});
assert.deepEqual(next.folders, folder.folders);
});
it('drops only a subproject entry, keeping the rest and the settings', function () {
var next = withoutProject(folder, project({ scope: at('tests/c64'), origin: 'folders', folderKey: 'tests/c64' }));
assert.deepEqual(next.settings, folder.settings);
assert.deepEqual(Object.keys(next.folders), ['tests']);
assert.deepEqual(next.folders.tests, { platform: 'nes' });
});
it('leaves a project it does not store (README, window, another folder)', function () {
assert.equal(withoutProject(folder, project({ origin: 'readme' })), undefined);
assert.equal(withoutProject(folder, project({ origin: 'window', folder: undefined })), undefined);
assert.equal(withoutProject(folder, project({ origin: 'settings', folder: '/elsewhere' })), undefined);
});
});
describe('resolveRunTarget', function () {
var project: Project = { scope: WS, root: WS, platform: 'nes', mainFile: at('game.c'), origin: 'settings' };
var noDeps = () => false;