Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ icon.png

# Rust
vscode_docs/target/
package-lock.json
22 changes: 22 additions & 0 deletions client/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<void> {
Expand Down
21 changes: 17 additions & 4 deletions client/findBinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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.
Expand All @@ -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));
}

/**
Expand Down Expand Up @@ -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)
Expand All @@ -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 {}
}

Expand Down
137 changes: 137 additions & 0 deletions client/tools/BinaryWatcher.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
) {
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;
}