From f70a04c8ade7d44394b6db935fc01a3b2ee93646 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Sat, 7 Feb 2026 18:26:31 -0800 Subject: [PATCH 01/11] test: setup `jest-mock-vscode` This allows testing code which imports `vscode`, particularly including infrastructure for manipulating `Uri` objects. --- package.json | 1 + pnpm-lock.yaml | 19 +++++++++++++++++++ tests/__mocks__/vscode.ts | 17 +++++++++++++++++ vitest.config.ts | 3 ++- 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/__mocks__/vscode.ts diff --git a/package.json b/package.json index edc16a5..e1a437c 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "eslint": "^9.39.2", "fast-npm-meta": "^1.2.0", "husky": "^9.1.7", + "jest-mock-vscode": "^4.10.0", "jsonc-parser": "^3.3.1", "module-replacements": "^2.11.0", "nano-staged": "^0.9.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d50a13c..829c964 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + jest-mock-vscode: + specifier: ^4.10.0 + version: 4.10.0(@types/vscode@1.101.0) jsonc-parser: specifier: ^3.3.1 version: 3.3.1 @@ -1999,6 +2002,12 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + jest-mock-vscode@4.10.0: + resolution: {integrity: sha512-NwhEGYjvhCY6EEXWgCmtszb1AVyl1vWyndb9t/pyyS923B0uLPW6DD5ITJyaX5ExvGdNKUO6wMSkD9nTQoc1Rg==} + engines: {node: '>22.0.0'} + peerDependencies: + '@types/vscode': ^1.90.0 + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3065,6 +3074,9 @@ packages: resolution: {integrity: sha512-Urn3N9b+lCzz0ZU9S+CFU9YT39NfAhdxdJKIxy82Qh3rOJ2KepKxZXHdek3yMqB20AAbosddFcAPMdTH3ZD0rA==} hasBin: true + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-eslint-parser@10.2.0: resolution: {integrity: sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5148,6 +5160,11 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + jest-mock-vscode@4.10.0(@types/vscode@1.101.0): + dependencies: + '@types/vscode': 1.101.0 + vscode-uri: 3.1.0 + jiti@2.6.1: optional: true @@ -6416,6 +6433,8 @@ snapshots: dependencies: cac: 6.7.14 + vscode-uri@3.1.0: {} + vue-eslint-parser@10.2.0(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 4.4.3 diff --git a/tests/__mocks__/vscode.ts b/tests/__mocks__/vscode.ts new file mode 100644 index 0000000..4049317 --- /dev/null +++ b/tests/__mocks__/vscode.ts @@ -0,0 +1,17 @@ +import { createVSCodeMock } from 'jest-mock-vscode' +import { vi } from 'vitest' + +const vscode = createVSCodeMock(vi) + +export const Uri = vscode.Uri +export const workspace = vscode.workspace +export const Range = vscode.Range +export const Position = vscode.Position +export const Location = vscode.Location +export const Selection = vscode.Selection +export const ThemeColor = vscode.ThemeColor +export const ThemeIcon = vscode.ThemeIcon +export const TreeItem = vscode.TreeItem +export const TreeItemCollapsibleState = vscode.TreeItemCollapsibleState +export const Disposable = vscode.Disposable +export default vscode diff --git a/vitest.config.ts b/vitest.config.ts index 40a8d4f..dd2fd01 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,7 +11,8 @@ export default defineConfig({ '#state': join(rootDir, '/src/state.ts'), '#types/*': join(rootDir, '/src/types/*'), '#utils/*': join(rootDir, '/src/utils/*'), + 'vscode': join(rootDir, '/tests/__mocks__/vscode.ts'), }, - include: ['tests/**/*.ts'], + include: ['tests/**/*.test.ts'], }, }) From ceeac5880dc80f73a09835c71df28b3d7d341d33 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Sat, 7 Feb 2026 18:26:34 -0800 Subject: [PATCH 02/11] test: add filesystem mocking utility This is a generic utility for creating a fake filesystem used by `workspace.fs`. Only `readFile` is implemented for now as that's all that's immediately needed, but other functions could be mocked just as easily when they become necessary. --- tests/filesystem.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++ tests/filesystem.ts | 27 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/filesystem.test.ts create mode 100644 tests/filesystem.ts diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts new file mode 100644 index 0000000..35b8163 --- /dev/null +++ b/tests/filesystem.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { mockFileSystem } from './filesystem' +import { Uri, workspace } from 'vscode' + +describe('mockFileSystem', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + describe('`readFile`', () => { + it('should mock matched paths', async () => { + mockFileSystem({ + '/test/file.txt': 'hello world' + }) + + const uri = Uri.file('/test/file.txt') + const content = await workspace.fs.readFile(uri) + + expect(new TextDecoder().decode(content)).toBe('hello world') + }) + + it('should throw error for unmatched paths', async () => { + mockFileSystem({}) + + const uri = Uri.file('/does-not-exist.txt') + await expect(workspace.fs.readFile(uri)).rejects.toThrow('File not found') + }) + + it('should handle multiple files', async () => { + mockFileSystem({ + '/a.js': 'content a', + '/b.js': 'content b' + }) + + const contentA = await workspace.fs.readFile(Uri.file('/a.js')) + const contentB = await workspace.fs.readFile(Uri.file('/b.js')) + + expect(new TextDecoder().decode(contentA)).toBe('content a') + expect(new TextDecoder().decode(contentB)).toBe('content b') + }) + }) +}) diff --git a/tests/filesystem.ts b/tests/filesystem.ts new file mode 100644 index 0000000..9418724 --- /dev/null +++ b/tests/filesystem.ts @@ -0,0 +1,27 @@ +import { expect, vi } from 'vitest' +import { workspace } from 'vscode' + +/** + * Mocks the VS Code filesystem by intercepting {@link workspace.fs}. + * + * @param files A record mapping file paths to their string content. + */ +export function mockFileSystem(files: Record) { + // Make all functions throw by default. + for (const [name, fn] of Object.entries(workspace.fs)) { + if (typeof fn === 'function') { + vi.mocked(fn).mockImplementation(() => { + expect.fail(`\`workspace.fs.${name}\` is not supported as a fake.`) + }) + } + } + + vi.mocked(workspace.fs.readFile).mockImplementation(async (uri) => { + const path = uri.path + const content = files[path] + if (content === undefined) { + throw new Error(`File not found: ${uri.toString()}`) + } + return new TextEncoder().encode(content) + }) +} From c85c8a5c661428e50610ec0a2b5bc46b4180b2ab Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Sat, 7 Feb 2026 18:26:37 -0800 Subject: [PATCH 03/11] refactor: resolve files within their local package This adds a new `resolvePackageRelativePath` function which takes an arbitrary file within `node_modules/` and returns its package-relative path. This is useful for generating links to specific files in npmx.dev. Given a file path, the logic for finding its associated `package.json` is the following: * Walk up its ancestor directories. * Check each for a `package.json` file. * If the file exists, and has a `name` and `version` field, use that path. * The motivation for checking `name` and `version` is to ignore "fake" `package.json` files used for configuring specific directories, such as Webpack's [`sideEffects: false`](https://webpack.js.org/guides/tree-shaking/) flag. * Calculate the relative path from the `package.json` to the original file path. For this module, we assume the file path is within a `node_modules/` directory, though this will be actually enforced earlier in the process. --- src/utils/resolve.ts | 80 ++++++++++++++++++++++++++ tests/resolve.test.ts | 130 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 src/utils/resolve.ts create mode 100644 tests/resolve.test.ts diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts new file mode 100644 index 0000000..8486761 --- /dev/null +++ b/src/utils/resolve.ts @@ -0,0 +1,80 @@ +import { Uri, workspace } from 'vscode' + +/** + * Resolves the relative path of a file within its package. + * + * @param uri The URI of the file to resolve. + * @returns A promise that resolves to the package manifest and relative path, + * or `undefined` if not found. + */ +export async function resolvePackageRelativePath(uri: Uri): + Promise<[PackageManifest, string] | undefined> { + const result = await findPackageJson(uri) + if (!result) return undefined + + const [pkgUri, pkg] = result + const relativePath = uri.path.slice(pkgUri.path.lastIndexOf('/') + 1) + + return [pkg, relativePath] +} + +/** A parsed `package.json` manifest file. */ +interface PackageManifest { + /** Package name. */ + name: string + + /** Package version specifier. */ + version: string +} + +/** + * Finds the nearest package.json file by searching upwards from the given URI. + * + * @param file The URI to start the search from. + * @returns The URI and parsed content of the package.json, or `undefined` if + * not found. + */ +async function findPackageJson(file: Uri): + Promise<[Uri, PackageManifest] | undefined> { + // Start from the directory, so we don't look for + // `node_modules/foo/bar.js/package.json` + const startDir = Uri.joinPath(file, '..') + + for (const dir of walkAncestors(startDir)) { + const pkgUri = Uri.joinPath(dir, 'package.json') + + let pkg: Partial | undefined + try { + const content = await workspace.fs.readFile(pkgUri) + pkg = JSON.parse(new TextDecoder().decode(content)) as Partial + } catch { + continue + } + + if (isValidManifest(pkg)) return [pkgUri, pkg] + } + + return undefined +} + +function* walkAncestors(start: Uri): Generator { + let currentUri = start + while (true) { + yield currentUri + + const parentUri = Uri.joinPath(currentUri, '..') + if (parentUri.toString() === currentUri.toString()) return + + currentUri = parentUri + if (currentUri.path.endsWith('/node_modules')) return + } +} + +/** + * Check for valid package manifest, as it might be a manifest which just + * configures a setting without really being a package (such as + * `{sideEffects: false}`). + */ +function isValidManifest(json: Partial): json is PackageManifest { + return Boolean(json && json.name && json.version) +} diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts new file mode 100644 index 0000000..7490b6e --- /dev/null +++ b/tests/resolve.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Uri } from 'vscode' +import { resolvePackageRelativePath } from '../src/utils/resolve' +import { mockFileSystem } from './filesystem' + +describe('resolvePackageRelativePath', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('should resolve simple package file', async () => { + mockFileSystem({ + '/root/node_modules/pkg/package.json': JSON.stringify({ + name: 'pkg', + version: '1.0.0', + }), + }) + + const uri = Uri.file('/root/node_modules/pkg/src/index.js') + const result = await resolvePackageRelativePath(uri) + + expect(result).toBeDefined() + + const [pkg, relativePath] = result! + expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + expect(relativePath).toBe('src/index.js') + }) + + it('should handle bundled dependencies', async () => { + mockFileSystem({ + '/root/node_modules/parent/package.json': JSON.stringify({ + name: 'parent', + version: '1.0.0', + }), + '/root/node_modules/parent/node_modules/child/package.json': JSON.stringify({ + name: 'child', + version: '2.0.0', + }), + }) + + const uri = Uri.file('/root/node_modules/parent/node_modules/child/index.js') + const result = await resolvePackageRelativePath(uri) + + expect(result).toBeDefined() + + const [pkg, relativePath] = result! + expect(pkg).toEqual({ name: 'child', version: '2.0.0' }) + expect(relativePath).toBe('index.js') + }) + + it('should handle pnpm structure', async () => { + mockFileSystem({ + '/root/.pnpm/pkg@1.0.0/node_modules/pkg/package.json': JSON.stringify({ + name: 'pkg', + version: '1.0.0', + }), + }) + + const uri = Uri.file('/root/.pnpm/pkg@1.0.0/node_modules/pkg/src/index.js') + const result = await resolvePackageRelativePath(uri) + + expect(result).toBeDefined() + + const [pkg, relativePath] = result! + expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + expect(relativePath).toBe('src/index.js') + }) + + it('should handle scoped packages', async () => { + mockFileSystem({ + '/root/node_modules/@scope/pkg/package.json': JSON.stringify({ + name: '@scope/pkg', + version: '1.0.0', + }), + }) + + const uri = Uri.file('/root/node_modules/@scope/pkg/src/index.js') + const result = await resolvePackageRelativePath(uri) + + expect(result).toBeDefined() + + const [pkg, relativePath] = result! + expect(pkg).toEqual({ name: '@scope/pkg', version: '1.0.0' }) + expect(relativePath).toBe('src/index.js') + }) + + it('should return undefined if no package.json found', async () => { + mockFileSystem({}) + + const uri = Uri.file('/root/no-pkg/file.js') + const result = await resolvePackageRelativePath(uri) + expect(result).toBeUndefined() + }) + + it('should return undefined even when a package.json exists outside the node_modules directory', async () => { + mockFileSystem({ + '/root/package.json': JSON.stringify({ + name: 'root', + version: '1.0.0', + }), + }) + + const uri = Uri.file('/root/node_modules/pkg/file.js') + const result = await resolvePackageRelativePath(uri) + expect(result).toBeUndefined() + }) + + it('should skip invalid manifests', async () => { + mockFileSystem({ + '/root/node_modules/pkg/package.json': JSON.stringify({ + name: 'pkg', + version: '1.0.0', + }), + + // Context: Side effects is often configured per-directory as the only key + // in a `package.json`, but it does not actually represent a real package. + '/root/node_modules/pkg/src/package.json': JSON.stringify({ + sideEffects: false, + }), + }) + + const uri = Uri.file('/root/node_modules/pkg/src/index.js') + const result = await resolvePackageRelativePath(uri) + expect(result).toBeDefined() + + const [pkg, relativePath] = result! + expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + expect(relativePath).toBe('src/index.js') + }) +}) From 70e20e7b6ca436e2dd330f823ddefc21b34ccd73 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Sat, 7 Feb 2026 18:26:39 -0800 Subject: [PATCH 04/11] feat: add `openFileInNpmx` command This command opens a given file under `node_modules/` in npmx.dev, deep linking to its specific package version, relative file path, and line number. The command is only shown when in a `node_modules/` context and only includes a line number when opening the currently active file. There are some edge cases for opening files like: * `node_modules/.bin/foo` * `node_modules/foo` * `node_modules/@foo/bar` * `node_modules/.pnpm` Ideally these wouldn't even support the command through the `when` filter, but that regex starts to get complicated, so I didn't bite off that complexity here. A potential future feature might be "npmx: Open package" and work specifically on `node_modules/foo` and `node_modules/@foo/bar` directories, opening the package itself rather than deep-linking to any specific file. But such a command is out of scope for now. --- package.json | 33 ++++++++++++++++++++++++++++++++- src/index.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- src/utils/links.ts | 4 ++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e1a437c..58d2cf2 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,39 @@ "command": "npmx.openInBrowser", "title": "Open npmx.dev in external browser", "category": "npmx" + }, + { + "command": "npmx.openFileInNpmx", + "title": "Open file on npmx.dev", + "category": "npmx" } - ] + ], + "menus": { + "editor/context": [ + { + "command": "npmx.openFileInNpmx", + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + } + ], + "editor/title": [ + { + "command": "npmx.openFileInNpmx", + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + } + ], + "explorer/context": [ + { + "command": "npmx.openFileInNpmx", + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + } + ], + "commandPalette": [ + { + "command": "npmx.openFileInNpmx", + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + } + ] + } }, "scripts": { "dev": "tsdown --watch", diff --git a/src/index.ts b/src/index.ts index 05bf8d2..cfcb59b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,8 @@ import { PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS, } from '#constants' -import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, env, languages, Uri } from 'vscode' +import { defineExtension, useActiveTextEditor, useCommands, watchEffect } from 'reactive-vscode' +import { Disposable, env, languages, Uri, window } from 'vscode' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' @@ -15,6 +15,8 @@ import { VersionCompletionItemProvider } from './providers/completion-item/versi import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' import { config, logger } from './state' +import { npmxFileUrl } from './utils/links' +import { resolvePackageRelativePath } from './utils/resolve' export const { activate, deactivate } = defineExtension(() => { logger.info(`${displayName} Activated, v${version}`) @@ -65,9 +67,47 @@ export const { activate, deactivate } = defineExtension(() => { [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, }) + const textEditor = useActiveTextEditor() useCommands({ [commands.openInBrowser]: () => { env.openExternal(Uri.parse(NPMX_DEV)) }, + + [commands.openFileInNpmx]: async (fileUri?: Uri) => { + // If triggered from context menu, fileUri is provided. + // If triggered from command palette, use active text editor. + const uri = fileUri || textEditor.value?.document.uri + if (!uri) { + window.showErrorMessage('npmx: No active file selected.') + return + } + + // Assert the given file is in `node_modules/`, though the command should + // already be limited to such files. + if (!uri.path.includes('/node_modules/')) { + window.showErrorMessage('npmx: Selected file is not within a node_modules folder.') + return + } + + // Find the associated package manifest and the relative path to the given file. + const result = await resolvePackageRelativePath(uri) + if (!result) { + logger.warn(`Could not resolve npmx url: ${uri.toString()}`) + window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`) + return + } + const [manifest, relativePath] = result + + // Use line number only if the user is actively looking at the relevant file + const openingActiveFile = !fileUri || fileUri.toString() === textEditor.value?.document.uri.toString() + + // VSCode uses 0-indexed lines, npmx uses 1-indexed lines + const vsCodeLine = openingActiveFile ? textEditor.value?.selection.active.line : undefined + const npmxLine = vsCodeLine !== undefined ? vsCodeLine + 1 : undefined + + // Construct the npmx.dev URL and open it. + const url = npmxFileUrl(manifest.name, manifest.version, relativePath, npmxLine) + await env.openExternal(Uri.parse(url)) + }, }) }) diff --git a/src/utils/links.ts b/src/utils/links.ts index b845b27..3e38537 100644 --- a/src/utils/links.ts +++ b/src/utils/links.ts @@ -10,6 +10,10 @@ export function npmxDocsUrl(name: string, version: string): string { return `${NPMX_DEV}/docs/${name}/v/${version}` } +export function npmxFileUrl(name: string, version: string, path: string, line?: number): string { + return `${NPMX_DEV}/package-code/${name}/v/${version}/${path}${line !== undefined ? `#L${line}` : ''}` +} + export function jsrPackageUrl(name: string, version: string): string { return `https://jsr.io/${name}@${version}` } From a0e56a448e900de50a69f9d80f3575b7f91a494a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:28:45 +0000 Subject: [PATCH 05/11] [autofix.ci] apply automated fixes --- src/utils/resolve.ts | 16 ++++++++++------ tests/filesystem.test.ts | 8 ++++---- tests/resolve.test.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 8486761..3bcaba2 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -8,9 +8,10 @@ import { Uri, workspace } from 'vscode' * or `undefined` if not found. */ export async function resolvePackageRelativePath(uri: Uri): - Promise<[PackageManifest, string] | undefined> { +Promise<[PackageManifest, string] | undefined> { const result = await findPackageJson(uri) - if (!result) return undefined + if (!result) + return undefined const [pkgUri, pkg] = result const relativePath = uri.path.slice(pkgUri.path.lastIndexOf('/') + 1) @@ -35,7 +36,7 @@ interface PackageManifest { * not found. */ async function findPackageJson(file: Uri): - Promise<[Uri, PackageManifest] | undefined> { +Promise<[Uri, PackageManifest] | undefined> { // Start from the directory, so we don't look for // `node_modules/foo/bar.js/package.json` const startDir = Uri.joinPath(file, '..') @@ -51,7 +52,8 @@ async function findPackageJson(file: Uri): continue } - if (isValidManifest(pkg)) return [pkgUri, pkg] + if (isValidManifest(pkg)) + return [pkgUri, pkg] } return undefined @@ -63,10 +65,12 @@ function* walkAncestors(start: Uri): Generator { yield currentUri const parentUri = Uri.joinPath(currentUri, '..') - if (parentUri.toString() === currentUri.toString()) return + if (parentUri.toString() === currentUri.toString()) + return currentUri = parentUri - if (currentUri.path.endsWith('/node_modules')) return + if (currentUri.path.endsWith('/node_modules')) + return } } diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts index 35b8163..0fe9ccc 100644 --- a/tests/filesystem.test.ts +++ b/tests/filesystem.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' -import { mockFileSystem } from './filesystem' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' +import { mockFileSystem } from './filesystem' describe('mockFileSystem', () => { beforeEach(() => { @@ -10,7 +10,7 @@ describe('mockFileSystem', () => { describe('`readFile`', () => { it('should mock matched paths', async () => { mockFileSystem({ - '/test/file.txt': 'hello world' + '/test/file.txt': 'hello world', }) const uri = Uri.file('/test/file.txt') @@ -29,7 +29,7 @@ describe('mockFileSystem', () => { it('should handle multiple files', async () => { mockFileSystem({ '/a.js': 'content a', - '/b.js': 'content b' + '/b.js': 'content b', }) const contentA = await workspace.fs.readFile(Uri.file('/a.js')) diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index 7490b6e..3af3dd2 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { Uri } from 'vscode' import { resolvePackageRelativePath } from '../src/utils/resolve' import { mockFileSystem } from './filesystem' From 24d8d9d26a2391040b7a5fd0c70ee475ad155506 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 21:38:29 +0800 Subject: [PATCH 06/11] fix: improve `node_modules` check --- src/utils/resolve.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 3bcaba2..3302f03 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -7,8 +7,7 @@ import { Uri, workspace } from 'vscode' * @returns A promise that resolves to the package manifest and relative path, * or `undefined` if not found. */ -export async function resolvePackageRelativePath(uri: Uri): -Promise<[PackageManifest, string] | undefined> { +export async function resolvePackageRelativePath(uri: Uri): Promise<[PackageManifest, string] | undefined> { const result = await findPackageJson(uri) if (!result) return undefined @@ -35,8 +34,7 @@ interface PackageManifest { * @returns The URI and parsed content of the package.json, or `undefined` if * not found. */ -async function findPackageJson(file: Uri): -Promise<[Uri, PackageManifest] | undefined> { +async function findPackageJson(file: Uri): Promise<[Uri, PackageManifest] | undefined> { // Start from the directory, so we don't look for // `node_modules/foo/bar.js/package.json` const startDir = Uri.joinPath(file, '..') @@ -64,13 +62,14 @@ function* walkAncestors(start: Uri): Generator { while (true) { yield currentUri + if (currentUri.path.endsWith('/node_modules')) + return + const parentUri = Uri.joinPath(currentUri, '..') if (parentUri.toString() === currentUri.toString()) return currentUri = parentUri - if (currentUri.path.endsWith('/node_modules')) - return } } From 2712a7818f2276a763be7b111c03234a7770e7bd Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 21:45:37 +0800 Subject: [PATCH 07/11] refactor: move command into `commands` --- src/commands/open-file-in-npmx.ts | 44 +++++++++++++++++++++++++++++ src/index.ts | 46 +++---------------------------- 2 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 src/commands/open-file-in-npmx.ts diff --git a/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts new file mode 100644 index 0000000..9c20003 --- /dev/null +++ b/src/commands/open-file-in-npmx.ts @@ -0,0 +1,44 @@ +import { logger } from '#state' +import { npmxFileUrl } from '#utils/links' +import { resolvePackageRelativePath } from '#utils/resolve' +import { useActiveTextEditor } from 'reactive-vscode' +import { env, Uri, window } from 'vscode' + +export async function openFileInNpmx(fileUri: Uri) { + const textEditor = useActiveTextEditor() + + // If triggered from context menu, fileUri is provided. + // If triggered from command palette, use active text editor. + const uri = fileUri || textEditor.value?.document.uri + if (!uri) { + window.showErrorMessage('npmx: No active file selected.') + return + } + + // Assert the given file is in `node_modules/`, though the command should + // already be limited to such files. + if (!uri.path.includes('/node_modules/')) { + window.showErrorMessage('npmx: Selected file is not within a node_modules folder.') + return + } + + // Find the associated package manifest and the relative path to the given file. + const result = await resolvePackageRelativePath(uri) + if (!result) { + logger.warn(`Could not resolve npmx url: ${uri.toString()}`) + window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`) + return + } + const [manifest, relativePath] = result + + // Use line number only if the user is actively looking at the relevant file + const openingActiveFile = !fileUri || fileUri.toString() === textEditor.value?.document.uri.toString() + + // VSCode uses 0-indexed lines, npmx uses 1-indexed lines + const vsCodeLine = openingActiveFile ? textEditor.value?.selection.active.line : undefined + const npmxLine = vsCodeLine !== undefined ? vsCodeLine + 1 : undefined + + // Construct the npmx.dev URL and open it. + const url = npmxFileUrl(manifest.name, manifest.version, relativePath, npmxLine) + await env.openExternal(Uri.parse(url)) +} diff --git a/src/index.ts b/src/index.ts index cfcb59b..164107c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,9 @@ import { PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS, } from '#constants' -import { defineExtension, useActiveTextEditor, useCommands, watchEffect } from 'reactive-vscode' -import { Disposable, env, languages, Uri, window } from 'vscode' +import { defineExtension, useCommands, watchEffect } from 'reactive-vscode' +import { Disposable, env, languages, Uri } from 'vscode' +import { openFileInNpmx } from './commands/open-file-in-npmx' import { PackageJsonExtractor } from './extractors/package-json' import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml' import { commands, displayName, version } from './generated-meta' @@ -15,8 +16,6 @@ import { VersionCompletionItemProvider } from './providers/completion-item/versi import { registerDiagnosticCollection } from './providers/diagnostics' import { NpmxHoverProvider } from './providers/hover/npmx' import { config, logger } from './state' -import { npmxFileUrl } from './utils/links' -import { resolvePackageRelativePath } from './utils/resolve' export const { activate, deactivate } = defineExtension(() => { logger.info(`${displayName} Activated, v${version}`) @@ -67,47 +66,10 @@ export const { activate, deactivate } = defineExtension(() => { [PNPM_WORKSPACE_BASENAME]: pnpmWorkspaceYamlExtractor, }) - const textEditor = useActiveTextEditor() useCommands({ [commands.openInBrowser]: () => { env.openExternal(Uri.parse(NPMX_DEV)) }, - - [commands.openFileInNpmx]: async (fileUri?: Uri) => { - // If triggered from context menu, fileUri is provided. - // If triggered from command palette, use active text editor. - const uri = fileUri || textEditor.value?.document.uri - if (!uri) { - window.showErrorMessage('npmx: No active file selected.') - return - } - - // Assert the given file is in `node_modules/`, though the command should - // already be limited to such files. - if (!uri.path.includes('/node_modules/')) { - window.showErrorMessage('npmx: Selected file is not within a node_modules folder.') - return - } - - // Find the associated package manifest and the relative path to the given file. - const result = await resolvePackageRelativePath(uri) - if (!result) { - logger.warn(`Could not resolve npmx url: ${uri.toString()}`) - window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`) - return - } - const [manifest, relativePath] = result - - // Use line number only if the user is actively looking at the relevant file - const openingActiveFile = !fileUri || fileUri.toString() === textEditor.value?.document.uri.toString() - - // VSCode uses 0-indexed lines, npmx uses 1-indexed lines - const vsCodeLine = openingActiveFile ? textEditor.value?.selection.active.line : undefined - const npmxLine = vsCodeLine !== undefined ? vsCodeLine + 1 : undefined - - // Construct the npmx.dev URL and open it. - const url = npmxFileUrl(manifest.name, manifest.version, relativePath, npmxLine) - await env.openExternal(Uri.parse(url)) - }, + [commands.openFileInNpmx]: openFileInNpmx, }) }) From a2fbb8d4904caf38591d74febd910c4365b3f924 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 21:49:09 +0800 Subject: [PATCH 08/11] refactor: prefer return an object instead of tuple --- src/commands/open-file-in-npmx.ts | 2 +- src/utils/resolve.ts | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts index 9c20003..65fbcbe 100644 --- a/src/commands/open-file-in-npmx.ts +++ b/src/commands/open-file-in-npmx.ts @@ -29,7 +29,7 @@ export async function openFileInNpmx(fileUri: Uri) { window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`) return } - const [manifest, relativePath] = result + const { manifest, relativePath } = result // Use line number only if the user is actively looking at the relevant file const openingActiveFile = !fileUri || fileUri.toString() === textEditor.value?.document.uri.toString() diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 3302f03..3293b03 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -7,15 +7,15 @@ import { Uri, workspace } from 'vscode' * @returns A promise that resolves to the package manifest and relative path, * or `undefined` if not found. */ -export async function resolvePackageRelativePath(uri: Uri): Promise<[PackageManifest, string] | undefined> { +export async function resolvePackageRelativePath(uri: Uri): Promise<{ manifest: PackageManifest, relativePath: string } | undefined> { const result = await findPackageJson(uri) if (!result) return undefined - const [pkgUri, pkg] = result + const { uri: pkgUri, manifest } = result const relativePath = uri.path.slice(pkgUri.path.lastIndexOf('/') + 1) - return [pkg, relativePath] + return { manifest, relativePath } } /** A parsed `package.json` manifest file. */ @@ -34,7 +34,7 @@ interface PackageManifest { * @returns The URI and parsed content of the package.json, or `undefined` if * not found. */ -async function findPackageJson(file: Uri): Promise<[Uri, PackageManifest] | undefined> { +async function findPackageJson(file: Uri): Promise<{ uri: Uri, manifest: PackageManifest } | undefined> { // Start from the directory, so we don't look for // `node_modules/foo/bar.js/package.json` const startDir = Uri.joinPath(file, '..') @@ -50,8 +50,12 @@ async function findPackageJson(file: Uri): Promise<[Uri, PackageManifest] | unde continue } - if (isValidManifest(pkg)) - return [pkgUri, pkg] + if (isValidManifest(pkg)) { + return { + uri: pkgUri, + manifest: pkg, + } + } } return undefined From a3911cb7f47c4c51bad57925cf5cb6fc90b85d3b Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 22:10:57 +0800 Subject: [PATCH 09/11] fix(explorer): hide context command on folders --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 58d2cf2..e758e05 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "explorer/context": [ { "command": "npmx.openFileInNpmx", - "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/ && !explorerResourceIsFolder" } ], "commandPalette": [ From 0155bb65ade7b4383091e006eee80f6087c5dcd9 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 22:12:56 +0800 Subject: [PATCH 10/11] test: fix --- tests/resolve.test.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index 3af3dd2..fe3b56a 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -21,8 +21,8 @@ describe('resolvePackageRelativePath', () => { expect(result).toBeDefined() - const [pkg, relativePath] = result! - expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + const { manifest, relativePath } = result! + expect(manifest).toEqual({ name: 'pkg', version: '1.0.0' }) expect(relativePath).toBe('src/index.js') }) @@ -43,8 +43,8 @@ describe('resolvePackageRelativePath', () => { expect(result).toBeDefined() - const [pkg, relativePath] = result! - expect(pkg).toEqual({ name: 'child', version: '2.0.0' }) + const { manifest, relativePath } = result! + expect(manifest).toEqual({ name: 'child', version: '2.0.0' }) expect(relativePath).toBe('index.js') }) @@ -61,8 +61,8 @@ describe('resolvePackageRelativePath', () => { expect(result).toBeDefined() - const [pkg, relativePath] = result! - expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + const { manifest, relativePath } = result! + expect(manifest).toEqual({ name: 'pkg', version: '1.0.0' }) expect(relativePath).toBe('src/index.js') }) @@ -79,8 +79,8 @@ describe('resolvePackageRelativePath', () => { expect(result).toBeDefined() - const [pkg, relativePath] = result! - expect(pkg).toEqual({ name: '@scope/pkg', version: '1.0.0' }) + const { manifest, relativePath } = result! + expect(manifest).toEqual({ name: '@scope/pkg', version: '1.0.0' }) expect(relativePath).toBe('src/index.js') }) @@ -123,8 +123,8 @@ describe('resolvePackageRelativePath', () => { const result = await resolvePackageRelativePath(uri) expect(result).toBeDefined() - const [pkg, relativePath] = result! - expect(pkg).toEqual({ name: 'pkg', version: '1.0.0' }) + const { manifest, relativePath } = result! + expect(manifest).toEqual({ name: 'pkg', version: '1.0.0' }) expect(relativePath).toBe('src/index.js') }) }) From a8db241caeb0ab31f34c2a6f3f956765f7db2be5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Sun, 8 Feb 2026 23:20:41 +0800 Subject: [PATCH 11/11] apply suggestions from coderabbit --- src/commands/open-file-in-npmx.ts | 4 ++-- tests/{ => __mocks__}/filesystem.ts | 0 tests/filesystem.test.ts | 2 +- tests/resolve.test.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename tests/{ => __mocks__}/filesystem.ts (100%) diff --git a/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts index 65fbcbe..8d5b568 100644 --- a/src/commands/open-file-in-npmx.ts +++ b/src/commands/open-file-in-npmx.ts @@ -4,12 +4,12 @@ import { resolvePackageRelativePath } from '#utils/resolve' import { useActiveTextEditor } from 'reactive-vscode' import { env, Uri, window } from 'vscode' -export async function openFileInNpmx(fileUri: Uri) { +export async function openFileInNpmx(fileUri?: Uri) { const textEditor = useActiveTextEditor() // If triggered from context menu, fileUri is provided. // If triggered from command palette, use active text editor. - const uri = fileUri || textEditor.value?.document.uri + const uri = fileUri ?? textEditor.value?.document.uri if (!uri) { window.showErrorMessage('npmx: No active file selected.') return diff --git a/tests/filesystem.ts b/tests/__mocks__/filesystem.ts similarity index 100% rename from tests/filesystem.ts rename to tests/__mocks__/filesystem.ts diff --git a/tests/filesystem.test.ts b/tests/filesystem.test.ts index 0fe9ccc..323fb77 100644 --- a/tests/filesystem.test.ts +++ b/tests/filesystem.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Uri, workspace } from 'vscode' -import { mockFileSystem } from './filesystem' +import { mockFileSystem } from './__mocks__/filesystem' describe('mockFileSystem', () => { beforeEach(() => { diff --git a/tests/resolve.test.ts b/tests/resolve.test.ts index fe3b56a..3bbc380 100644 --- a/tests/resolve.test.ts +++ b/tests/resolve.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Uri } from 'vscode' import { resolvePackageRelativePath } from '../src/utils/resolve' -import { mockFileSystem } from './filesystem' +import { mockFileSystem } from './__mocks__/filesystem' describe('resolvePackageRelativePath', () => { beforeEach(() => {