From 28aaf16b368fecbe6f52a477c6b4477338c51fe2 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Tue, 29 Sep 2026 12:50:31 +0200 Subject: [PATCH] vscode: Remove Project command, project deletion detection --- extension/docs/detection.md | 2 ++ extension/docs/projects.md | 19 +++++++++++ extension/package.json | 9 +++++ extension/src/extension.ts | 53 +++++++++++++++++++++++++++++- extension/src/projectinfo.ts | 17 ++++++++++ extension/src/projectscope.ts | 50 ++++++++++++++++++++++++++-- extension/test/projectinfo.test.ts | 33 ++++++++++++++++++- 7 files changed, 178 insertions(+), 5 deletions(-) diff --git a/extension/docs/detection.md b/extension/docs/detection.md index f8b3e572..ccfd98fe 100644 --- a/extension/docs/detection.md +++ b/extension/docs/detection.md @@ -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 diff --git a/extension/docs/projects.md b/extension/docs/projects.md index 7908cb43..ccbbf18b 100644 --- a/extension/docs/projects.md +++ b/extension/docs/projects.md @@ -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 F5: diff --git a/extension/package.json b/extension/package.json index ea232e22..44604ed1 100644 --- a/extension/package.json +++ b/extension/package.json @@ -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" diff --git a/extension/src/extension.ts b/extension/src/extension.ts index c993690b..eb2622a8 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -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) { diff --git a/extension/src/projectinfo.ts b/extension/src/projectinfo.ts index b584b470..48881006 100644 --- a/extension/src/projectinfo.ts +++ b/extension/src/projectinfo.ts @@ -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 diff --git a/extension/src/projectscope.ts b/extension/src/projectscope.ts index 8e507b51..2cf592c8 100644 --- a/extension/src/projectscope.ts +++ b/extension/src/projectscope.ts @@ -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 { + 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') { diff --git a/extension/test/projectinfo.test.ts b/extension/test/projectinfo.test.ts index f443a5b6..8411227a 100644 --- a/extension/test/projectinfo.test.ts +++ b/extension/test/projectinfo.test.ts @@ -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 => ({ 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;