Skip to content
34 changes: 33 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions src/commands/open-file-in-npmx.ts
Original file line number Diff line number Diff line change
@@ -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))
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -69,5 +70,6 @@ export const { activate, deactivate } = defineExtension(() => {
[commands.openInBrowser]: () => {
env.openExternal(Uri.parse(NPMX_DEV))
},
[commands.openFileInNpmx]: openFileInNpmx,
})
})
4 changes: 4 additions & 0 deletions src/utils/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
87 changes: 87 additions & 0 deletions src/utils/resolve.ts
Original file line number Diff line number Diff line change
@@ -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<PackageManifest> | undefined
try {
const content = await workspace.fs.readFile(pkgUri)
pkg = JSON.parse(new TextDecoder().decode(content)) as Partial<PackageManifest>
} catch {
continue
}

if (isValidManifest(pkg)) {
return {
uri: pkgUri,
manifest: pkg,
}
}
}

return undefined
}

function* walkAncestors(start: Uri): Generator<Uri, void, void> {
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<PackageManifest>): json is PackageManifest {
return Boolean(json && json.name && json.version)
}
27 changes: 27 additions & 0 deletions tests/__mocks__/filesystem.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
// 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)
})
}
17 changes: 17 additions & 0 deletions tests/__mocks__/vscode.ts
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions tests/filesystem.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
Loading