diff --git a/.gitignore b/.gitignore index 91f9cfa..fb7027b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ icon.png # Rust vscode_docs/target/ +package-lock.json diff --git a/client/extension.ts b/client/extension.ts index de80690..eaba878 100644 --- a/client/extension.ts +++ b/client/extension.ts @@ -3,6 +3,7 @@ import { commands, ExtensionContext, LogOutputChannel, window, workspace } from import { copyDebugCommand, OxcCommands } from "./commands"; import { ConfigService } from "./ConfigService"; import StatusBarItemHandler from "./StatusBarItemHandler"; +import { BinaryWatcher } from "./tools/BinaryWatcher"; import Formatter from "./tools/formatter"; import Linter from "./tools/linter"; import ToolInterface from "./tools/ToolInterface"; @@ -128,6 +129,27 @@ export async function activate(context: ExtensionContext) { // Finally show the status bar item. statusBarItemHandler.show(); + + // Watch for lock file changes (e.g. after npm/pnpm/yarn/bun install) so that + // tools are restarted automatically without requiring a VS Code restart. + const createBinaryWatcher = ( + toolClass: typeof Linter | typeof Formatter, + binaryName: string, + outputChannel: LogOutputChannel, + ) => { + const tool = tools.find((t) => t instanceof toolClass); + if (tool) { + const binary = binaryPaths[tools.indexOf(tool)]; + context.subscriptions.push( + new BinaryWatcher(binary, binaryName, outputChannel, () => + restartTool(tool, outputChannel), + ), + ); + } + }; + + createBinaryWatcher(Linter, "oxlint", outputChannelLint); + createBinaryWatcher(Formatter, "oxfmt", outputChannelFormat); } export async function deactivate(): Promise { diff --git a/client/findBinary.ts b/client/findBinary.ts index 0df5ce9..4a6c849 100644 --- a/client/findBinary.ts +++ b/client/findBinary.ts @@ -9,6 +9,12 @@ export type BinarySearchResult = { path: string; loader: "node" | "native"; yarnPnpLoaderPath?: string; // only set if loader is 'node' and found via Yarn PnP + /** + * When set, `BinaryWatcher` will watch this specific file instead of + * searching for a lock file. Used for global and settings-specified + * binaries that have no project lock file to watch. + */ + watcherPath?: string; }; /** @internal only used for testing */ @@ -188,6 +194,13 @@ export async function searchYarnPnpBin( return results.find(Boolean); } +/** Attaches `watcherPath: result.path` to an existing result, used for global and settings binaries. */ +function withWatcherPath( + result: BinarySearchResult | undefined, +): BinarySearchResult | undefined { + return result ? { ...result, watcherPath: result.path } : undefined; +} + /** * Search for the binary in global node_modules. * Returns undefined if not found. @@ -202,11 +215,11 @@ export async function searchGlobalNodeModulesBin( require.resolve(binaryName, { paths: globalPaths }), binaryName, ); - return { path: resolvedPath, loader: "node" }; + return withWatcherPath({ path: resolvedPath, loader: "node" }); } catch {} // fallback to direct binary lookup in global node_modules/.bin - return searchNodeModulesDefaultBinPath(binaryName, globalPaths); + return withWatcherPath(await searchNodeModulesDefaultBinPath(binaryName, globalPaths)); } /** @@ -249,7 +262,7 @@ export async function searchSettingsBin( try { await workspace.fs.stat(Uri.file(settingsBinary)); - return { path: settingsBinary, loader: isNode ? "node" : "native" }; + return withWatcherPath({ path: settingsBinary, loader: isNode ? "node" : "native" }); } catch {} // on Windows, also check for `.exe` extension (bun uses `.exe` for its binaries) @@ -260,7 +273,7 @@ export async function searchSettingsBin( try { await workspace.fs.stat(Uri.file(settingsBinary)); - return { path: settingsBinary, loader: "native" }; + return withWatcherPath({ path: settingsBinary, loader: "native" }); } catch {} } diff --git a/client/tools/BinaryWatcher.ts b/client/tools/BinaryWatcher.ts new file mode 100644 index 0000000..ff4229f --- /dev/null +++ b/client/tools/BinaryWatcher.ts @@ -0,0 +1,137 @@ +import { existsSync } from "node:fs"; +import * as path from "node:path"; +import { FileSystemWatcher, LogOutputChannel, RelativePattern, Uri, workspace } from "vscode"; +import { clearWorkspacePackageJsonNodeModulesCache } from "../findBinary"; +import type { BinarySearchResult } from "../findBinary"; + +/** + * Glob pattern matching all known dependency lock files. + */ +const LOCK_FILE_GLOB = "{package-lock.json,yarn.lock,pnpm-lock.yaml,bun.lockb,bun.lock}"; + +/** + * Lock file names in priority order, used when walking up the directory tree. + */ +const LOCK_FILE_NAMES = [ + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lockb", + "bun.lock", +] as const; + +/** + * Watches for dependency changes so that tools are restarted automatically + * after `npm install`, `pnpm install`, `yarn install`, or `bun install` — + * without requiring a VS Code restart. + * + * **Project-local binaries** (no `watcherPath` set on `BinarySearchResult`): + * Walks up from the resolved binary path to find the project root containing a + * lock file and watches that directory. This correctly handles pnpm's versioned + * store layout (`.pnpm/pkg@version/node_modules/…`) where updating a package + * creates a brand-new directory instead of modifying the existing binary. + * Falls back to all workspace folders when no binary is found yet (initial + * install) or no lock file is found on the walk. + * + * **Global / settings-specified binaries** (`watcherPath` set on + * `BinarySearchResult`): Watches that specific file for creation, change, and + * deletion, since global installs have no project lock file. + */ +export class BinaryWatcher { + private readonly watchers: FileSystemWatcher[] = []; + + constructor( + binary: BinarySearchResult | undefined, + binaryName: string, + outputChannel: LogOutputChannel, + onBinaryChanged: () => Promise, + ) { + if (binary?.watcherPath) { + // Global or settings-specified binary: watch the resolved binary file directly. + const watcher = workspace.createFileSystemWatcher( + new RelativePattern( + Uri.file(path.dirname(binary.watcherPath)), + path.basename(binary.watcherPath), + ), + false, + false, + false, + ); + const fileHandler = async () => { + outputChannel.info(`Binary "${binaryName}" changed, restarting tool...`); + clearWorkspacePackageJsonNodeModulesCache(); + await onBinaryChanged(); + }; + watcher.onDidCreate(fileHandler); + watcher.onDidChange(fileHandler); + watcher.onDidDelete(fileHandler); + this.watchers.push(watcher); + return; + } + + // Project-local binary (or no binary found yet): watch lock files. + const watchDirs = resolveLockFileWatchDirectories(binary); + for (const dir of watchDirs) { + const watcher = workspace.createFileSystemWatcher( + new RelativePattern(Uri.file(dir), LOCK_FILE_GLOB), + false, + false, + false, + ); + const lockHandler = async () => { + outputChannel.info(`Dependency lock file changed, restarting "${binaryName}" tool...`); + clearWorkspacePackageJsonNodeModulesCache(); + await onBinaryChanged(); + }; + watcher.onDidCreate(lockHandler); + watcher.onDidChange(lockHandler); + watcher.onDidDelete(lockHandler); + this.watchers.push(watcher); + } + } + + dispose(): void { + for (const watcher of this.watchers) { + watcher.dispose(); + } + } +} + +/** + * Returns the directories to watch for lock file changes. + * + * If a binary was resolved, walks up from its path to find the project root + * (the first ancestor containing a lock file). Falls back to all workspace + * folders when the binary is not found or no lock file exists on the walk. + */ +function resolveLockFileWatchDirectories(binary: BinarySearchResult | undefined): string[] { + const workspaceFolderPaths = (workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); + + if (!binary) { + return workspaceFolderPaths; + } + + const projectRoot = findProjectRoot(binary.path); + return projectRoot ? [projectRoot] : workspaceFolderPaths; +} + +/** + * Walk up from `binaryPath` and return the first ancestor directory that + * contains a known lock file. Returns `undefined` if none is found. + */ +function findProjectRoot(binaryPath: string): string | undefined { + let dir = path.dirname(binaryPath); + const root = path.parse(dir).root; + + while (dir !== root) { + for (const lockFile of LOCK_FILE_NAMES) { + if (existsSync(path.join(dir, lockFile))) { + return dir; + } + } + dir = path.dirname(dir); + } + + return undefined; +} +