From 84803f3af2d56dc94596adbbfdcd35836ce46265 Mon Sep 17 00:00:00 2001 From: Chad Norvell Date: Fri, 26 Jul 2024 18:11:24 +0000 Subject: [PATCH] pw_ide: VSC extension refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is almost entirely a refactor, at least in spirit, if not completely by the book Embrace the Visual Studio code event system: Remove some redundant event handling in favor of leaning into the VSC event system, except for the refresh manager, which needs to remain editor-agnostic, but nonetheless needs a bridge to connect it to the VSC event system. Standardize on disposable class instances: There was a melange of different ways of initializing components and linking them together. Now we standardize on disposable class instances and an ad hoc constructor dependency injection system, plus event subscriptions in each component's constructor, reducing coupling. This should make things a lot clearer. Standardize import formatting Fix a bad dependency import Add pre-release build commands Change-Id: I75425b587decd64040e9917c6ebe8aeebe90d7a8 Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/226059 Lint: Lint 🤖 Reviewed-by: Asad Memon Commit-Queue: Chad Norvell Presubmit-Verified: CQ Bot Account --- pw_ide/ts/pigweed-vscode/package.json | 5 +- pw_ide/ts/pigweed-vscode/src/bazel.ts | 32 ++- pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts | 136 ++++++---- pw_ide/ts/pigweed-vscode/src/clangd.ts | 246 +++++++++--------- .../ts/pigweed-vscode/src/commands/bazel.ts | 117 +++++++++ pw_ide/ts/pigweed-vscode/src/configParsing.ts | 4 +- pw_ide/ts/pigweed-vscode/src/disposables.ts | 37 +++ pw_ide/ts/pigweed-vscode/src/events.ts | 88 +++++++ pw_ide/ts/pigweed-vscode/src/extension.ts | 176 ++++--------- pw_ide/ts/pigweed-vscode/src/project.ts | 3 +- .../ts/pigweed-vscode/src/refreshManager.ts | 27 +- .../ts/pigweed-vscode/src/settingsWatcher.ts | 109 ++++---- pw_ide/ts/pigweed-vscode/src/statusBar.ts | 174 ++++++++----- pw_ide/ts/pigweed-vscode/src/terminal.ts | 26 +- 14 files changed, 736 insertions(+), 444 deletions(-) create mode 100644 pw_ide/ts/pigweed-vscode/src/commands/bazel.ts create mode 100644 pw_ide/ts/pigweed-vscode/src/disposables.ts create mode 100644 pw_ide/ts/pigweed-vscode/src/events.ts diff --git a/pw_ide/ts/pigweed-vscode/package.json b/pw_ide/ts/pigweed-vscode/package.json index 73d8560250..0d53f71a2c 100644 --- a/pw_ide/ts/pigweed-vscode/package.json +++ b/pw_ide/ts/pigweed-vscode/package.json @@ -159,8 +159,10 @@ "lint": "eslint src --ext ts", "prePackage": "tsx scripts/prepostPackage.mts --pre", "doPackage": "vsce package", + "doPackagePrerelease": "vsce package --pre-release", "postPackage": "tsx scripts/prepostPackage.mts --post", "package": "npm run clean && npm run build && npm run bundle && npm run prePackage && npm run doPackage && npm run postPackage", + "packagePrerelease": "npm run clean && npm run build && npm run bundle && npm run prePackage && npm run doPackagePrerelease && npm run postPackage", "test:jest": "jest '.*.test.ts'", "test:vscode": "vscode-test" }, @@ -170,8 +172,7 @@ "glob": "^10.4.5", "hjson": "^3.2.2", "js-yaml": "^4.1.0", - "node_modules-path": "^2.0.8", - "strip-ansi": "^7.1.0" + "node_modules-path": "^2.0.8" }, "devDependencies": { "@types/glob": "^8.1.0", diff --git a/pw_ide/ts/pigweed-vscode/src/bazel.ts b/pw_ide/ts/pigweed-vscode/src/bazel.ts index 79670c5bcc..ee01fa2077 100644 --- a/pw_ide/ts/pigweed-vscode/src/bazel.ts +++ b/pw_ide/ts/pigweed-vscode/src/bazel.ts @@ -12,14 +12,16 @@ // License for the specific language governing permissions and limitations under // the License. -/** Set up and manage the Bazel tools integration. */ +import * as child_process from 'child_process'; +import * as path from 'path'; import * as vscode from 'vscode'; -import { execSync } from 'child_process'; -import { resolve, basename } from 'path'; import { getNativeBinary as getBazeliskBinary } from '@bazel/bazelisk'; import node_modules from 'node_modules-path'; + +import logger from './logging'; + import { bazel_executable, buildifier_executable, @@ -27,10 +29,9 @@ import { ConfigAccessor, bazel_codelens, } from './settings'; -import logger from './logging'; /** - * Is there a path to the given tool configured in VS Code settings + * Is there a path to the given tool configured in VS Code settings? * * @param name The name of the tool * @param configAccessor A config accessor for the setting @@ -41,7 +42,9 @@ function hasConfiguredPathTo( configAccessor: ConfigAccessor, ): boolean { const exe = configAccessor.get(); - return exe ? basename(exe).toLowerCase().includes(name.toLowerCase()) : false; + return exe + ? path.basename(exe).toLowerCase().includes(name.toLowerCase()) + : false; } /** @@ -52,7 +55,9 @@ function hasConfiguredPathTo( function findPathsTo(name: string): string[] { // TODO: https://pwbug.dev/351883170 - This only works on Unix-ish OSes. try { - const stdout = execSync(`which -a ${name.toLowerCase()}`).toString(); + const stdout = child_process + .execSync(`which -a ${name.toLowerCase()}`) + .toString(); // Parse the output into a list of paths, removing any duplicates/blanks. return [...new Set(stdout.split('\n'))].filter((item) => item.length > 0); } catch (err: unknown) { @@ -69,7 +74,12 @@ export function vendoredBazeliskPath(): string | undefined { // have a path. if (typeof result !== 'string') return undefined; - return resolve(node_modules()!, '@bazel', 'bazelisk', basename(result)); + return path.resolve( + node_modules()!, + '@bazel', + 'bazelisk', + path.basename(result), + ); } function vendoredBuildifierPath(): string | undefined { @@ -82,9 +92,9 @@ function vendoredBuildifierPath(): string | undefined { // Unlike the @bazel/bazelisk package, @bazel/buildifer doesn't export any // code. The logic is exactly the same, but with a different name. - const binaryName = basename(result).replace('bazelisk', 'buildifier'); + const binaryName = path.basename(result).replace('bazelisk', 'buildifier'); - return resolve(node_modules()!, '@bazel', 'buildifier', binaryName); + return path.resolve(node_modules()!, '@bazel', 'buildifier', binaryName); } const VENDORED_LABEL = 'Use the version built in to the Pigweed extension'; @@ -210,7 +220,7 @@ export async function setBazelRecommendedSettings() { await bazel_codelens.update(true); } -export async function configureOtherBazelSettings() { +export async function configureBazelSettings() { await updateVendoredBazelisk(); await updateVendoredBuildifier(); diff --git a/pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts b/pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts index 41a3e866d3..db5def3939 100644 --- a/pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts +++ b/pw_ide/ts/pigweed-vscode/src/bazelWatcher.ts @@ -12,31 +12,50 @@ // License for the specific language governing permissions and limitations under // the License. -/** - * A file watcher for Bazel files. - * - * We use this to automatically trigger compile commands refreshes on changes - * to the build graph. - */ +import * as child_process from 'child_process'; import * as vscode from 'vscode'; -import { spawn } from 'child_process'; +import { Disposable } from './disposables'; +import logger from './logging'; +import { getPigweedProjectRoot } from './project'; import { RefreshCallback, OK, - refreshManager, + RefreshManager, RefreshCallbackResult, } from './refreshManager'; -import logger from './logging'; -import { getPigweedProjectRoot } from './project'; + import { bazel_executable, settings, workingDir } from './settings'; +/** Regex for finding ANSI escape codes. */ +const ANSI_PATTERN = new RegExp( + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)' + + '*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)' + + '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', + 'g', +); + +/** Strip ANSI escape codes from a string. */ +const stripAnsi = (input: string): string => input.replace(ANSI_PATTERN, ''); + +/** Remove ANSI escape codes that aren't supported in the output window. */ +const cleanLogLine = (line: Buffer) => { + const stripped = stripAnsi(line.toString()); + + // Remove superfluous newlines + if (stripped.at(-1) === '\n') { + return stripped.substring(0, stripped.length - 1); + } + + return stripped; +}; + /** * Create a container for a process running the refresh compile commands target. * - * @return Refresh callbacks to do the refresh and to abort + * @return Refresh callbacks to do the refresh and to abort it */ function createRefreshProcess(): [RefreshCallback, () => void] { // This provides us a handle to abort the process, if needed. @@ -47,23 +66,6 @@ function createRefreshProcess(): [RefreshCallback, () => void] { // This callback will be registered with the RefreshManager to be called // when it's time to do the refresh. const cb: RefreshCallback = async () => { - // This package is an ES module, but we're still building with CommonJS - // modules. This is the workaround. - // TODO: https://pwbug.dev/354034542 - Change when we're ES modules - const { default: stripAnsi } = await import('strip-ansi'); - - const cleanLogLine = (line: Buffer) => { - // Remove ANSI escape codes that aren't supported in the output window - const stripped = stripAnsi(line.toString()); - - // Remove superfluous newlines - if (stripped.at(-1) === '\n') { - return stripped.substring(0, stripped.length - 1); - } - - return stripped; - }; - logger.info('Refreshing compile commands'); const cwd = (await getPigweedProjectRoot(settings, workingDir)) as string; const cmd = bazel_executable.get(); @@ -89,7 +91,7 @@ function createRefreshProcess(): [RefreshCallback, () => void] { // TODO: https://pwbug.dev/350861417 - This should use the Bazel // extension commands instead, but doing that through the VS Code // command API is not simple. - const spawnedProcess = spawn(cmd, args, { cwd, signal }); + const spawnedProcess = child_process.spawn(cmd, args, { cwd, signal }); // Wrapping this in a promise that only resolves on exit or error ensures // that this refresh callback blocks until the spawned process is complete. @@ -141,37 +143,61 @@ function createRefreshProcess(): [RefreshCallback, () => void] { return [cb, abort]; } -/** Trigger a refresh compile commands process. */ -export async function refreshCompileCommands() { - const [cb, abort] = createRefreshProcess(); +/** A file watcher that automatically runs a refresh on Bazel file changes. */ +export class BazelRefreshCompileCommandsWatcher extends Disposable { + private refreshManager: RefreshManager; - const wrappedAbort = () => { - abort(); - return OK; - }; + constructor(refreshManager: RefreshManager, disable = false) { + super(); - refreshManager.onOnce(cb, 'refreshing'); - refreshManager.onOnce(wrappedAbort, 'abort'); - refreshManager.refresh(); -} + this.refreshManager = refreshManager; + if (disable) return; + + logger.info('Initializing Bazel refresh compile commands file watcher'); -/** Create file watchers to refresh compile commands on Bazel file changes. */ -export function initRefreshCompileCommandsWatcher(): { dispose: () => void } { - logger.info('Initializing refresh compile commands file watcher'); + const watchers = [ + vscode.workspace.createFileSystemWatcher('**/WORKSPACE'), + vscode.workspace.createFileSystemWatcher('**/*.bazel'), + vscode.workspace.createFileSystemWatcher('**/*.bzl'), + ]; - const watchers = [ - vscode.workspace.createFileSystemWatcher('**/BUILD.bazel'), - vscode.workspace.createFileSystemWatcher('**/WORKSPACE'), - vscode.workspace.createFileSystemWatcher('**/*.bzl'), - ]; + watchers.forEach((watcher) => { + watcher.onDidChange(() => { + logger.info( + '[onDidChange] triggered from refresh compile commands watcher', + ); + this.refresh(); + }); - watchers.forEach((watcher) => { - watcher.onDidChange(refreshCompileCommands); - watcher.onDidCreate(refreshCompileCommands); - watcher.onDidDelete(refreshCompileCommands); - }); + watcher.onDidCreate(() => { + logger.info( + '[onDidCreate] triggered from refresh compile commands watcher', + ); + this.refresh(); + }); + + watcher.onDidDelete(() => { + logger.info( + '[onDidDelete] triggered from refresh compile commands watcher', + ); + this.refresh(); + }); + }); + + this.disposables.push(...watchers); + } + + /** Trigger a refresh compile commands process. */ + refresh = () => { + const [cb, abort] = createRefreshProcess(); + + const wrappedAbort = () => { + abort(); + return OK; + }; - return { - dispose: () => watchers.forEach((watcher) => watcher.dispose()), + this.refreshManager.onOnce(cb, 'refreshing'); + this.refreshManager.onOnce(wrappedAbort, 'abort'); + this.refreshManager.refresh(); }; } diff --git a/pw_ide/ts/pigweed-vscode/src/clangd.ts b/pw_ide/ts/pigweed-vscode/src/clangd.ts index 18b5698bb7..3f138370bf 100644 --- a/pw_ide/ts/pigweed-vscode/src/clangd.ts +++ b/pw_ide/ts/pigweed-vscode/src/clangd.ts @@ -12,43 +12,45 @@ // License for the specific language governing permissions and limitations under // the License. +import * as fs from 'fs'; +import * as fs_p from 'fs/promises'; +import * as path from 'path'; +import * as readline_p from 'readline/promises'; + import * as vscode from 'vscode'; import { createHash } from 'crypto'; -import { createInterface } from 'readline/promises'; -import { createReadStream, existsSync } from 'fs'; -import { copyFile, readFile, unlink, writeFile } from 'fs/promises'; import { glob } from 'glob'; -import { basename, dirname, isAbsolute, join } from 'path'; import * as yaml from 'js-yaml'; -import { refreshCompileCommands } from './bazelWatcher'; -import { OK, RefreshCallback, refreshManager } from './refreshManager'; -import { settingFor, settings, stringSettingFor, workingDir } from './settings'; +import { Disposable } from './disposables'; +import { didChangeClangdConfig, didChangeTarget } from './events'; import { launchTroubleshootingLink } from './links'; import logger from './logging'; +import { OK, RefreshCallback, RefreshManager } from './refreshManager'; +import { settingFor, settings, stringSettingFor, workingDir } from './settings'; const CDB_FILE_NAME = 'compile_commands.json' as const; const CDB_FILE_DIR = '.compile_commands' as const; // Need this indirection to prevent `workingDir` being called before init. -const CDB_DIR = () => join(workingDir.get(), CDB_FILE_DIR); +const CDB_DIR = () => path.join(workingDir.get(), CDB_FILE_DIR); // TODO: https://pwbug.dev/352601321 - This is brittle and also probably // doesn't work on Windows. const clangdPath = () => - join(workingDir.get(), 'external', 'llvm_toolchain', 'bin', 'clangd'); + path.join(workingDir.get(), 'external', 'llvm_toolchain', 'bin', 'clangd'); -export const targetPath = (target: string) => join(`${CDB_DIR()}`, target); +export const targetPath = (target: string) => path.join(`${CDB_DIR()}`, target); export const targetCompileCommandsPath = (target: string) => - join(targetPath(target), CDB_FILE_NAME); + path.join(targetPath(target), CDB_FILE_NAME); export async function availableTargets(): Promise { // Get the name of every sub dir in the compile commands dir that contains // a compile commands file. return ( (await glob(`**/${CDB_FILE_NAME}`, { cwd: CDB_DIR() })) - .map((path) => basename(dirname(path))) + .map((filePath) => path.basename(path.dirname(filePath))) // Filter out a catch-all database in the root compile commands dir .filter((name) => name.trim() !== '.') ); @@ -58,7 +60,10 @@ export function getTarget(): string | undefined { return settings.codeAnalysisTarget(); } -export async function setTarget(target: string): Promise { +export async function setTarget( + target: string, + settingsFileWriter: (target: string) => Promise, +): Promise { if (!(await availableTargets()).includes(target)) { throw new Error(`Target not among available targets: ${target}`); } @@ -78,7 +83,7 @@ export async function setTarget(target: string): Promise { '--header-insertion=never', '--background-index', ]), - writeClangdSettingsFile(target), + settingsFileWriter(target), ]).then(() => // Restart the clangd server so it picks up the new setting. vscode.commands.executeCommand('clangd.restart'), @@ -87,8 +92,8 @@ export async function setTarget(target: string): Promise { /** Parse a compilation database and get the source files in the build. */ async function parseForSourceFiles(target: string): Promise> { - const rd = createInterface({ - input: createReadStream(targetCompileCommandsPath(target)), + const rd = readline_p.createInterface({ + input: fs.createReadStream(targetCompileCommandsPath(target)), crlfDelay: Infinity, }); @@ -103,7 +108,7 @@ async function parseForSourceFiles(target: string): Promise> { if ( // Ignore files outside of this project dir - !isAbsolute(matchedPath) && + !path.isAbsolute(matchedPath) && // Ignore build artifacts !matchedPath.startsWith('bazel') && // Ignore external dependencies @@ -117,59 +122,87 @@ async function parseForSourceFiles(target: string): Promise> { return files; } -/** A cache of files that are in the builds of each target. */ -let activeFiles: Record> = {}; +// See: https://clangd.llvm.org/config#files +const clangdSettingsDisableFiles = (paths: string[]) => ({ + If: { + PathExclude: paths, + }, + Diagnostics: { + Suppress: '*', + }, +}); -export const refreshActiveFiles: RefreshCallback = async () => { - logger.info('Refreshing active files cache'); - const targets = await availableTargets(); +export class ClangdActiveFilesCache extends Disposable { + activeFiles: Record> = {}; - const targetSourceFiles = await Promise.all( - targets.map( - async (target) => [target, await parseForSourceFiles(target)] as const, - ), - ); + /** Get the active files for a particular target. */ + getForTarget = async (target: string): Promise> => { + if (!Object.keys(this.activeFiles).includes(target)) { + return new Set(); + } - activeFiles = Object.fromEntries(targetSourceFiles); - logger.info('Finished refreshing active files cache'); - return OK; -}; + return this.activeFiles[target]; + }; -// Refresh the active files cache after refreshing compile commands -refreshManager.on(refreshActiveFiles, 'didRefresh'); + refresh: RefreshCallback = async () => { + logger.info('Refreshing active files cache'); + const targets = await availableTargets(); -/** Get the active files for a particular target. */ -export async function getActiveFiles(target: string): Promise> { - if (!Object.keys(activeFiles).includes(target)) { - return new Set(); - } + const targetSourceFiles = await Promise.all( + targets.map( + async (target) => [target, await parseForSourceFiles(target)] as const, + ), + ); - return activeFiles[target]; -} + this.activeFiles = Object.fromEntries(targetSourceFiles); + logger.info('Finished refreshing active files cache'); + return OK; + }; + + writeToSettings = async (target?: string) => { + const settingsPath = path.join(workingDir.get(), '.clangd'); + const sharedSettingsPath = path.join(workingDir.get(), '.clangd.shared'); + + // If the setting to disable code intelligence for files not in the build + // of this target is disabled, then we need to: + // 1. *Not* add configuration to disable clangd for any files + // 2. *Remove* any prior such configuration that may have existed + if (!settings.disableInactiveFileCodeIntelligence()) { + await handleInactiveFileCodeIntelligenceEnabled( + settingsPath, + sharedSettingsPath, + ); -const setCompileCommandsCallbacks: ((target: string) => void)[] = []; + return; + } -/** - * Register callbacks to be called when the target is changed. - * - * Setting the target does persist the target into settings, where it can be - * retrieved by other functions. But there's enough asyncronicity in that - * procedure that it's more reliable to just get the target name directly, so - * we provide it to the callbacks. - */ -export function onSetCompileCommands(cb: (target: string) => void) { - setCompileCommandsCallbacks.push(cb); -} + if (!target) return; -async function onSetTargetSelection(target: string | undefined) { - if (target) { - vscode.window.showInformationMessage(`Analysis target set to: ${target}`); - await setTarget(target); - setCompileCommandsCallbacks.forEach((cb) => cb(target)); - } + // Create clangd settings that disable code intelligence for all files + // except those that are in the build for the specified target. + const activeFilesForTarget = [...(await this.getForTarget(target))]; + let data = yaml.dump(clangdSettingsDisableFiles(activeFilesForTarget)); + + // If there are other clangd settings for the project, append this fragment + // to the end of those settings. + if (fs.existsSync(sharedSettingsPath)) { + const sharedSettingsData = ( + await fs_p.readFile(sharedSettingsPath) + ).toString(); + data = `${sharedSettingsData}\n---\n${data}`; + } + + await fs_p.writeFile(settingsPath, data, { flag: 'w+' }); + + logger.info( + `Updated .clangd to exclude files not in the build for: ${target}`, + ); + }; } -export async function setCompileCommandsTarget() { +export async function setCompileCommandsTarget( + activeFilesCache: ClangdActiveFilesCache, +) { const targets = await availableTargets(); if (targets.length === 0) { @@ -192,25 +225,24 @@ export async function setCompileCommandsTarget() { title: 'Select a target', canPickMany: false, }) - .then(onSetTargetSelection); + .then(async (target) => { + if (target) { + await setTarget(target, activeFilesCache.writeToSettings); + didChangeTarget.fire(target); + } + }); } -export async function refreshCompileCommandsAndSetTarget() { - await refreshCompileCommands(); +export async function refreshCompileCommandsAndSetTarget( + refresh: () => void, + refreshManager: RefreshManager, + activeFilesCache: ClangdActiveFilesCache, +) { + refresh(); await refreshManager.waitFor('didRefresh'); - await setCompileCommandsTarget(); + await setCompileCommandsTarget(activeFilesCache); } -// See: https://clangd.llvm.org/config#files -const clangdSettingsDisableFiles = (paths: string[]) => ({ - If: { - PathExclude: paths, - }, - Diagnostics: { - Suppress: '*', - }, -}); - /** * Handle the case where inactive file code intelligence is enabled. * @@ -227,67 +259,49 @@ async function handleInactiveFileCodeIntelligenceEnabled( settingsPath: string, sharedSettingsPath: string, ) { - if (existsSync(sharedSettingsPath)) { - if (!existsSync(settingsPath)) { + if (fs.existsSync(sharedSettingsPath)) { + if (!fs.existsSync(settingsPath)) { // If there's a shared settings file, but no active settings file, copy // the shared settings file to make an active settings file. - await copyFile(sharedSettingsPath, settingsPath); + await fs_p.copyFile(sharedSettingsPath, settingsPath); } else { // If both shared settings and active settings are present, check if they // are identical. If so, no action is required. Otherwise, copy the shared // settings file over the active settings file. const settingsHash = createHash('md5').update( - await readFile(settingsPath), + await fs_p.readFile(settingsPath), ); const sharedSettingsHash = createHash('md5').update( - await readFile(sharedSettingsPath), + await fs_p.readFile(sharedSettingsPath), ); if (settingsHash !== sharedSettingsHash) { - await copyFile(sharedSettingsPath, settingsPath); + await fs_p.copyFile(sharedSettingsPath, settingsPath); } } - } else if (existsSync(settingsPath)) { + } else if (fs.existsSync(settingsPath)) { // If there's no shared settings file, then we just need to remove the // active settings file if it's present. - unlink(settingsPath); + await fs_p.unlink(settingsPath); } } -export async function writeClangdSettingsFile(target?: string) { - const settingsPath = join(workingDir.get(), '.clangd'); - const sharedSettingsPath = join(workingDir.get(), '.clangd.shared'); - - // If the setting to disable code intelligence for files not in the build of - // this target is disabled, then we need to: - // 1. *Not* add configuration to disable clangd for any files - // 2. *Remove* any prior such configuration that may have existed - if (!settings.disableInactiveFileCodeIntelligence()) { - await handleInactiveFileCodeIntelligenceEnabled( - settingsPath, - sharedSettingsPath, - ); - - return; - } - - if (!target) return; - - // Create clangd settings that disable code intelligence for all files - // except those that are in the build for the specified target. - const activeFilesForTarget = [...(await getActiveFiles(target))]; - let data = yaml.dump(clangdSettingsDisableFiles(activeFilesForTarget)); - - // If there are other clangd settings for the project, append this fragment - // to the end of those settings. - if (existsSync(sharedSettingsPath)) { - const sharedSettingsData = (await readFile(sharedSettingsPath)).toString(); - data = `${sharedSettingsData}\n---\n${data}`; - } - - await writeFile(settingsPath, data, { flag: 'w+' }); +export async function disableInactiveFileCodeIntelligence( + activeFilesCache: ClangdActiveFilesCache, +) { + logger.info('Disabling inactive file code intelligence'); + await settings.disableInactiveFileCodeIntelligence(true); + didChangeClangdConfig.fire(); + await activeFilesCache.writeToSettings(settings.codeAnalysisTarget()); + await vscode.commands.executeCommand('clangd.restart'); +} - logger.info( - `Updated .clangd to exclude files not in the build for: ${target}`, - ); +export async function enableInactiveFileCodeIntelligence( + activeFilesCache: ClangdActiveFilesCache, +) { + logger.info('Enabling inactive file code intelligence'); + await settings.disableInactiveFileCodeIntelligence(false); + didChangeClangdConfig.fire(); + await activeFilesCache.writeToSettings(); + await vscode.commands.executeCommand('clangd.restart'); } diff --git a/pw_ide/ts/pigweed-vscode/src/commands/bazel.ts b/pw_ide/ts/pigweed-vscode/src/commands/bazel.ts new file mode 100644 index 0000000000..e7b7e62df9 --- /dev/null +++ b/pw_ide/ts/pigweed-vscode/src/commands/bazel.ts @@ -0,0 +1,117 @@ +// Copyright 2024 The Pigweed Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +import * as vscode from 'vscode'; +import { ExtensionContext } from 'vscode'; + +import { + interactivelySetBazeliskPath, + setBazelRecommendedSettings, +} from '../bazel'; + +import { BazelRefreshCompileCommandsWatcher } from '../bazelWatcher'; + +import { + ClangdActiveFilesCache, + disableInactiveFileCodeIntelligence, + enableInactiveFileCodeIntelligence, + refreshCompileCommandsAndSetTarget, + setCompileCommandsTarget, +} from '../clangd'; + +import { RefreshManager } from '../refreshManager'; +import { patchBazeliskIntoTerminalPath } from '../terminal'; + +export function registerBazelProjectCommands( + context: ExtensionContext, + refreshManager: RefreshManager, + compileCommandsWatcher: BazelRefreshCompileCommandsWatcher, + clangdActiveFilesCache: ClangdActiveFilesCache, +) { + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.disable-inactive-file-code-intelligence', + () => disableInactiveFileCodeIntelligence(clangdActiveFilesCache), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.enable-inactive-file-code-intelligence', + () => enableInactiveFileCodeIntelligence(clangdActiveFilesCache), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.refresh-compile-commands', + compileCommandsWatcher.refresh, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.refresh-compile-commands-and-set-target', + () => + refreshCompileCommandsAndSetTarget( + compileCommandsWatcher.refresh, + refreshManager, + clangdActiveFilesCache, + ), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.set-bazelisk-path', + interactivelySetBazeliskPath, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.set-bazel-recommended-settings', + setBazelRecommendedSettings, + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pigweed.select-target', () => + setCompileCommandsTarget(clangdActiveFilesCache), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pigweed.launch-terminal', () => + vscode.window.showWarningMessage( + 'This command is currently not supported with Bazel projects', + ), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand('pigweed.bootstrap-terminal', () => + vscode.window.showWarningMessage( + 'This command is currently not supported with Bazel projects', + ), + ), + ); + + context.subscriptions.push( + vscode.commands.registerCommand( + 'pigweed.activate-bazelisk-in-terminal', + patchBazeliskIntoTerminalPath, + ), + ); +} diff --git a/pw_ide/ts/pigweed-vscode/src/configParsing.ts b/pw_ide/ts/pigweed-vscode/src/configParsing.ts index 10bc6a7762..f9b2f1b192 100644 --- a/pw_ide/ts/pigweed-vscode/src/configParsing.ts +++ b/pw_ide/ts/pigweed-vscode/src/configParsing.ts @@ -12,8 +12,10 @@ // License for the specific language governing permissions and limitations under // the License. -import * as hjson from 'hjson'; import * as vscode from 'vscode'; + +import * as hjson from 'hjson'; + import logger from './logging'; /** diff --git a/pw_ide/ts/pigweed-vscode/src/disposables.ts b/pw_ide/ts/pigweed-vscode/src/disposables.ts new file mode 100644 index 0000000000..fbe48ed737 --- /dev/null +++ b/pw_ide/ts/pigweed-vscode/src/disposables.ts @@ -0,0 +1,37 @@ +// Copyright 2024 The Pigweed Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +interface IDisposable { + dispose: () => void; +} + +export class Disposable implements IDisposable { + protected disposables: IDisposable[] = []; + + dispose = () => { + this.disposables.forEach((it) => it.dispose()); + }; +} + +export class Disposer extends Disposable { + add = (disposable: T): T => { + this.disposables.push(disposable); + return disposable; + }; + + addMany = >(disposables: T): T => { + this.disposables.push(...Object.values(disposables)); + return disposables; + }; +} diff --git a/pw_ide/ts/pigweed-vscode/src/events.ts b/pw_ide/ts/pigweed-vscode/src/events.ts new file mode 100644 index 0000000000..f35e4476dc --- /dev/null +++ b/pw_ide/ts/pigweed-vscode/src/events.ts @@ -0,0 +1,88 @@ +// Copyright 2024 The Pigweed Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations under +// the License. + +import { EventEmitter } from 'vscode'; + +import { Disposer } from './disposables'; + +import { + OK, + RefreshCallback, + RefreshManager, + RefreshStatus, +} from './refreshManager'; + +/** Event emitted when the code analysis target is changed. */ +export const didChangeTarget = new EventEmitter(); + +/** Event emitted whenever the `clangd` configuration is changed. */ +export const didChangeClangdConfig = new EventEmitter(); + +/** Event emitted when the refresh manager state changes. */ +export const didChangeRefreshStatus = new EventEmitter(); + +/** + * Helper for subscribing to events. + * + * This lets you register the event callback and add the resulting handler to + * a disposer in one call. + */ +export function subscribe( + event: EventEmitter, + cb: (data: T) => void, + disposer: Disposer, +): void { + disposer.add(event.event(cb)); +} + +/** + * Helper for linking refresh manager state changes to VSC events. + * + * Given a refresh manager state, it returns a tuple containing a refresh + * callback that fires the event, and the same status you provided. This just + * makes it easier to spread those arguments to the refresh manager callback + * register function. + */ +const fireDidChangeRefreshStatus = ( + status: RefreshStatus, +): [RefreshCallback, RefreshStatus] => { + const cb: RefreshCallback = () => { + didChangeRefreshStatus.fire(status); + return OK; + }; + + return [cb, status]; +}; + +/** + * Link the refresh manager state machine to the VSC event system. + * + * The refresh manager needs to remain editor-agnostic so it can be used outside + * of VSC. It also has a more constrained event system that isn't completely + * represented by the VSC event system. This function links the two, such that + * any refresh manager state changes also trigger a VSC event that can be + * subscribed to by things that need to be notified or updated when the refresh + * manager runs, but don't need to be integrated into the refresh manager + * itself. + */ +export function linkRefreshManagerToEvents( + refreshManager: RefreshManager, +) { + refreshManager.on(...fireDidChangeRefreshStatus('idle')); + refreshManager.on(...fireDidChangeRefreshStatus('willRefresh')); + refreshManager.on(...fireDidChangeRefreshStatus('refreshing')); + refreshManager.on(...fireDidChangeRefreshStatus('didRefresh')); + refreshManager.on(...fireDidChangeRefreshStatus('abort')); + refreshManager.on(...fireDidChangeRefreshStatus('fault')); +} diff --git a/pw_ide/ts/pigweed-vscode/src/extension.ts b/pw_ide/ts/pigweed-vscode/src/extension.ts index 53ce85bde6..4890a7bf89 100644 --- a/pw_ide/ts/pigweed-vscode/src/extension.ts +++ b/pw_ide/ts/pigweed-vscode/src/extension.ts @@ -14,51 +14,35 @@ import * as vscode from 'vscode'; -import { - onSetCompileCommands, - refreshCompileCommandsAndSetTarget, - setCompileCommandsTarget, - writeClangdSettingsFile, -} from './clangd'; +import { registerBazelProjectCommands } from './commands/bazel'; +import { getSettingsData, syncSettingsSharedToProject } from './configParsing'; +import { configureBazelisk, configureBazelSettings } from './bazel'; +import { BazelRefreshCompileCommandsWatcher } from './bazelWatcher'; +import { Disposer } from './disposables'; +import { linkRefreshManagerToEvents } from './events'; +import { ClangdActiveFilesCache } from './clangd'; import { checkExtensions } from './extensionManagement'; import logger, { output } from './logging'; import { fileBug, launchTroubleshootingLink } from './links'; -import { settings, workingDir } from './settings'; -import { - initRefreshCompileCommandsWatcher, - refreshCompileCommands, -} from './bazelWatcher'; + import { getPigweedProjectRoot, isBazelWorkspaceProject, isBootstrapProject, } from './project'; -import { refreshManager } from './refreshManager'; -import { - launchBootstrapTerminal, - launchTerminal, - patchBazeliskIntoTerminalPath, -} from './terminal'; -import { - interactivelySetBazeliskPath, - configureBazelisk, - configureOtherBazelSettings, - setBazelRecommendedSettings, -} from './bazel'; + +import { RefreshManager } from './refreshManager'; +import { settings, workingDir } from './settings'; +import { ClangdFileWatcher, SettingsFileWatcher } from './settingsWatcher'; + import { - getInactiveVisibilityStatusBarItem, - getTargetStatusBarItem, - updateInactiveVisibilityStatusBarItem, - updateTargetStatusBarItem, + InactiveVisibilityStatusBarItem, + TargetStatusBarItem, } from './statusBar'; -import { - initClangdFileWatcher, - initSettingsFilesWatcher, -} from './settingsWatcher'; -import { getSettingsData, syncSettingsSharedToProject } from './configParsing'; -// Anything that needs to be disposed of should be stored here. -const disposables: { dispose: () => void }[] = [output, refreshManager]; +import { launchBootstrapTerminal, launchTerminal } from './terminal'; + +const disposer = new Disposer(); function registerUniversalCommands(context: vscode.ExtensionContext) { context.subscriptions.push( @@ -160,108 +144,40 @@ function registerBootstrapCommands(context: vscode.ExtensionContext) { ); } -async function registerBazelCommands(context: vscode.ExtensionContext) { - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.disable-inactive-file-code-intelligence', - async () => { - logger.info('Disabling inactive file code intelligence'); - await settings.disableInactiveFileCodeIntelligence(true); - updateInactiveVisibilityStatusBarItem(); - await writeClangdSettingsFile(settings.codeAnalysisTarget()); - await vscode.commands.executeCommand('clangd.restart'); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.enable-inactive-file-code-intelligence', - async () => { - logger.info('Enabling inactive file code intelligence'); - await settings.disableInactiveFileCodeIntelligence(false); - updateInactiveVisibilityStatusBarItem(); - await writeClangdSettingsFile(); - await vscode.commands.executeCommand('clangd.restart'); - }, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.refresh-compile-commands', - refreshCompileCommands, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.refresh-compile-commands-and-set-target', - refreshCompileCommandsAndSetTarget, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.set-bazelisk-path', - interactivelySetBazeliskPath, - ), - ); - - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.set-bazel-recommended-settings', - setBazelRecommendedSettings, - ), - ); +async function initAsBazelProject(context: vscode.ExtensionContext) { + // Marshall all of our components and dependencies. + const refreshManager = disposer.add(RefreshManager.create()); + linkRefreshManagerToEvents(refreshManager); - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.select-target', - setCompileCommandsTarget, + const { clangdActiveFilesCache, compileCommandsWatcher } = disposer.addMany({ + clangdActiveFilesCache: new ClangdActiveFilesCache(), + compileCommandsWatcher: new BazelRefreshCompileCommandsWatcher( + refreshManager, + settings.disableCompileCommandsFileWatcher(), ), - ); + inactiveVisibilityStatusBarItem: new InactiveVisibilityStatusBarItem(), + settingsFileWatcher: new SettingsFileWatcher(), + targetStatusBarItem: new TargetStatusBarItem(), + }); - context.subscriptions.push( - vscode.commands.registerCommand('pigweed.launch-terminal', () => - vscode.window.showWarningMessage( - 'This command is currently not supported with Bazel projects', - ), - ), - ); + disposer.add(new ClangdFileWatcher(clangdActiveFilesCache)); - context.subscriptions.push( - vscode.commands.registerCommand('pigweed.bootstrap-terminal', () => - vscode.window.showWarningMessage( - 'This command is currently not supported with Bazel projects', - ), - ), - ); + // Refresh the active files cache after refreshing compile commands. + refreshManager.on(clangdActiveFilesCache.refresh, 'didRefresh'); - context.subscriptions.push( - vscode.commands.registerCommand( - 'pigweed.activate-bazelisk-in-terminal', - patchBazeliskIntoTerminalPath, - ), + registerBazelProjectCommands( + context, + refreshManager, + compileCommandsWatcher, + clangdActiveFilesCache, ); - disposables.push(initSettingsFilesWatcher()); - disposables.push(initClangdFileWatcher()); - - context.subscriptions.push(getTargetStatusBarItem()); - onSetCompileCommands(updateTargetStatusBarItem); - refreshManager.on(updateTargetStatusBarItem, 'idle'); - refreshManager.on(updateTargetStatusBarItem, 'willRefresh'); - refreshManager.on(updateTargetStatusBarItem, 'refreshing'); - refreshManager.on(updateTargetStatusBarItem, 'didRefresh'); - refreshManager.on(updateTargetStatusBarItem, 'abort'); - refreshManager.on(updateTargetStatusBarItem, 'fault'); - - context.subscriptions.push(getInactiveVisibilityStatusBarItem()); + // Do stuff that we want to do on load. + await configureBazelSettings(); + await configureBazelisk(); if (!settings.disableCompileCommandsFileWatcher()) { - await vscode.commands.executeCommand('pigweed.refresh-compile-commands'); - disposables.push(initRefreshCompileCommandsWatcher()); + compileCommandsWatcher.refresh(); } } @@ -298,9 +214,7 @@ async function configureProject(context: vscode.ExtensionContext) { isBazelWorkspaceProject(projectRoot) ) { output.appendLine('This is a Bazel project'); - await registerBazelCommands(context); - await configureOtherBazelSettings(); - await configureBazelisk(); + await initAsBazelProject(context); } else { vscode.window .showErrorMessage( @@ -396,5 +310,5 @@ export async function activate(context: vscode.ExtensionContext) { } export function deactivate() { - disposables.forEach((item) => item.dispose()); + disposer.dispose(); } diff --git a/pw_ide/ts/pigweed-vscode/src/project.ts b/pw_ide/ts/pigweed-vscode/src/project.ts index 0881eee794..4aa273547b 100644 --- a/pw_ide/ts/pigweed-vscode/src/project.ts +++ b/pw_ide/ts/pigweed-vscode/src/project.ts @@ -13,9 +13,10 @@ // the License. import * as fs from 'fs'; -import { glob } from 'glob'; import * as path from 'path'; +import { glob } from 'glob'; + import type { Settings, WorkingDirStore } from './settings'; const PIGWEED_JSON = 'pigweed.json' as const; diff --git a/pw_ide/ts/pigweed-vscode/src/refreshManager.ts b/pw_ide/ts/pigweed-vscode/src/refreshManager.ts index bcb659503e..1eecaf6824 100644 --- a/pw_ide/ts/pigweed-vscode/src/refreshManager.ts +++ b/pw_ide/ts/pigweed-vscode/src/refreshManager.ts @@ -40,6 +40,8 @@ * in-progress refresh process. */ +import logger from './logging'; + /** Refresh statuses that broadly represent a refresh in progress. */ export type RefreshStatusInProgress = | 'willRefresh' @@ -257,7 +259,28 @@ export class RefreshManager { // Run the callbacks associated with this state transition. await this.runCallbacks(previous); } catch (err: unknown) { - // If an error occurs while during callbacks, move into the fault state. + // An error occurred while running the callbacks associated with this + // state change. + + // Report errors to the output window. + // Errors can come from well-behaved refresh callbacks that return + // an object like `{ error: '...' }`, but since those errors are + // hoisted to here by `throw`ing them, we will also catch unexpected or + // not-well-behaved errors as exceptions (`{ message: '...' }`-style). + if (typeof err === 'string') { + logger.error(err); + } else { + const { message } = err as { message: string | undefined }; + + if (message) { + logger.error(message); + } else { + logger.error('Unknown error occurred'); + } + } + + // Move into the fault state, running the callbacks associated with + // that state transition. const previous = this._state; this._state = 'fault'; await this.runCallbacks(previous); @@ -474,5 +497,3 @@ export class RefreshManager { } } } - -export const refreshManager = RefreshManager.create(); diff --git a/pw_ide/ts/pigweed-vscode/src/settingsWatcher.ts b/pw_ide/ts/pigweed-vscode/src/settingsWatcher.ts index 90dc974c4a..60b1462e32 100644 --- a/pw_ide/ts/pigweed-vscode/src/settingsWatcher.ts +++ b/pw_ide/ts/pigweed-vscode/src/settingsWatcher.ts @@ -13,79 +13,88 @@ // the License. import * as vscode from 'vscode'; +import { RelativePattern } from 'vscode'; + +import { Disposable } from './disposables'; +import { ClangdActiveFilesCache } from './clangd'; import { getSettingsData, syncSettingsSharedToProject } from './configParsing'; -import { writeClangdSettingsFile } from './clangd'; -import { settings } from './settings'; import logger from './logging'; +import { settings } from './settings'; + +export class SettingsFileWatcher extends Disposable { + constructor() { + super(); -export function initSettingsFilesWatcher(): { dispose: () => void } { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) return { dispose: () => null }; + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) return; - const workspaceFolder = workspaceFolders[0]; + const workspaceFolder = workspaceFolders[0]; - logger.info('Initializing settings file watcher'); + logger.info('Initializing settings file watcher'); - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, '.vscode/settings.shared.json'), - ); + const watcher = vscode.workspace.createFileSystemWatcher( + new RelativePattern(workspaceFolder, '.vscode/settings.shared.json'), + ); - watcher.onDidChange(async () => { - logger.info('[onDidChange] triggered from settings file watcher'); - syncSettingsSharedToProject(await getSettingsData()); - }); + watcher.onDidChange(async () => { + logger.info('[onDidChange] triggered from settings file watcher'); + syncSettingsSharedToProject(await getSettingsData()); + }); - watcher.onDidCreate(async () => { - logger.info('[onDidCreate] triggered from settings file watcher'); - syncSettingsSharedToProject(await getSettingsData()); - }); + watcher.onDidCreate(async () => { + logger.info('[onDidCreate] triggered from settings file watcher'); + syncSettingsSharedToProject(await getSettingsData()); + }); - watcher.onDidDelete(async () => { - logger.info('[onDidDelete] triggered from settings file watcher'); - syncSettingsSharedToProject(await getSettingsData()); - }); + watcher.onDidDelete(async () => { + logger.info('[onDidDelete] triggered from settings file watcher'); + syncSettingsSharedToProject(await getSettingsData()); + }); - return { - dispose: () => watcher.dispose(), - }; + this.disposables.push(watcher); + } } -async function handleClangdFileEvent() { +async function handleClangdFileEvent( + settingsFileWriter: (target: string) => Promise, +) { const target = settings.codeAnalysisTarget(); if (target) { - await writeClangdSettingsFile(target); + await settingsFileWriter(target); } } -export function initClangdFileWatcher(): { dispose: () => void } { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) return { dispose: () => null }; +export class ClangdFileWatcher extends Disposable { + constructor(clangdActiveFilesCache: ClangdActiveFilesCache) { + super(); + + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) return; - const workspaceFolder = workspaceFolders[0]; + const workspaceFolder = workspaceFolders[0]; - logger.info('Initializing clangd file watcher'); + logger.info('Initializing clangd file watcher'); - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceFolder, '.clangd.shared'), - ); + const watcher = vscode.workspace.createFileSystemWatcher( + new RelativePattern(workspaceFolder, '.clangd.shared'), + ); - watcher.onDidChange(async () => { - logger.info('[onDidChange] triggered from clangd file watcher'); - await handleClangdFileEvent(); - }); + watcher.onDidChange(async () => { + logger.info('[onDidChange] triggered from clangd file watcher'); + await handleClangdFileEvent(clangdActiveFilesCache.writeToSettings); + }); - watcher.onDidCreate(async () => { - logger.info('[onDidCreate] triggered from clangd file watcher'); - await handleClangdFileEvent(); - }); + watcher.onDidCreate(async () => { + logger.info('[onDidCreate] triggered from clangd file watcher'); + await handleClangdFileEvent(clangdActiveFilesCache.writeToSettings); + }); - watcher.onDidDelete(async () => { - logger.info('[onDidDelete] triggered from clangd file watcher'); - await handleClangdFileEvent(); - }); + watcher.onDidDelete(async () => { + logger.info('[onDidDelete] triggered from clangd file watcher'); + await handleClangdFileEvent(clangdActiveFilesCache.writeToSettings); + }); - return { - dispose: () => watcher.dispose(), - }; + this.disposables.push(watcher); + } } diff --git a/pw_ide/ts/pigweed-vscode/src/statusBar.ts b/pw_ide/ts/pigweed-vscode/src/statusBar.ts index dc0301c95f..3835b2dcb2 100644 --- a/pw_ide/ts/pigweed-vscode/src/statusBar.ts +++ b/pw_ide/ts/pigweed-vscode/src/statusBar.ts @@ -13,85 +13,137 @@ // the License. import * as vscode from 'vscode'; +import { StatusBarItem } from 'vscode'; -import { OK, refreshManager } from './refreshManager'; -import { settings } from './settings'; +import { Disposable } from './disposables'; -const targetStatusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 100, -); +import { + didChangeClangdConfig, + didChangeRefreshStatus, + didChangeTarget, +} from './events'; -export function getTargetStatusBarItem(): vscode.StatusBarItem { - targetStatusBarItem.command = 'pigweed.select-target'; - updateTargetStatusBarItem(); - targetStatusBarItem.show(); - return targetStatusBarItem; -} +import { RefreshStatus } from './refreshManager'; +import { settings } from './settings'; -export function updateTargetStatusBarItem(target?: string) { - const status = refreshManager.state; +const DEFAULT_TARGET_TEXT = 'Select a Target'; +const ICON_IDLE = '$(check)'; +const ICON_FAULT = '$(warning)'; +const ICON_INPROGRESS = '$(sync~spin)'; - const targetText = - target ?? settings.codeAnalysisTarget() ?? 'Select a Target'; +export class TargetStatusBarItem extends Disposable { + private statusBarItem: StatusBarItem; + private targetText = DEFAULT_TARGET_TEXT; + private icon = ICON_IDLE; - const text = (icon: string) => `${icon} ${targetText}`; + constructor() { + super(); - switch (status) { - case 'idle': - targetStatusBarItem.tooltip = 'Click to select a code analysis target'; - targetStatusBarItem.text = text('$(check)'); - targetStatusBarItem.command = 'pigweed.select-target'; - break; - case 'fault': - targetStatusBarItem.tooltip = 'An error occurred! Click to try again'; - targetStatusBarItem.text = text('$(warning)'); + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 100, + ); - targetStatusBarItem.command = - 'pigweed.refresh-compile-commands-and-set-target'; + // Seed the initial state, then make it visible + this.updateTarget(); + this.updateRefreshStatus(); + this.statusBarItem.show(); - break; - default: - targetStatusBarItem.tooltip = - 'Refreshing compile commands. Click to open the output panel'; + // Subscribe to relevant events + didChangeTarget.event(this.updateTarget); + didChangeRefreshStatus.event(this.updateRefreshStatus); - targetStatusBarItem.text = text('$(sync~spin)'); - targetStatusBarItem.command = 'pigweed.open-output-panel'; - break; + // Dispose this when the extension is deactivated + this.disposables.push(this.statusBarItem); } - return OK; + label = () => `${this.icon} ${this.targetText}`; + + updateProps = ( + props: Partial<{ command: string; icon: string; tooltip: string }> = {}, + ): void => { + this.icon = props.icon ?? this.icon; + this.statusBarItem.command = props.command ?? this.statusBarItem.command; + this.statusBarItem.tooltip = props.tooltip ?? this.statusBarItem.tooltip; + this.statusBarItem.text = this.label(); + }; + + updateTarget = (target?: string): void => { + this.targetText = + target ?? settings.codeAnalysisTarget() ?? DEFAULT_TARGET_TEXT; + + this.updateProps(); + }; + + updateRefreshStatus = (status: RefreshStatus = 'idle'): void => { + switch (status) { + case 'idle': + this.updateProps({ + command: 'pigweed.select-target', + icon: ICON_IDLE, + tooltip: 'Click to select a code analysis target', + }); + break; + case 'fault': + this.updateProps({ + command: 'pigweed.refresh-compile-commands-and-set-target', + icon: ICON_FAULT, + tooltip: 'An error occurred! Click to try again', + }); + break; + default: + this.updateProps({ + command: 'pigweed.open-output-panel', + icon: ICON_INPROGRESS, + tooltip: + 'Refreshing compile commands. Click to open the output panel', + }); + break; + } + }; } -const inactiveVisibilityStatusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 99, -); - -export function getInactiveVisibilityStatusBarItem(): vscode.StatusBarItem { - updateInactiveVisibilityStatusBarItem(); - inactiveVisibilityStatusBarItem.show(); - return inactiveVisibilityStatusBarItem; -} +export class InactiveVisibilityStatusBarItem extends Disposable { + private statusBarItem: StatusBarItem; -export function updateInactiveVisibilityStatusBarItem() { - if (settings.disableInactiveFileCodeIntelligence()) { - inactiveVisibilityStatusBarItem.tooltip = - 'Code intelligence is disabled for files not in current ' + - "target's build. Click to enable."; + constructor() { + super(); - inactiveVisibilityStatusBarItem.text = '$(eye-closed)'; + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 99, + ); - inactiveVisibilityStatusBarItem.command = - 'pigweed.enable-inactive-file-code-intelligence'; - } else { - inactiveVisibilityStatusBarItem.tooltip = - 'Code intelligence is enabled for all files.' + - "Click to disable for files not in current target's build."; + // Seed the initial state, then make it visible + this.update(); + this.statusBarItem.show(); - inactiveVisibilityStatusBarItem.text = '$(eye)'; + // Update state on clangd config change events + didChangeClangdConfig.event(this.update); - inactiveVisibilityStatusBarItem.command = - 'pigweed.disable-inactive-file-code-intelligence'; + // Dispose this when the extension is deactivated + this.disposables.push(this.statusBarItem); } + + update = (): void => { + if (settings.disableInactiveFileCodeIntelligence()) { + this.statusBarItem.text = '$(eye-closed)'; + + this.statusBarItem.tooltip = + 'Code intelligence is disabled for files not in current ' + + "target's build. Click to enable."; + + this.statusBarItem.command = + 'pigweed.enable-inactive-file-code-intelligence'; + } else { + this.statusBarItem.text = '$(eye)'; + + this.statusBarItem.tooltip = + 'Code intelligence is enabled for all files.' + + "Click to disable for files not in current target's build."; + + this.statusBarItem.command = + 'pigweed.disable-inactive-file-code-intelligence'; + } + }; } diff --git a/pw_ide/ts/pigweed-vscode/src/terminal.ts b/pw_ide/ts/pigweed-vscode/src/terminal.ts index 0684b0eb6b..91e0e8dc7e 100644 --- a/pw_ide/ts/pigweed-vscode/src/terminal.ts +++ b/pw_ide/ts/pigweed-vscode/src/terminal.ts @@ -12,20 +12,20 @@ // License for the specific language governing permissions and limitations under // the License. -import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as process from 'process'; // Convert `exec` from callback style to promise style. import { exec as cbExec } from 'child_process'; import util from 'node:util'; const exec = util.promisify(cbExec); -import { symlink } from 'fs'; -import { platform } from 'process'; -import { basename, dirname, join } from 'path'; +import * as vscode from 'vscode'; +import { vendoredBazeliskPath } from './bazel'; import logger from './logging'; import { bazel_executable, settings } from './settings'; -import { vendoredBazeliskPath } from './bazel'; type InitScript = 'activate' | 'bootstrap'; @@ -88,7 +88,7 @@ async function getShellTypeFromTerminal( let pidPos: number; let namePos: number; - switch (platform) { + switch (process.platform) { case 'linux': { cmd = `ps -A`; pidPos = 1; @@ -102,7 +102,7 @@ async function getShellTypeFromTerminal( break; } default: { - logger.error(`Platform not currently supported: ${platform}`); + logger.error(`Platform not currently supported: ${process.platform}`); return; } } @@ -124,7 +124,7 @@ async function getShellTypeFromTerminal( return; } - return basename(shellProcessName); + return path.basename(shellProcessName); } /** Prepend the path to Bazelisk into the active terminal's path. */ @@ -143,11 +143,11 @@ export async function patchBazeliskIntoTerminalPath(): Promise { // to just run `bazelisk` in the terminal. So while this is not entirely // ideal, we just create a symlink in the same directory if the binary name // isn't plain `bazelisk`. - if (basename(bazeliskPath) !== 'bazelisk') { + if (path.basename(bazeliskPath) !== 'bazelisk') { try { - symlink( + fs.symlink( bazeliskPath, - join(dirname(bazeliskPath), 'bazelisk'), + path.join(path.dirname(bazeliskPath), 'bazelisk'), (error) => { const message = error ? `${error.errno} ${error.message}` @@ -183,11 +183,11 @@ export async function patchBazeliskIntoTerminalPath(): Promise { switch (shellType) { case 'bash': case 'zsh': { - cmd = `export PATH="${dirname(bazeliskPath)}:$\{PATH}"`; + cmd = `export PATH="${path.dirname(bazeliskPath)}:$\{PATH}"`; break; } case 'fish': { - cmd = `set -x --prepend PATH "${dirname(bazeliskPath)}"`; + cmd = `set -x --prepend PATH "${path.dirname(bazeliskPath)}"`; break; } default: {