diff --git a/package.json b/package.json index edc16a5..e758e05 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[\\/]/ && !explorerResourceIsFolder" + } + ], + "commandPalette": [ + { + "command": "npmx.openFileInNpmx", + "when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/" + } + ] + } }, "scripts": { "dev": "tsdown --watch", @@ -120,6 +151,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/src/commands/open-file-in-npmx.ts b/src/commands/open-file-in-npmx.ts new file mode 100644 index 0000000..8d5b568 --- /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 05bf8d2..164107c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { } from '#constants' 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' @@ -69,5 +70,6 @@ export const { activate, deactivate } = defineExtension(() => { [commands.openInBrowser]: () => { env.openExternal(Uri.parse(NPMX_DEV)) }, + [commands.openFileInNpmx]: openFileInNpmx, }) }) 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}` } diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts new file mode 100644 index 0000000..3293b03 --- /dev/null +++ b/src/utils/resolve.ts @@ -0,0 +1,87 @@ +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<{ manifest: PackageManifest, relativePath: string } | undefined> { + const result = await findPackageJson(uri) + if (!result) + return undefined + + const { uri: pkgUri, manifest } = result + const relativePath = uri.path.slice(pkgUri.path.lastIndexOf('/') + 1) + + return { manifest, 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: 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, '..') + + 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 { + uri: pkgUri, + manifest: pkg, + } + } + } + + return undefined +} + +function* walkAncestors(start: Uri): Generator { + let currentUri = start + while (true) { + yield currentUri + + if (currentUri.path.endsWith('/node_modules')) + return + + const parentUri = Uri.joinPath(currentUri, '..') + if (parentUri.toString() === currentUri.toString()) + return + + currentUri = parentUri + } +} + +/** + * 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/__mocks__/filesystem.ts b/tests/__mocks__/filesystem.ts new file mode 100644 index 0000000..9418724 --- /dev/null +++ b/tests/__mocks__/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) + }) +} 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/tests/filesystem.test.ts b/tests/filesystem.test.ts new file mode 100644 index 0000000..323fb77 --- /dev/null +++ b/tests/filesystem.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Uri, workspace } from 'vscode' +import { mockFileSystem } from './__mocks__/filesystem' + +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/resolve.test.ts b/tests/resolve.test.ts new file mode 100644 index 0000000..3bbc380 --- /dev/null +++ b/tests/resolve.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { Uri } from 'vscode' +import { resolvePackageRelativePath } from '../src/utils/resolve' +import { mockFileSystem } from './__mocks__/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 { manifest, relativePath } = result! + expect(manifest).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 { manifest, relativePath } = result! + expect(manifest).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 { manifest, relativePath } = result! + expect(manifest).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 { manifest, relativePath } = result! + expect(manifest).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 { manifest, relativePath } = result! + expect(manifest).toEqual({ name: 'pkg', version: '1.0.0' }) + expect(relativePath).toBe('src/index.js') + }) +}) 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'], }, })