diff --git a/package.json b/package.json index 0534ec54..e98fce43 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,8 @@ "onWebviewPanel:newPicoProject", "onWebviewPanel:newPicoMicroPythonProject", "onWebviewPanel:newPicoRustProject", - "onWebviewPanel:newPicoZephyrProject" + "onWebviewPanel:newPicoZephyrProject", + "onWebviewPanel:uninstaller" ], "contributes": { "commands": [ @@ -273,6 +274,11 @@ "title": "Get Zephyr SDK path", "category": "Raspberry Pi Pico", "enablement": "false" + }, + { + "command": "raspberry-pi-pico.openUninstaller", + "title": "Open Uninstaller", + "category": "Raspberry Pi Pico" } ], "configuration": { diff --git a/src/commands/cmdIds.mts b/src/commands/cmdIds.mts index e7529792..51ee8843 100644 --- a/src/commands/cmdIds.mts +++ b/src/commands/cmdIds.mts @@ -44,3 +44,5 @@ export const SWITCH_SDK = "switchSDK"; export const UNINSTALL_PICO_SDK = "uninstallPicoSDK"; export const UPDATE_OPENOCD = "updateOpenOCD"; + +export const OPEN_UNINSTALLER = "openUninstaller"; diff --git a/src/commands/openUninstaller.mts b/src/commands/openUninstaller.mts new file mode 100644 index 00000000..1a956eeb --- /dev/null +++ b/src/commands/openUninstaller.mts @@ -0,0 +1,18 @@ +import type { Uri } from "vscode"; +import { Command } from "./command.mjs"; +import { OPEN_UNINSTALLER } from "./cmdIds.mjs"; +import { UninstallerPanel } from "../webview/uninstallerPanel.mjs"; + +export default class OpenUninstallerCommand extends Command { + private readonly _extensionUri: Uri; + + constructor(extensionUri: Uri) { + super(OPEN_UNINSTALLER); + + this._extensionUri = extensionUri; + } + + execute(): void { + UninstallerPanel.createOrShow(this._extensionUri); + } +} diff --git a/src/commands/switchSDK.mts b/src/commands/switchSDK.mts index 24624215..7a8f3658 100644 --- a/src/commands/switchSDK.mts +++ b/src/commands/switchSDK.mts @@ -5,7 +5,6 @@ import { window, workspace, commands, - type WorkspaceFolder, } from "vscode"; import type UI from "../ui.mjs"; import { updateVSCodeStaticConfigs } from "../utils/vscodeConfigUtil.mjs"; diff --git a/src/extension.mts b/src/extension.mts index 597d4ded..198415d9 100644 --- a/src/extension.mts +++ b/src/extension.mts @@ -67,10 +67,7 @@ import { downloadAndInstallOpenOCD, } from "./utils/download.mjs"; import { getSupportedToolchains } from "./utils/toolchainUtil.mjs"; -import { - NewProjectPanel, - getWebviewOptions, -} from "./webview/newProjectPanel.mjs"; +import { NewProjectPanel } from "./webview/newProjectPanel.mjs"; import GithubApiCache from "./utils/githubApiCache.mjs"; import ClearGithubApiCacheCommand from "./commands/clearGithubApiCache.mjs"; import { ContextKeys } from "./contextKeys.mjs"; @@ -124,6 +121,10 @@ import { ZEPHYR_PICO_W, } from "./models/zephyrBoards.mjs"; import { NewZephyrProjectPanel } from "./webview/newZephyrProjectPanel.mjs"; +import LastUsedDepsStore from "./utils/lastUsedDeps.mjs"; +import { getWebviewOptions } from "./webview/sharedFunctions.mjs"; +import { UninstallerPanel } from "./webview/uninstallerPanel.mjs"; +import OpenUninstallerCommand from "./commands/openUninstaller.mjs"; export async function activate(context: ExtensionContext): Promise { Logger.info(LoggerSource.extension, "Extension activation triggered"); @@ -134,6 +135,7 @@ export async function activate(context: ExtensionContext): Promise { context.extension.packageJSON as PackageJSON ); GithubApiCache.createInstance(context); + LastUsedDepsStore.instance.setup(context.globalState); const picoProjectActivityBarProvider = new PicoProjectActivityBar(); const ui = new UI(picoProjectActivityBarProvider); @@ -181,6 +183,7 @@ export async function activate(context: ExtensionContext): Promise { new UpdateOpenOCDCommand(), new SbomTargetPathDebugCommand(), new SbomTargetPathReleaseCommand(), + new OpenUninstallerCommand(context.extensionUri), ]; // register all command handlers @@ -241,6 +244,17 @@ export async function activate(context: ExtensionContext): Promise { }) ); + context.subscriptions.push( + window.registerWebviewPanelSerializer(UninstallerPanel.viewType, { + // eslint-disable-next-line @typescript-eslint/require-await + async deserializeWebviewPanel(webviewPanel: WebviewPanel): Promise { + // Reset the webview options so we use latest uri for `localResourceRoots`. + webviewPanel.webview.options = getWebviewOptions(context.extensionUri); + UninstallerPanel.revive(webviewPanel, context.extensionUri); + }, + }) + ); + context.subscriptions.push( window.registerTreeDataProvider( PicoProjectActivityBar.viewType, diff --git a/src/logger.mts b/src/logger.mts index 83796e16..f21125e1 100644 --- a/src/logger.mts +++ b/src/logger.mts @@ -46,6 +46,7 @@ export enum LoggerSource { projectRust = "projectRust", zephyrSetup = "setupZephyr", projectZephyr = "projectZephyr", + uninstallUtil = "uninstallUtil", } /** diff --git a/src/models/dependency.mts b/src/models/dependency.mts new file mode 100644 index 00000000..ad41f910 --- /dev/null +++ b/src/models/dependency.mts @@ -0,0 +1,53 @@ +export type DepId = + | "pico-sdk" + | "arm-toolchain" + | "riscv-toolchain" + | "ninja" + | "cmake" + | "embedded-python" // windows-only + | "git" // windows-only + | "openocd" + | "7zip" // windows-only + | "pico-sdk-tools" + | "picotool" + | "zephyr"; + +export interface DependencyMeta { + id: DepId; + label: string; + platforms?: NodeJS.Platform[]; // omit = all + versioned?: boolean; // default true +} + +export const ALL_DEPS: DependencyMeta[] = [ + { id: "pico-sdk", label: "Pico SDK" }, + { id: "arm-toolchain", label: "Arm GNU Toolchain" }, + { id: "riscv-toolchain", label: "RISC-V GNU Toolchain" }, + { id: "ninja", label: "Ninja" }, + { id: "cmake", label: "CMake" }, + { id: "embedded-python", label: "Embedded Python", platforms: ["win32"] }, + { id: "git", label: "Git", platforms: ["win32"] }, + { id: "openocd", label: "OpenOCD" }, + { id: "7zip", label: "7-Zip", platforms: ["win32"], versioned: false }, + { id: "pico-sdk-tools", label: "Pico SDK Tools" }, + { id: "picotool", label: "Picotool" }, + { id: "zephyr", label: "Zephyr" }, +]; + +// version -> YYYY-MM-DD (local) +export type VersionMap = Record; +// dep -> VersionMap +export type DepVersionDb = Record; + +export const INSTALL_ROOT_ALIAS: Partial> = { + // eslint-disable-next-line @typescript-eslint/naming-convention + "pico-sdk": "sdk", + // eslint-disable-next-line @typescript-eslint/naming-convention + "pico-sdk-tools": "tools", + // eslint-disable-next-line @typescript-eslint/naming-convention + "arm-toolchain": "toolchain", + // eslint-disable-next-line @typescript-eslint/naming-convention + "riscv-toolchain": "toolchain", + zephyr: "zephyr_workspace", + // others default to their depId +}; diff --git a/src/ui.mts b/src/ui.mts index 2c154804..b06a9dc2 100644 --- a/src/ui.mts +++ b/src/ui.mts @@ -19,17 +19,20 @@ enum StatusBarItemKey { const STATUS_BAR_ITEMS: { [key: string]: { + name: string; text: string; rustText?: string; zephyrText?: string; command: string; tooltip: string; + rustTooltip?: string; zephyrTooltip?: string; rustSupport: boolean; zephyrSupport: boolean; }; } = { [StatusBarItemKey.compile]: { + name: "Raspberry Pi Pico - Compile Project", // alt. "$(gear) Compile" text: "$(file-binary) Compile", command: `${extensionName}.${COMPILE_PROJECT}`, @@ -38,6 +41,7 @@ const STATUS_BAR_ITEMS: { zephyrSupport: true, }, [StatusBarItemKey.run]: { + name: "Raspberry Pi Pico - Run Project", // alt. "$(gear) Compile" text: "$(run) Run", command: `${extensionName}.${RUN_PROJECT}`, @@ -46,6 +50,7 @@ const STATUS_BAR_ITEMS: { zephyrSupport: true, }, [StatusBarItemKey.picoSDKQuickPick]: { + name: "Raspberry Pi Pico - Select SDK Version", text: "Pico SDK: ", zephyrText: "Zephyr version: ", command: `${extensionName}.${SWITCH_SDK}`, @@ -55,10 +60,12 @@ const STATUS_BAR_ITEMS: { zephyrSupport: true, }, [StatusBarItemKey.picoBoardQuickPick]: { + name: "Raspberry Pi Pico - Select Board", text: "Board: ", rustText: "Chip: ", command: `${extensionName}.${SWITCH_BOARD}`, - tooltip: "Select Chip", + tooltip: "Select board", + rustTooltip: "Select Chip", rustSupport: true, zephyrSupport: true, }, @@ -78,6 +85,7 @@ export default class UI { Object.entries(STATUS_BAR_ITEMS).forEach(([key, value]) => { this._items[key] = this.createStatusBarItem( key, + value.name, value.text, value.command, value.tooltip @@ -102,6 +110,13 @@ export default class UI { if (STATUS_BAR_ITEMS[item.id].zephyrTooltip) { item.tooltip = STATUS_BAR_ITEMS[item.id].zephyrTooltip; } + } else if (isRustProject) { + if (STATUS_BAR_ITEMS[item.id].rustText) { + item.text = STATUS_BAR_ITEMS[item.id].rustText!; + } + if (STATUS_BAR_ITEMS[item.id].rustTooltip) { + item.tooltip = STATUS_BAR_ITEMS[item.id].rustTooltip; + } } item.show(); }); @@ -160,11 +175,13 @@ export default class UI { private createStatusBarItem( key: string, + name: string, text: string, command: string, tooltip: string ): StatusBarItem { const item = window.createStatusBarItem(key, StatusBarAlignment.Right); + item.name = name; item.text = text; item.command = command; item.tooltip = tooltip; diff --git a/src/utils/download.mts b/src/utils/download.mts index 15b36c01..f99c3e8e 100644 --- a/src/utils/download.mts +++ b/src/utils/download.mts @@ -47,6 +47,7 @@ import { WINDOWS_X86_PYTHON_DOWNLOAD_URL, } from "./sharedConstants.mjs"; import { compareGe } from "./semverUtil.mjs"; +import LastUsedDepsStore from "./lastUsedDeps.mjs"; /// Translate nodejs platform names to ninja platform names const NINJA_PLATFORMS: { [key: string]: string } = { @@ -573,8 +574,15 @@ export async function downloadAndInstallSDK( `SDK ${version} is missing submodules - installing them now.` ); - return initSubmodules(targetDirectory, gitPath); + const result = await initSubmodules(targetDirectory, gitPath); + if (result) { + await LastUsedDepsStore.instance.record("pico-sdk", version); + } + + return result; } else { + await LastUsedDepsStore.instance.record("pico-sdk", version); + return true; } } @@ -610,7 +618,12 @@ export async function downloadAndInstallSDK( return false; } - return initSubmodules(targetDirectory, gitPath); + const result = await initSubmodules(targetDirectory, gitPath); + if (result) { + await LastUsedDepsStore.instance.record("pico-sdk", version); + } + + return result; } return false; @@ -745,7 +758,6 @@ export async function downloadAndInstallGithubAsset( // eslint-disable-next-line @typescript-eslint/naming-convention "User-Agent": EXT_USER_AGENT, }, - maxRedirections: 0, // don't automatically follow redirects }; } else { const urlObj = new URL(url); @@ -757,7 +769,6 @@ export async function downloadAndInstallGithubAsset( // eslint-disable-next-line @typescript-eslint/naming-convention "User-Agent": EXT_USER_AGENT, }, - maxRedirections: 0, // don't automatically follow redirects }; } @@ -934,7 +945,7 @@ export async function downloadAndInstallTools( }-${TOOLS_PLATFORMS[process.platform]}.${assetExt}`; const releaseVersion = TOOLS_RELEASES[version] ?? "v" + version + "-0"; - return downloadAndInstallGithubAsset( + const result = await downloadAndInstallGithubAsset( version, releaseVersion, GithubRepository.tools, @@ -946,6 +957,11 @@ export async function downloadAndInstallTools( undefined, progressCallback ); + if (result) { + await LastUsedDepsStore.instance.record("pico-sdk-tools", version); + } + + return result; } export async function downloadAndInstallPicotool( @@ -964,7 +980,7 @@ export async function downloadAndInstallPicotool( }-${TOOLS_PLATFORMS[process.platform]}.${assetExt}`; const releaseVersion = PICOTOOL_RELEASES[version] ?? "v" + version + "-0"; - return downloadAndInstallGithubAsset( + const result = await downloadAndInstallGithubAsset( version, releaseVersion, GithubRepository.tools, @@ -976,6 +992,12 @@ export async function downloadAndInstallPicotool( undefined, progressCallback ); + + if (result) { + await LastUsedDepsStore.instance.record("picotool", version); + } + + return result; } export async function downloadAndInstallToolchain( @@ -999,7 +1021,7 @@ export async function downloadAndInstallToolchain( const archiveFileName = `${toolchain.version}.${artifactExt}`; - return downloadAndInstallArchive( + const result = await downloadAndInstallArchive( downloadUrl, targetDirectory, archiveFileName, @@ -1009,6 +1031,16 @@ export async function downloadAndInstallToolchain( undefined, progressCallback ); + if (result) { + await LastUsedDepsStore.instance.record( + toolchain.version.toLowerCase().includes("risc") + ? "riscv-toolchain" + : "arm-toolchain", + toolchain.version + ); + } + + return result; } /** @@ -1032,7 +1064,7 @@ export async function downloadAndInstallNinja( : "" }.zip`; - return downloadAndInstallGithubAsset( + const result = await downloadAndInstallGithubAsset( version, version, GithubRepository.ninja, @@ -1044,6 +1076,11 @@ export async function downloadAndInstallNinja( undefined, progressCallback ); + if (result) { + await LastUsedDepsStore.instance.record("ninja", version); + } + + return result; } /// Detects if the current system is a Raspberry Pi with Debian @@ -1102,7 +1139,7 @@ export async function downloadAndInstallOpenOCD( } }; - return downloadAndInstallGithubAsset( + const result = await downloadAndInstallGithubAsset( version, OPENOCD_RELEASES[version], GithubRepository.tools, @@ -1114,6 +1151,11 @@ export async function downloadAndInstallOpenOCD( undefined, progressCallback ); + if (result) { + await LastUsedDepsStore.instance.record("openocd", version); + } + + return result; } /** @@ -1155,7 +1197,7 @@ export async function downloadAndInstallCmake( //chmodSync(join(targetDirectory, "CMake.app", "Contents", "bin", "cmake"), 0o755); }; - return downloadAndInstallGithubAsset( + const result = await downloadAndInstallGithubAsset( version, version, GithubRepository.cmake, @@ -1167,6 +1209,11 @@ export async function downloadAndInstallCmake( undefined, progressCallback ); + if (result) { + await LastUsedDepsStore.instance.record("cmake", version); + } + + return result; } function _runCommand( @@ -1363,8 +1410,9 @@ export async function downloadEmbedPython( await workspace.fs.createDirectory(Uri.file(dllDir)); // Write to *._pth to allow use of installed packages - const versionAppend = - CURRENT_PYTHON_VERSION.split(".").slice(0, 2).join(""); + const versionAppend = CURRENT_PYTHON_VERSION.split(".") + .slice(0, 2) + .join(""); const pthFile = `${targetDirectory}/python${versionAppend}._pth`; let pthContents = ( await workspace.fs.readFile(Uri.file(pthFile)) @@ -1405,5 +1453,10 @@ export async function downloadEmbedPython( } } + await LastUsedDepsStore.instance.record( + "embedded-python", + CURRENT_PYTHON_VERSION + ); + return pythonExe; } diff --git a/src/utils/gitUtil.mts b/src/utils/gitUtil.mts index b7f3e7ff..bf63dbb6 100644 --- a/src/utils/gitUtil.mts +++ b/src/utils/gitUtil.mts @@ -9,6 +9,7 @@ import which from "which"; import { ProgressLocation, window } from "vscode"; import { compareGe } from "./semverUtil.mjs"; import { downloadGit } from "./downloadGit.mjs"; +import LastUsedDepsStore from "./lastUsedDeps.mjs"; export const execAsync = promisify(exec); @@ -134,6 +135,10 @@ export async function ensureGit( } } + if (gitPath?.includes(".pico-sdk")) { + await LastUsedDepsStore.instance.record("git", "latest"); + } + return returnPath ? gitPath || undefined : isGitInstalled; } diff --git a/src/utils/lastUsedDeps.mts b/src/utils/lastUsedDeps.mts new file mode 100644 index 00000000..ff235f05 --- /dev/null +++ b/src/utils/lastUsedDeps.mts @@ -0,0 +1,436 @@ +import { FileType, Uri, workspace, type Memento } from "vscode"; +import { + ALL_DEPS, + type DepVersionDb, + type VersionMap, + type DependencyMeta, + type DepId, + INSTALL_ROOT_ALIAS, +} from "../models/dependency.mjs"; +import { homedir, platform } from "os"; + +const INSTALLED_AT_STORAGE_KEY = "installedAtDeps.v1"; +const INSTALLED_STORAGE_KEY = "lastUsedDeps.v1"; +const UNINSTALLED_STORAGE_KEY = "uninstalledDeps.v1"; +const NON_VERSION_KEY = "__"; // sentinel for non-versioned deps + +/** YYYY-MM-DD in local time; day-resolution only */ +function todayLocalISO(): string { + const now = new Date(); + const yyyy = now.getFullYear(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + + return `${yyyy}-${mm}-${dd}`; +} + +function isRelevant(meta: DependencyMeta): boolean { + return !meta.platforms || meta.platforms.includes(platform()); +} + +function metaFor(dep: DepId): DependencyMeta | undefined { + return ALL_DEPS.find(d => d.id === dep); +} + +function isVersioned(dep: DepId): boolean { + const m = metaFor(dep); + + return m?.versioned !== false; // default: true +} + +const SDK_ROOT = Uri.joinPath(Uri.file(homedir()), ".pico-sdk"); + +function installRootName(dep: DepId): string { + return INSTALL_ROOT_ALIAS[dep] ?? dep; +} + +/** Resolve install folder for dep@version */ +function installUriFor(dep: DepId, version?: string): Uri | undefined { + const rootName = installRootName(dep); + if (!isVersioned(dep)) { + // ~/.pico-sdk/ + return Uri.joinPath(SDK_ROOT, rootName); + } + // "" == asked for root of versioned dep + if (version === "") { + return Uri.joinPath(SDK_ROOT, rootName); + } + if (!version) { + return undefined; + } // can't resolve path for versioned dep without version + + if (dep === "zephyr") { + return Uri.joinPath( + SDK_ROOT, + rootName, + version.startsWith("zephyr-") ? version : `zephyr-${version}` + ); + } + + // ~/.pico-sdk// + return Uri.joinPath(SDK_ROOT, rootName, version); +} + +async function pathExists(uri: Uri): Promise { + try { + await workspace.fs.stat(uri); + + return true; + } catch { + return false; + } +} + +export default class LastUsedDepsStore { + static #instance: LastUsedDepsStore; + + private globalState: Memento | undefined; + + private constructor() {} + + public static get instance(): LastUsedDepsStore { + if (!LastUsedDepsStore.#instance) { + LastUsedDepsStore.#instance = new LastUsedDepsStore(); + } + + return LastUsedDepsStore.#instance; + } + + public setup(globalState: Memento): void { + this.globalState = globalState; + } + + private async save(db: DepVersionDb): Promise { + await this.globalState?.update(INSTALLED_STORAGE_KEY, db); + } + + private load(): DepVersionDb { + return (this.globalState?.get(INSTALLED_STORAGE_KEY) ?? + {}) as DepVersionDb; + } + + private async saveUn(db: DepVersionDb): Promise { + await this.globalState?.update(UNINSTALLED_STORAGE_KEY, db); + } + private loadUn(): DepVersionDb { + return (this.globalState?.get(UNINSTALLED_STORAGE_KEY) ?? + {}) as DepVersionDb; + } + + private async saveInstalledAt(db: DepVersionDb): Promise { + await this.globalState?.update(INSTALLED_AT_STORAGE_KEY, db); + } + + private loadInstalledAt(): DepVersionDb { + return (this.globalState?.get(INSTALLED_AT_STORAGE_KEY) ?? + {}) as DepVersionDb; + } + + /** + * Record last-used = today (or supplied date) for a dep/version. + * For versioned deps, `version` is REQUIRED. + * For non-versioned deps, `version` is ignored. + */ + public async record( + dep: DepId, + version?: string, + date?: string + ): Promise { + const db = this.load(); + const installedDb = this.loadInstalledAt(); + + const map: VersionMap = db[dep] ?? {}; + const today = date ?? todayLocalISO(); + if (isVersioned(dep)) { + if (!version) { + throw new Error( + `record(): version is required for versioned dependency "${dep}"` + ); + } + map[version] = today; + + // installed_at (only if not set yet) + const instMap: VersionMap = installedDb[dep] ?? {}; + if (!instMap[version]) { + instMap[version] = today; + installedDb[dep] = instMap; + await this.saveInstalledAt(installedDb); + } + } else { + map[NON_VERSION_KEY] = today; + + // installed_at (only if not set yet) + const instMap: VersionMap = installedDb[dep] ?? {}; + if (!instMap[NON_VERSION_KEY]) { + instMap[NON_VERSION_KEY] = today; + installedDb[dep] = instMap; + await this.saveInstalledAt(installedDb); + } + } + + db[dep] = map; + await this.save(db); + } + + /** + * Get last-used date (YYYY-MM-DD) for a dep/version. + * For non-versioned deps, `version` is ignored. + * Returns "" if absent. + */ + public get(dep: DepId, version?: string): string { + const db = this.load(); + const map = db[dep] ?? {}; + + if (isVersioned(dep)) { + if (!version) { + return ""; + } + + return map[version] ?? ""; + } else { + return map[NON_VERSION_KEY] ?? ""; + } + } + + /** + * Get all entries: dep -> (version -> date). + * For non-versioned deps, a single entry under key "__". + * If `onlyRelevant`, filters by current platform. + */ + public async getAll( + onlyRelevant = false, + includeUnused = false + ): Promise { + const db = this.load(); + const out: DepVersionDb = {} as DepVersionDb; + + for (const meta of ALL_DEPS) { + if (onlyRelevant && !isRelevant(meta)) { + continue; + } + + const dep = meta.id; + const versions = db[dep] ?? {}; + const filtered: VersionMap = {}; + + // No records at all? Skip unless onlyRelevant=false (we still return empty map then) + const entries = Object.entries(versions); + if (entries.length === 0) { + if (!onlyRelevant) { + out[dep] = {}; + } + continue; + } + + if (!onlyRelevant) { + out[dep] = { ...versions }; + continue; + } + + // onlyRelevant === true → also require that the install path exists + for (const [verKey, date] of entries) { + const version = isVersioned(dep) ? verKey : undefined; + const uri = installUriFor(dep, version); + if (!uri) { + continue; + } // can't determine a path without version + // For non-versioned, verKey is "__"; version is undefined + if (await pathExists(uri)) { + filtered[verKey] = date; + } + } + if (Object.keys(filtered).length > 0) { + out[dep] = filtered; + } + // If nothing is installed, dep is omitted entirely. + } + + // unused are dependencies that installed in ~/.pico-sdk but have no last-used record + if (includeUnused) { + for (const meta of ALL_DEPS) { + if (onlyRelevant && !isRelevant(meta)) { + continue; + } + const dep = meta.id; + const rootUri = installUriFor(dep, ""); // root of dep + if (rootUri === undefined) { + continue; + } // can't determine a path without version + if (!(await pathExists(rootUri))) { + continue; + } // not installed at all + if (isVersioned(dep)) { + // look for versions under rootUri + try { + const children = await workspace.fs.readDirectory(rootUri); + const verMap: VersionMap = out[dep] ?? {}; + for (const [name, type] of children) { + if (type === FileType.Directory) { + if ( + dep === "zephyr" && + (!name.startsWith("zephyr-") || + name.startsWith("zephyr-sdk-")) + ) { + continue; + } else if ( + dep === "arm-toolchain" && + name.toLowerCase().includes("riscv") + ) { + continue; + } else if ( + dep === "riscv-toolchain" && + !name.toLowerCase().includes("riscv") + ) { + continue; + } + + const found = dep === "zephyr" ? name.slice(7) : name; + + // if vermap already has an entry, skip + if (verMap[found]) { + continue; + } + + verMap[found] = ""; // no last-used record + } + } + if (Object.keys(verMap).length > 0) { + out[dep] = verMap; + } + } catch { + // ignore + } + } else { + // non-versioned; just check rootUri + const verMap: VersionMap = out[dep] ?? {}; + verMap[NON_VERSION_KEY] = ""; // no last-used record + out[dep] = verMap; + } + } + } + + return out; + } + + /** Get installed date (YYYY-MM-DD) for dep@version; "" if unknown. */ + public getInstalledDate(dep: DepId, version?: string): string { + const installedDb = this.loadInstalledAt(); + const map = installedDb[dep] ?? {}; + if (isVersioned(dep)) { + if (!version) { + return ""; + } + + return map[version] ?? ""; + } else { + return map[NON_VERSION_KEY] ?? ""; + } + } + + public getAllInstalledDates(): DepVersionDb { + return this.loadInstalledAt(); + } + + public async recordUninstalled( + dep: DepId, + version?: string, + date?: string + ): Promise { + const un = this.loadUn(); + const map: VersionMap = un[dep] ?? {}; + const key = isVersioned(dep) ? version ?? "" : NON_VERSION_KEY; + if (isVersioned(dep) && !version) { + throw new Error(`recordUninstalled(): version required for "${dep}"`); + } + map[key] = date ?? todayLocalISO(); + un[dep] = map; + await this.saveUn(un); + } + + /** Has dep@version been recorded as uninstalled? */ + public wasUninstalled(dep: DepId, version?: string): boolean { + const un = this.loadUn(); + const map = un[dep] ?? {}; + const key = isVersioned(dep) ? version ?? "" : NON_VERSION_KEY; + + return Boolean(map[key]); + } + + /** Get all uninstall records (optionally filter by platform relevance only). */ + public getAllUninstalled(onlyRelevant = false): DepVersionDb { + const un = this.loadUn(); + if (!onlyRelevant) { + return un; + } + + const out: DepVersionDb = {} as DepVersionDb; + for (const meta of ALL_DEPS) { + if (!isRelevant(meta)) { + continue; + } + const dep = meta.id; + if (un[dep]) { + out[dep] = { ...un[dep] }; + } + } + + return out; + } + + /** + * Clear entries. + * + * @param dep If given, clear only this dep; otherwise clear all. + */ + public async clear(dep?: DepId, version?: string): Promise { + if (!dep) { + await this.globalState?.update(INSTALLED_STORAGE_KEY, undefined); + + return; + } + + const db = this.load(); + if (!db[dep]) { + return; + } + + if (version && isVersioned(dep)) { + delete db[dep][version]; + if (Object.keys(db[dep]).length === 0) { + delete db[dep]; + } + await this.save(db); + + return; + } + + // clear all for dep + delete db[dep]; + await this.save(db); + } + + /** Clear uninstall records (same semantics as clear). */ + public async clearUninstalled(dep?: DepId, version?: string): Promise { + if (!dep) { + await this.globalState?.update(UNINSTALLED_STORAGE_KEY, undefined); + + return; + } + const un = this.loadUn(); + if (!un[dep]) { + return; + } + + if (version && isVersioned(dep)) { + delete un[dep][version]; + if (Object.keys(un[dep]).length === 0) { + delete un[dep]; + } + await this.saveUn(un); + + return; + } + delete un[dep]; + await this.saveUn(un); + } +} diff --git a/src/utils/setupZephyr.mts b/src/utils/setupZephyr.mts index e08f20da..5bf6b360 100644 --- a/src/utils/setupZephyr.mts +++ b/src/utils/setupZephyr.mts @@ -38,9 +38,9 @@ import which from "which"; import { stdoutToString, unknownErrorToString } from "./errorHelper.mjs"; import { VALID_ZEPHYR_BOARDS } from "../models/zephyrBoards.mjs"; import type { ITask } from "../models/task.mjs"; -import { configureCmakeNinja } from "./cmakeUtil.mjs"; import { getWestConfigValue, updateZephyrBase } from "./westConfig.mjs"; import { addZephyrVariant } from "./westManifest.mjs"; +import LastUsedDepsStore from "./lastUsedDeps.mjs"; interface ZephyrSetupValue { cmakeMode: number; @@ -562,6 +562,7 @@ async function check7Zip(): Promise { message: "Success", increment: 100, }); + await LastUsedDepsStore.instance.record("7zip", "latest"); return true; } @@ -1245,6 +1246,7 @@ export async function setupZephyr( message: "Success", increment: 100, }); + await LastUsedDepsStore.instance.record("7zip", "latest"); return true; } else { @@ -1281,6 +1283,10 @@ export async function setupZephyr( if (endResult) { Logger.info(LoggerSource.zephyrSetup, "Zephyr setup complete."); + const zephyrVersion = await getZephyrVersion(); + if (zephyrVersion) { + await LastUsedDepsStore.instance.record("zephyr", zephyrVersion); + } return output; } else { diff --git a/src/utils/uninstallUtil.mts b/src/utils/uninstallUtil.mts new file mode 100644 index 00000000..41ff9fc4 --- /dev/null +++ b/src/utils/uninstallUtil.mts @@ -0,0 +1,144 @@ +import { Uri, window, workspace } from "vscode"; +import { + ALL_DEPS, + type DependencyMeta, + INSTALL_ROOT_ALIAS, + type DepId, +} from "../models/dependency.mjs"; +import { homedir } from "os"; +import LastUsedDepsStore from "./lastUsedDeps.mjs"; +import { unknownErrorToString } from "./errorHelper.mjs"; +import Logger, { LoggerSource } from "../logger.mjs"; +import { getZephyrSDKVersion } from "./setupZephyr.mjs"; + +const SDK_ROOT = Uri.joinPath(Uri.file(homedir()), ".pico-sdk"); + +function metaFor(dep: DepId): DependencyMeta | undefined { + return ALL_DEPS.find(d => d.id === dep); +} +function isVersioned(dep: DepId): boolean { + return metaFor(dep)?.versioned !== false; // default true +} + +function resolveInstallUri(dep: DepId, version?: string): Uri { + if (dep === "zephyr") { + if (!version) { + throw new Error("Version required for zephyr"); + } + // Accept either "zephyr-v4.2.0" or "v4.2.0"/"main" + const folder = version.startsWith("zephyr-") + ? version + : `zephyr-${version}`; + + return Uri.joinPath(SDK_ROOT, "zephyr_workspace", folder); + } + + const root = INSTALL_ROOT_ALIAS[dep] ?? dep; + + if (isVersioned(dep)) { + if (!version) { + throw new Error(`Version required for ${dep}`); + } + + return Uri.joinPath(SDK_ROOT, root, version); + } + + // non-versioned (e.g., 7zip) + return Uri.joinPath(SDK_ROOT, root); +} + +export async function uninstallOne( + depIdStr: string, + version?: string +): Promise { + const depId = depIdStr as DepId; + if (!metaFor(depId)) { + throw new Error(`Unknown dependency: ${depId}`); + } + const target = resolveInstallUri(depId, version); + + // ensure it exists + try { + await workspace.fs.stat(target); + } catch { + Logger.warn( + LoggerSource.uninstallUtil, + `Cannot uninstall ${depId} ${version ?? ""}: not found at ${ + target.fsPath + }` + ); + + return; + } + + if (depId === "zephyr") { + const sdkVersion = await getZephyrSDKVersion( + version?.startsWith("zephyr-") ? version.slice(7) : version + ); + if (sdkVersion === undefined) { + Logger.warn( + LoggerSource.uninstallUtil, + `Cannot uninstall zephyr ${version}: matching ` + + "zephyr SDK version not found" + ); + void window.showWarningMessage( + `Cannot uninstall zephyr ${version}: matching ` + + "zephyr SDK version not found" + ); + + return; + } + + const sdkPath = Uri.joinPath(target, "..", "zephyr-sdk-" + sdkVersion); + try { + await workspace.fs.stat(sdkPath); + await workspace.fs.delete(sdkPath, { + recursive: true, + useTrash: false, + }); + } catch { + // ignore errors; maybe it doesn't exist + Logger.debug( + LoggerSource.uninstallUtil, + `Zephyr SDK ${sdkVersion} not found at ${sdkPath.fsPath}, ` + + "not deleting or reporting error" + ); + } + } + + // delete folder (prefer hard delete; fall back to trash if needed) + try { + await workspace.fs.delete(target, { + recursive: true, + useTrash: false, + }); + } catch { + // VS Code supports useTrash in recent versions; try it as a fallback + try { + // useTrash is supported in VS Code >=1.92 + await workspace.fs.delete(target, { + recursive: true, + useTrash: true, + }); + } catch (e2) { + Logger.error( + LoggerSource.uninstallUtil, + `Failed to uninstall ${depId} ${version ?? ""}: ${unknownErrorToString( + e2 + )}` + ); + void window.showWarningMessage( + `Failed to uninstall ${depId} ${version ?? ""}: ${unknownErrorToString( + e2 + )}` + ); + + return; + } + } + + // update bookkeeping + const store = LastUsedDepsStore.instance; + await store.recordUninstalled(depId, version); + await store.clear(depId, version); +} diff --git a/src/webview/newMicroPythonProjectPanel.mts b/src/webview/newMicroPythonProjectPanel.mts index f4ad29d4..2f8eb6e6 100644 --- a/src/webview/newMicroPythonProjectPanel.mts +++ b/src/webview/newMicroPythonProjectPanel.mts @@ -13,17 +13,17 @@ import { } from "vscode"; import Settings from "../settings.mjs"; import Logger from "../logger.mjs"; -import type { WebviewMessage } from "./newProjectPanel.mjs"; -import { - getNonce, - getProjectFolderDialogOptions, - getWebviewOptions, -} from "./newProjectPanel.mjs"; import { existsSync } from "fs"; import { join } from "path"; import { PythonExtension } from "@vscode/python-extension"; import { unknownErrorToString } from "../utils/errorHelper.mjs"; import { getSystemPythonVersion } from "../utils/pythonHelper.mjs"; +import type { WebviewMessage } from "./sharedEnums.mjs"; +import { + getNonce, + getProjectFolderDialogOptions, + getWebviewOptions, +} from "./sharedFunctions.mjs"; interface SubmitMessageValue { projectName: string; diff --git a/src/webview/newProjectPanel.mts b/src/webview/newProjectPanel.mts index b4ea64b6..6424c048 100644 --- a/src/webview/newProjectPanel.mts +++ b/src/webview/newProjectPanel.mts @@ -3,8 +3,6 @@ import { Uri, window, workspace, - type WebviewOptions, - type OpenDialogOptions, type WebviewPanel, type Disposable, ViewColumn, @@ -63,69 +61,23 @@ import { OPENOCD_VERSION, SDK_REPOSITORY_URL, } from "../utils/sharedConstants.mjs"; -import { BoardType } from "./sharedEnums.mjs"; +import { + BoardType, + type ImportProjectMessageValue, + type SubmitExampleMessageValue, + type SubmitMessageValue, + type WebviewMessage, +} from "./sharedEnums.mjs"; import { getSystemNinjaVersion } from "../utils/ninjaUtil.mjs"; import { getSystemCmakeVersion } from "../utils/cmakeUtil.mjs"; +import { + getNonce, + getProjectFolderDialogOptions, + getWebviewOptions, +} from "./sharedFunctions.mjs"; export const NINJA_AUTO_INSTALL_DISABLED = false; -interface ImportProjectMessageValue { - selectedSDK: string; - selectedToolchain: string; - selectedPicotool: string; - ninjaMode: number; - ninjaPath: string; - ninjaVersion: string; - cmakeMode: number; - cmakePath: string; - cmakeVersion: string; - - // debugger - debugger: number; - useCmakeTools: boolean; -} - -interface SubmitExampleMessageValue extends ImportProjectMessageValue { - example: string; - boardType: string; -} - -interface SubmitMessageValue extends ImportProjectMessageValue { - projectName: string; - boardType: string; - - // features (libraries) - spiFeature: boolean; - pioFeature: boolean; - i2cFeature: boolean; - dmaFeature: boolean; - hwwatchdogFeature: boolean; - hwclocksFeature: boolean; - hwinterpolationFeature: boolean; - hwtimerFeature: boolean; - - // stdio support - uartStdioSupport: boolean; - usbStdioSupport: boolean; - - // pico wireless options - picoWireless: number; - - // code generation options - addUartExample: boolean; - runFromRAM: boolean; - entryPointProjectName: boolean; - cpp: boolean; - cppRtti: boolean; - cppExceptions: boolean; -} - -export interface WebviewMessage { - command: string; - value: object | string | SubmitMessageValue | boolean; - key?: string; -} - enum ConsoleOption { consoleOverUART = "Console over UART", consoleOverUSB = "Console over USB (disables other USB use)", @@ -284,29 +236,6 @@ interface NewProjectOptions extends ImportProjectOptions { codeOptions: CodeOption[]; } -export function getWebviewOptions(extensionUri: Uri): WebviewOptions { - return { - enableScripts: true, - localResourceRoots: [Uri.joinPath(extensionUri, "web")], - }; -} - -export function getProjectFolderDialogOptions( - projectRoot?: Uri, - forImport: boolean = false -): OpenDialogOptions { - return { - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: "Select", - title: forImport - ? "Select a project folder to import" - : "Select a project root to create the new project folder in", - defaultUri: projectRoot, - }; -} - export class NewProjectPanel { public static currentPanel: NewProjectPanel | undefined; @@ -2503,14 +2432,3 @@ export class NewProjectPanel { } } } - -export function getNonce(): string { - let text = ""; - const possible = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - - return text; -} diff --git a/src/webview/newRustProjectPanel.mts b/src/webview/newRustProjectPanel.mts index b824ab7c..617fa7c8 100644 --- a/src/webview/newRustProjectPanel.mts +++ b/src/webview/newRustProjectPanel.mts @@ -13,12 +13,6 @@ import { } from "vscode"; import Settings from "../settings.mjs"; import Logger from "../logger.mjs"; -import type { WebviewMessage } from "./newProjectPanel.mjs"; -import { - getNonce, - getProjectFolderDialogOptions, - getWebviewOptions, -} from "./newProjectPanel.mjs"; import { existsSync } from "fs"; import { join } from "path"; import { unknownErrorToString } from "../utils/errorHelper.mjs"; @@ -27,6 +21,12 @@ import { installLatestRustRequirements, } from "../utils/rustUtil.mjs"; import { generateRustProject } from "../utils/projectGeneration/projectRust.mjs"; +import { + getNonce, + getProjectFolderDialogOptions, + getWebviewOptions, +} from "./sharedFunctions.mjs"; +import type { WebviewMessage } from "./sharedEnums.mjs"; interface SubmitMessageValue { projectName: string; diff --git a/src/webview/newZephyrProjectPanel.mts b/src/webview/newZephyrProjectPanel.mts index caa23884..f273b901 100644 --- a/src/webview/newZephyrProjectPanel.mts +++ b/src/webview/newZephyrProjectPanel.mts @@ -14,12 +14,6 @@ import { import Settings from "../settings.mjs"; import Logger from "../logger.mjs"; import { compare } from "../utils/semverUtil.mjs"; -import type { WebviewMessage } from "./newProjectPanel.mjs"; -import { - getNonce, - getProjectFolderDialogOptions, - getWebviewOptions, -} from "./newProjectPanel.mjs"; import { existsSync } from "fs"; import { join } from "path"; import { PythonExtension } from "@vscode/python-extension"; @@ -28,9 +22,18 @@ import { setupZephyr } from "../utils/setupZephyr.mjs"; import { getCmakeReleases, getNinjaReleases } from "../utils/githubREST.mjs"; import { getSystemCmakeVersion } from "../utils/cmakeUtil.mjs"; import { generateZephyrProject } from "../utils/projectGeneration/projectZephyr.mjs"; -import { BoardType, type ZephyrSubmitMessageValue } from "./sharedEnums.mjs"; +import { + BoardType, + type WebviewMessage, + type ZephyrSubmitMessageValue, +} from "./sharedEnums.mjs"; import { getSystemNinjaVersion } from "../utils/ninjaUtil.mjs"; import { checkGitWithProgress } from "../utils/gitUtil.mjs"; +import { + getNonce, + getProjectFolderDialogOptions, + getWebviewOptions, +} from "./sharedFunctions.mjs"; export class NewZephyrProjectPanel { public static currentPanel: NewZephyrProjectPanel | undefined; diff --git a/src/webview/sharedEnums.mts b/src/webview/sharedEnums.mts index beedcb80..a050836b 100644 --- a/src/webview/sharedEnums.mts +++ b/src/webview/sharedEnums.mts @@ -35,3 +35,70 @@ export interface ZephyrSubmitMessageValue { ninjaPath: string; ninjaVersion: string; } + +export interface ImportProjectMessageValue { + selectedSDK: string; + selectedToolchain: string; + selectedPicotool: string; + ninjaMode: number; + ninjaPath: string; + ninjaVersion: string; + cmakeMode: number; + cmakePath: string; + cmakeVersion: string; + + // debugger + debugger: number; + useCmakeTools: boolean; +} + +export interface SubmitExampleMessageValue extends ImportProjectMessageValue { + example: string; + boardType: string; +} + +export interface SubmitMessageValue extends ImportProjectMessageValue { + projectName: string; + boardType: string; + + // features (libraries) + spiFeature: boolean; + pioFeature: boolean; + i2cFeature: boolean; + dmaFeature: boolean; + hwwatchdogFeature: boolean; + hwclocksFeature: boolean; + hwinterpolationFeature: boolean; + hwtimerFeature: boolean; + + // stdio support + uartStdioSupport: boolean; + usbStdioSupport: boolean; + + // pico wireless options + picoWireless: number; + + // code generation options + addUartExample: boolean; + runFromRAM: boolean; + entryPointProjectName: boolean; + cpp: boolean; + cppRtti: boolean; + cppExceptions: boolean; +} + +export interface WebviewMessage { + command: string; + value: object | string | SubmitMessageValue | boolean; + key?: string; +} + +export type DependencyItem = { + id: string; + depId: string; + label: string; + version: string; + installedAt: string; + lastUsed: string; + path?: string; +}; diff --git a/src/webview/sharedFunctions.mts b/src/webview/sharedFunctions.mts new file mode 100644 index 00000000..3dd81d33 --- /dev/null +++ b/src/webview/sharedFunctions.mts @@ -0,0 +1,35 @@ +import { type OpenDialogOptions, Uri, type WebviewOptions } from "vscode"; + +export function getWebviewOptions(extensionUri: Uri): WebviewOptions { + return { + enableScripts: true, + localResourceRoots: [Uri.joinPath(extensionUri, "web")], + }; +} + +export function getProjectFolderDialogOptions( + projectRoot?: Uri, + forImport: boolean = false +): OpenDialogOptions { + return { + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: "Select", + title: forImport + ? "Select a project folder to import" + : "Select a project root to create the new project folder in", + defaultUri: projectRoot, + }; +} + +export function getNonce(): string { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return text; +} diff --git a/src/webview/uninstallerPanel.mts b/src/webview/uninstallerPanel.mts new file mode 100644 index 00000000..1cdaad57 --- /dev/null +++ b/src/webview/uninstallerPanel.mts @@ -0,0 +1,367 @@ +/* eslint-disable max-len */ +import { + ColorThemeKind, + commands, + Uri, + ViewColumn, + type Webview, + type WebviewPanel, + window, + workspace, + type Disposable, +} from "vscode"; +import Settings from "../settings.mjs"; +import Logger from "../logger.mjs"; +import { getNonce, getWebviewOptions } from "./sharedFunctions.mjs"; +import { unknownErrorToString } from "../utils/errorHelper.mjs"; +import type { DependencyItem } from "./sharedEnums.mjs"; +import LastUsedDepsStore from "../utils/lastUsedDeps.mjs"; +import { ALL_DEPS, type DepVersionDb } from "../models/dependency.mjs"; +import { uninstallOne } from "../utils/uninstallUtil.mjs"; + +export class UninstallerPanel { + public static currentPanel: UninstallerPanel | undefined; + + public static readonly viewType = "uninstaller"; + + private readonly _panel: WebviewPanel; + private readonly _extensionUri: Uri; + private readonly _settings: Settings; + private readonly _logger: Logger = new Logger("UninstallerPanel"); + private _disposables: Disposable[] = []; + + private readonly _lastUsed: LastUsedDepsStore; + + public static createOrShow(extensionUri: Uri): void { + const column = window.activeTextEditor + ? window.activeTextEditor.viewColumn + : undefined; + + if (UninstallerPanel.currentPanel) { + UninstallerPanel.currentPanel._panel.reveal(column); + + return; + } + + const panel = window.createWebviewPanel( + UninstallerPanel.viewType, + "Pico SDK Uninstaller", + column || ViewColumn.One, + getWebviewOptions(extensionUri) + ); + + const settings = Settings.getInstance(); + if (!settings) { + panel.dispose(); + + void window + .showErrorMessage( + "Failed to load settings. Please restart VS Code " + + "or reload the window.", + "Reload Window" + ) + .then(selected => { + if (selected === "Reload Window") { + void commands.executeCommand("workbench.action.reloadWindow"); + } + }); + + return; + } + + UninstallerPanel.currentPanel = new UninstallerPanel( + panel, + settings, + extensionUri + ); + } + + public static revive(panel: WebviewPanel, extensionUri: Uri): void { + const settings = Settings.getInstance(); + if (settings === undefined) { + // TODO: maybe add restart button + void window.showErrorMessage( + "Failed to load settings. Please restart VSCode." + ); + + return; + } + + UninstallerPanel.currentPanel = new UninstallerPanel( + panel, + settings, + extensionUri + ); + } + + private constructor( + panel: WebviewPanel, + settings: Settings, + extensionUri: Uri + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._settings = settings; + this._lastUsed = LastUsedDepsStore.instance; + + void this._update(); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + // Update the content based on view changes + this._panel.onDidChangeViewState( + async () => { + if (this._panel.visible) { + await this._update(); + } + }, + null, + this._disposables + ); + + workspace.onDidChangeConfiguration( + async () => { + await this._updateTheme(); + }, + null, + this._disposables + ); + + this._panel.webview.onDidReceiveMessage( + async (message: { type: string; ids: string[] }) => { + switch (message.type) { + case "ready": { + const installedMap = await this.getInstalledItems(); + this._panel.webview.postMessage({ + type: "init", + items: installedMap, + }); + break; + } + + case "uninstall": { + const ids: string[] = Array.isArray(message.ids) ? message.ids : []; + const removed: string[] = []; + try { + for (let i = 0; i < ids.length; i++) { + const id = ids[i]; + const [depId, versionKey] = id.split("@@"); + const version = versionKey === "__" ? undefined : versionKey; + + this._panel.webview.postMessage({ + type: "uninstall:progress", + text: `Uninstalling ${depId} ${version ?? ""} (${i + 1}/${ + ids.length + })…`, + }); + + await uninstallOne(depId, version); + removed.push(id); + } + await this._panel.webview.postMessage({ + type: "uninstall:done", + ids: removed, + }); + } catch (err) { + await this._panel.webview.postMessage({ + type: "uninstall:error", + error: unknownErrorToString(err), + }); + } + break; + } + } + } + ); + } + + private async _update(): Promise { + this._panel.title = "Pico SDK Uninstaller"; + + this._panel.iconPath = Uri.joinPath( + this._extensionUri, + "web", + "raspberry-128.png" + ); + const html = this._getHtmlForWebview(this._panel.webview); + + if (html !== "") { + try { + this._panel.webview.html = html; + } catch (error) { + this._logger.error( + "Failed to set webview html. Webview might " + + "have been disposed. Error: ", + unknownErrorToString(error) + ); + // properly dispose panel + this.dispose(); + + return; + } + await this._updateTheme(); + } else { + void window.showErrorMessage( + "Failed to load webview for the Uninstaller." + ); + this.dispose(); + } + } + + private async _updateTheme(): Promise { + try { + await this._panel.webview.postMessage({ + command: "setTheme", + theme: + window.activeColorTheme.kind === ColorThemeKind.Dark || + window.activeColorTheme.kind === ColorThemeKind.HighContrast + ? "dark" + : "light", + }); + } catch (error) { + this._logger.error( + "Failed to update theme in webview. Webview " + + "might have been disposed. Error:", + unknownErrorToString(error) + ); + // properly dispose panel + this.dispose(); + } + } + + public dispose(): void { + UninstallerPanel.currentPanel = undefined; + + this._panel.dispose(); + + while (this._disposables.length) { + const x = this._disposables.pop(); + + if (x) { + x.dispose(); + } + } + } + + private async getInstalledItems(): Promise { + const installedItems = await this._lastUsed.getAll(true, true); + const installedAtItems = this._lastUsed.getAllInstalledDates(); + + return flattenInstalled(installedItems, installedAtItems); + } + + private _getHtmlForWebview(webview: Webview): string { + const mainScriptUri = webview.asWebviewUri( + Uri.joinPath(this._extensionUri, "web", "uninstaller", "main.js") + ); + + const mainStyleUri = webview.asWebviewUri( + Uri.joinPath(this._extensionUri, "web", "uninstaller", "main.css") + ); + + const tailwindcssScriptUri = webview.asWebviewUri( + Uri.joinPath(this._extensionUri, "web", "tailwindcss-3_3_5.js") + ); + + // Restrict the webview to only load specific scripts + const nonce = getNonce(); + + const defaultTheme = + window.activeColorTheme.kind === ColorThemeKind.Dark || + window.activeColorTheme.kind === ColorThemeKind.HighContrast + ? "dark" + : "light"; + + return ` + + + + + + + + + + Pico SDK Uninstaller + + + + + + + + + `; + } +} + +function generatePath(depId: string, version: string): string | undefined { + // TODO: use INSTALL_ROOT_ALIAS + switch (depId) { + case "pico-sdk": + case "ninja": + case "cmake": + case "openocd": + case "git": + case "picotool": + return `~/.pico-sdk/${depId}/${version}`; + case "arm-toolchain": + case "riscv-toolchain": + return `~/.pico-sdk/toolchain/${version}`; + case "embedded-python": + return `~/.pico-sdk/python/${version}`; + case "7zip": + return `~/.pico-sdk/7zip`; + case "pico-sdk-tools": + return `~/.pico-sdk/tools/${version}`; + case "zephyr": + return `~/.pico-sdk/zephyr_workspace/zephyr-${version}`; + default: + return undefined; + } +} + +function flattenInstalled( + installedMap: DepVersionDb, + installedAtMap: DepVersionDb +): DependencyItem[] { + // installedMap: { depId: { versionKey: "YYYY-MM-DD" } } + // Provide labels from metadata + const labelFor = (id: string): string => + ALL_DEPS.find(d => d.id === id)?.label ?? id; + const items: DependencyItem[] = []; + for (const [depId, versions] of Object.entries(installedMap)) { + for (const [verKey, lastUsed] of Object.entries(versions)) { + const version = verKey === "__" ? "latest" : verKey; + // TODO: maybe improve + const installedAt = Object.entries(installedAtMap).find( + ([d, v]) => d === depId && v[verKey] !== undefined + ); + + items.push({ + id: `${depId}@@${version || "__"}`, + depId, + label: labelFor(depId), + version, + lastUsed, + path: generatePath(`${depId}`, version), + installedAt: installedAt ? installedAt[1][verKey] : "1970-01-01", + }); + } + } + + return items; +} diff --git a/web/uninstaller/main.css b/web/uninstaller/main.css new file mode 100644 index 00000000..3e99d8ef --- /dev/null +++ b/web/uninstaller/main.css @@ -0,0 +1,20 @@ +/* Minimal extras; Tailwind handles most styling */ + +/* Hide focus outlines only when using mouse; keep for keyboard */ +:focus:not(:focus-visible) { + outline: 0; +} + +/* Smooth font rendering for dark backgrounds */ +body { + -webkit-font-smoothing: antialiased; +} + +/* A tiny reset which helps on some platforms */ +html, +body { + height: 100%; + margin: 0; +} + +/* Fullscreen overlay tweaks handled via Tailwind classes; nothing else needed */ \ No newline at end of file diff --git a/web/uninstaller/main.js b/web/uninstaller/main.js new file mode 100644 index 00000000..189b0522 --- /dev/null +++ b/web/uninstaller/main.js @@ -0,0 +1,546 @@ +/* global acquireVsCodeApi */ +"use strict"; + +const OVERLAY_MIN_MS = 3000; // keep overlay visible at least this long +let overlayShownAt = 0; +let overlayTimerId = null + +const vscode = acquireVsCodeApi(); + +/** @typedef {{ id:string, depId:string, label:string, version:string, installedAt: string, lastUsed:string, path?:string }} Item */ + +let state = { + items /** @type {Item[]} */: [], + sortKey: "lastUsed", // "lastUsed" | "installedAt" | "name" + sortDir: "desc", // "asc" | "desc" + filter: "", + selected: new Set(), // of item.id + uninstalling: false, + overlayVisible: false, + pendingRender: false, +}; + +const $ = (sel, root = document) => /** @type {HTMLElement|null} */(root.querySelector(sel)); +const $$ = (sel, root = document) => /** @type {HTMLElement[]} */(Array.from(root.querySelectorAll(sel))); + +function dayValue(ymd /* YYYY-MM-DD or "" */) { + // Treat empty (never used) as very old + if (!ymd) return -1; + // Safe compare by number: YYYYMMDD + return Number(ymd.replaceAll("-", "")); +} + +function cmp(a, b) { + const dir = state.sortDir === "asc" ? 1 : -1; + + if (state.sortKey === "lastUsed") { + const av = dayValue(a.lastUsed), bv = dayValue(b.lastUsed); + if (av !== bv) return (av < bv ? -1 : 1) * dir; + // tie-break by name + return a.label.localeCompare(b.label) * dir; + } + + if (state.sortKey === "installedAt") { + const av = dayValue(a.installedAt), bv = dayValue(b.installedAt); + if (av !== bv) return (av < bv ? -1 : 1) * dir; + return a.label.localeCompare(b.label) * dir; + } + + // name + const an = `${a.label} ${a.version}`.toLowerCase(); + const bn = `${b.label} ${b.version}`.toLowerCase(); + if (an !== bn) return (an < bn ? -1 : 1) * dir; + return dayValue(a.lastUsed) - dayValue(b.lastUsed); +} + +function filteredSorted(items) { + const f = state.filter.trim().toLowerCase(); + let list = !f ? items : items.filter(i => + i.label.toLowerCase().includes(f) || + i.depId.toLowerCase().includes(f) || + i.version.toLowerCase().includes(f) + ); + return list.sort(cmp); +} + +// --- date helpers --- +function parseYMD(ymd) { + if (!ymd) return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(ymd); + if (!m) return null; + const [_, y, mo, d] = m.map(Number); + return new Date(y, mo - 1, d); +} +function daysSince(ymd) { + const dt = parseYMD(ymd); + if (!dt) return Infinity; // treat unknown/never as very old + const ms = Date.now() - dt.getTime(); + return Math.floor(ms / (1000 * 60 * 60 * 24)); +} + +// --- version parsing/compare --- +// supports: 'main' (highest), 'v1.2.3', '1.2.3', '1.2.3-rc1' +// falls back to string compare if not semver-ish +function parseVersion(v) { + if (!v) return { kind: "none" }; + if (v === "main") return { kind: "main" }; + const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-rc(\d+))?$/.exec(v); + if (m) { + return { + kind: "semver", + maj: Number(m[1]), + min: Number(m[2]), + pat: Number(m[3]), + rc: m[4] ? Number(m[4]) : null, + }; + } + return { kind: "other", raw: v.toLowerCase() }; +} + +function versionCompare(a, b) { + // return -1 if ab + const pa = parseVersion(a); + const pb = parseVersion(b); + if (pa.kind === "main" && pb.kind !== "main") return 1; + if (pb.kind === "main" && pa.kind !== "main") return -1; + if (pa.kind === "semver" && pb.kind === "semver") { + if (pa.maj !== pb.maj) return pa.maj < pb.maj ? -1 : 1; + if (pa.min !== pb.min) return pa.min < pb.min ? -1 : 1; + if (pa.pat !== pb.pat) return pa.pat < pb.pat ? -1 : 1; + // rc < final + if (pa.rc == null && pb.rc == null) return 0; + if (pa.rc == null) return 1; + if (pb.rc == null) return -1; + if (pa.rc !== pb.rc) return pa.rc < pb.rc ? -1 : 1; + return 0; + } + if (pa.kind === "semver" && pb.kind !== "semver") return 1; + if (pb.kind === "semver" && pa.kind !== "semver") return -1; + if (pa.kind === "other" && pb.kind === "other") { + if (pa.raw === pb.raw) return 0; + return pa.raw < pb.raw ? -1 : 1; + } + // both "none" (non-versioned) or mixed unknowns → consider equal + return 0; +} + +// latest per depId among items +function latestVersionByDep(items) { + /** @type {Record} */ + const latest = {}; + for (const it of items) { + if (!it.version) continue; // non-versioned deps → skip latest logic + const cur = latest[it.depId]; + if (!cur || versionCompare(cur, it.version) < 0) { + latest[it.depId] = it.version; + } + } + return latest; +} + +// --- Rendering --- +function renderToolbar() { + return ` +
+
+ +
+ +
+ + + + + + +
+ + + + + + +
+
+ `; +} + +function itemRow(i) { + const checked = state.selected.has(i.id) ? "checked" : ""; + const last = i.lastUsed || "Never"; + let installed = i.installedAt || "Unknown"; + if (installed === "1970-01-01") installed = "Unknown"; // fallback for missing dates + return ` +
+ + +
+ + Last used: ${escapeHtml(last)} + + + Installed: ${escapeHtml(installed)} + + +
+
`; +} + +function renderList() { + const visible = filteredSorted(state.items); + const rows = visible.map(itemRow).join(""); + return ` +
+ ${rows || `
No matching components.
`} +
+ `; +} + +function renderOverlay(visible = false) { + return ` +
+
+ +
Uninstalling…
+
+
`; +} + +function renderFrame() { + document.body.innerHTML = ` +
+
+

Uninstall components

+

+ Select one or more installed items to uninstall. Sort by last used to find stale components quickly. +

+
+ +
+ ${renderToolbar()} +
+ ${renderList()} +
+
+
+ ${renderOverlay(state.overlayVisible)} + `; + bindEvents(); + updateSelectAllCheckbox(); +} + +function update() { + // Save minimal view state for back/forward etc. + vscode.setState({ sortKey: state.sortKey, sortDir: state.sortDir, filter: state.filter }); + renderFrame(); +} + +function renderListOnly() { + const listEl = document.getElementById("list"); + if (!listEl) return; + + const visible = filteredSorted(state.items); + const rows = visible.map(itemRow).join(""); + listEl.innerHTML = + rows || + `
No matching components.
`; + + updateSelectAllCheckbox(); + // keep the “Uninstall selected” button state in sync + const btn = document.getElementById("uninstall-selected"); + if (btn) { + if (state.selected.size) btn.removeAttribute("disabled"); + else btn.setAttribute("disabled", ""); + } +} + +function selectStaleNotLatest(days = 30) { + const latest = latestVersionByDep(state.items); + let added = 0; + for (const it of state.items) { + // non-versioned → skip "not latest" criterion + if (!it.version) continue; + + const isNotLatest = latest[it.depId] && it.version !== latest[it.depId]; + const isStale = daysSince(it.lastUsed) > days; // empty lastUsed counts as stale + if (isNotLatest && isStale) { + state.selected.add(it.id); + added++; + } + } + // update UI + renderFrame(); + // keep the list position; re-focus search if it had focus + const search = document.getElementById("search"); + if (search && document.activeElement === search) search.focus(); + return added; +} + +function bindEvents() { + // search + $("#search")?.addEventListener("input", (e) => { + state.filter = e.target.value; + //renderFrame(); + renderListOnly(); + }); + + // sort buttons + $("#sort-installed")?.addEventListener("click", () => { + if (state.sortKey === "installedAt") { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = "installedAt"; state.sortDir = "desc"; + } + renderFrame(); + }); + $("#sort-last")?.addEventListener("click", () => { + if (state.sortKey === "lastUsed") { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = "lastUsed"; state.sortDir = "desc"; + } + renderFrame(); + }); + $("#sort-name")?.addEventListener("click", () => { + if (state.sortKey === "name") { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = "name"; state.sortDir = "asc"; + } + renderFrame(); + }); + + // select all (visible) + $("#select-all")?.addEventListener("change", (e) => { + const visible = filteredSorted(state.items).map(i => i.id); + if (e.target.checked) visible.forEach(id => state.selected.add(id)); + else visible.forEach(id => state.selected.delete(id)); + renderFrame(); + }); + + // select stale & not-latest + $("#select-stale")?.addEventListener("click", () => { + selectStaleNotLatest(30); + }); + + // uninstall selected + $("#uninstall-selected")?.addEventListener("click", () => { + const ids = Array.from(state.selected); + if (!ids.length) return; + beginUninstall(ids); + }); + + // event delegation for row actions + $("#list")?.addEventListener("click", (ev) => { + const target = ev.target; + if (!(target instanceof HTMLElement)) return; + + if (target.dataset.role === "uninstall-one" || target.closest("[data-role='uninstall-one']")) { + const el = target.dataset.role === "uninstall-one" ? target : target.closest("[data-role='uninstall-one']"); + const id = el?.getAttribute("data-id"); + if (id) beginUninstall([id]); + return; + } + + if (target.dataset.role === "select") { + const id = target.getAttribute("data-id"); + if (id) { + if (target.checked) state.selected.add(id); else state.selected.delete(id); + updateSelectAllCheckbox(); + $("#uninstall-selected")?.setAttribute("disabled", state.selected.size ? "" : "disabled"); + if (state.selected.size) $("#uninstall-selected")?.removeAttribute("disabled"); + } + } + }); +} + +function updateSelectAllCheckbox() { + const visible = new Set(filteredSorted(state.items).map(i => i.id)); + const checkedCount = Array.from(visible).filter(id => state.selected.has(id)).length; + const all = $("#select-all"); + if (!all) return; + all.indeterminate = checkedCount > 0 && checkedCount < visible.size; + all.checked = checkedCount > 0 && checkedCount === visible.size; +} + +function beginUninstall(ids) { + if (state.uninstalling) return; + state.uninstalling = true; + showOverlay(`Uninstalling ${ids.length} item${ids.length > 1 ? "s" : ""}…`); + vscode.postMessage({ type: "uninstall", ids }); +} + +function showOverlay(text) { + // reset any previous timer + if (overlayTimerId) { + clearTimeout(overlayTimerId); + overlayTimerId = null; + } + + overlayShownAt = Date.now(); + state.overlayVisible = true; + const ov = $("#overlay"); + if (!ov) return; + const ovT = $("#overlay-text"); + if (!ovT) return; + ovT.textContent = text || "Working…"; + + ov.classList.remove("hidden"); + // prevent interaction underneath + document.body.style.pointerEvents = "none"; + ov.style.pointerEvents = "auto"; +} + +function scheduleHideOverlay() { + const now = Date.now(); + const remain = Math.max(OVERLAY_MIN_MS - (now - overlayShownAt), 0); + console.debug(`scheduling overlay hide in ${remain}ms`); + if (overlayTimerId) clearTimeout(overlayTimerId); + + const finish = () => { + hideOverlay(); + overlayTimerId = null; + if (state.pendingRender) { + state.pendingRender = false; + update(); + } + }; + + overlayTimerId = remain <= 0 + ? setTimeout(() => requestAnimationFrame(finish), 0) // next frame + : setTimeout(finish, remain); +} + +function hideOverlay() { + const ov = $("#overlay"); + if (!ov) return; + ov.classList.add("hidden"); + state.overlayVisible = false; + document.body.style.pointerEvents = ""; +} + +/** Defensive HTML escaping */ +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (m) => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[m])); +} + +// --- Webview <-> Extension messaging --- + +window.addEventListener("message", (event) => { + const msg = event.data; + switch (msg?.type) { + case "init": { + /** @type {Item[]} */ + state.items = Array.isArray(msg.items) ? msg.items : []; + // Restore last UI state if available + const saved = vscode.getState(); + if (saved) { + state.sortKey = saved.sortKey ?? state.sortKey; + state.sortDir = saved.sortDir ?? state.sortDir; + state.filter = saved.filter ?? state.filter; + } + // Drop any selected ids that no longer exist + state.selected = new Set([...state.selected].filter(id => state.items.some(i => i.id === id))); + state.uninstalling = false; + update(); + break; + } + case "uninstall:progress": { + // Optional: could update overlay text with current item + if (msg.text) showOverlay(msg.text); + break; + } + case "uninstall:done": { + // Remove items that were uninstalled; update list + const removedIds = new Set(msg.ids || []); + state.items = state.items.filter(i => !removedIds.has(i.id)); + for (const id of removedIds) state.selected.delete(id); + state.uninstalling = false; + + //hideOverlay(); + // don't flicker: wait until min visible time has passed + state.pendingRender = true; + scheduleHideOverlay(); + break; + } + case "uninstall:error": { + state.uninstalling = false; + //hideOverlay(); + scheduleHideOverlay(); + alert(msg.error || "Uninstall failed."); // simple; VS Code shows alerts fine + break; + } + case "update:items": { + // Extension can push refreshed inventory + if (Array.isArray(msg.items)) { + state.items = msg.items; + // prune selection + state.selected = new Set([...state.selected].filter(id => state.items.some(i => i.id === id))); + if (state.overlayVisible) { + state.pendingRender = true; // defer while overlay is up + } else { + update(); + } + } + break; + } + } +}); + +function renderBootLoading(text = "Loading installed components…") { + document.body.innerHTML = ` +
+
+
+
${escapeHtml(text)}
+
+
+ `; +} + +// Tell extension we’re ready +window.addEventListener("DOMContentLoaded", () => { + // show a spinner immediately + renderBootLoading(); + // let the spinner paint, then tell the extension we're ready + requestAnimationFrame(() => vscode.postMessage({ type: "ready" })); +});