Skip to content

Commit d576f06

Browse files
dgp1130autofix-ci[bot]9romise
authored
feat: add openFileInNpmx command for node_modules files (#34)
* test: setup `jest-mock-vscode` This allows testing code which imports `vscode`, particularly including infrastructure for manipulating `Uri` objects. * 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. * 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. * 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. * [autofix.ci] apply automated fixes * fix: improve `node_modules` check * refactor: move command into `commands` * refactor: prefer return an object instead of tuple * fix(explorer): hide context command on folders * test: fix * apply suggestions from coderabbit --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Vida Xie <vida_2020@163.com>
1 parent 55506be commit d576f06

11 files changed

Lines changed: 407 additions & 2 deletions

File tree

package.json

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,39 @@
9191
"command": "npmx.openInBrowser",
9292
"title": "Open npmx.dev in external browser",
9393
"category": "npmx"
94+
},
95+
{
96+
"command": "npmx.openFileInNpmx",
97+
"title": "Open file on npmx.dev",
98+
"category": "npmx"
9499
}
95-
]
100+
],
101+
"menus": {
102+
"editor/context": [
103+
{
104+
"command": "npmx.openFileInNpmx",
105+
"when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/"
106+
}
107+
],
108+
"editor/title": [
109+
{
110+
"command": "npmx.openFileInNpmx",
111+
"when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/"
112+
}
113+
],
114+
"explorer/context": [
115+
{
116+
"command": "npmx.openFileInNpmx",
117+
"when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/ && !explorerResourceIsFolder"
118+
}
119+
],
120+
"commandPalette": [
121+
{
122+
"command": "npmx.openFileInNpmx",
123+
"when": "resourceScheme == file && resourcePath =~ /[\\/]node_modules[\\/]/"
124+
}
125+
]
126+
}
96127
},
97128
"scripts": {
98129
"dev": "tsdown --watch",
@@ -120,6 +151,7 @@
120151
"eslint": "^9.39.2",
121152
"fast-npm-meta": "^1.2.0",
122153
"husky": "^9.1.7",
154+
"jest-mock-vscode": "^4.10.0",
123155
"jsonc-parser": "^3.3.1",
124156
"module-replacements": "^2.11.0",
125157
"nano-staged": "^0.9.0",

pnpm-lock.yaml

Lines changed: 19 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/open-file-in-npmx.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { logger } from '#state'
2+
import { npmxFileUrl } from '#utils/links'
3+
import { resolvePackageRelativePath } from '#utils/resolve'
4+
import { useActiveTextEditor } from 'reactive-vscode'
5+
import { env, Uri, window } from 'vscode'
6+
7+
export async function openFileInNpmx(fileUri?: Uri) {
8+
const textEditor = useActiveTextEditor()
9+
10+
// If triggered from context menu, fileUri is provided.
11+
// If triggered from command palette, use active text editor.
12+
const uri = fileUri ?? textEditor.value?.document.uri
13+
if (!uri) {
14+
window.showErrorMessage('npmx: No active file selected.')
15+
return
16+
}
17+
18+
// Assert the given file is in `node_modules/`, though the command should
19+
// already be limited to such files.
20+
if (!uri.path.includes('/node_modules/')) {
21+
window.showErrorMessage('npmx: Selected file is not within a node_modules folder.')
22+
return
23+
}
24+
25+
// Find the associated package manifest and the relative path to the given file.
26+
const result = await resolvePackageRelativePath(uri)
27+
if (!result) {
28+
logger.warn(`Could not resolve npmx url: ${uri.toString()}`)
29+
window.showWarningMessage(`npmx: Could not find package.json for ${uri.toString()}`)
30+
return
31+
}
32+
const { manifest, relativePath } = result
33+
34+
// Use line number only if the user is actively looking at the relevant file
35+
const openingActiveFile = !fileUri || fileUri.toString() === textEditor.value?.document.uri.toString()
36+
37+
// VSCode uses 0-indexed lines, npmx uses 1-indexed lines
38+
const vsCodeLine = openingActiveFile ? textEditor.value?.selection.active.line : undefined
39+
const npmxLine = vsCodeLine !== undefined ? vsCodeLine + 1 : undefined
40+
41+
// Construct the npmx.dev URL and open it.
42+
const url = npmxFileUrl(manifest.name, manifest.version, relativePath, npmxLine)
43+
await env.openExternal(Uri.parse(url))
44+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '#constants'
99
import { defineExtension, useCommands, watchEffect } from 'reactive-vscode'
1010
import { Disposable, env, languages, Uri } from 'vscode'
11+
import { openFileInNpmx } from './commands/open-file-in-npmx'
1112
import { PackageJsonExtractor } from './extractors/package-json'
1213
import { PnpmWorkspaceYamlExtractor } from './extractors/pnpm-workspace-yaml'
1314
import { commands, displayName, version } from './generated-meta'
@@ -69,5 +70,6 @@ export const { activate, deactivate } = defineExtension(() => {
6970
[commands.openInBrowser]: () => {
7071
env.openExternal(Uri.parse(NPMX_DEV))
7172
},
73+
[commands.openFileInNpmx]: openFileInNpmx,
7274
})
7375
})

src/utils/links.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ export function npmxDocsUrl(name: string, version: string): string {
1010
return `${NPMX_DEV}/docs/${name}/v/${version}`
1111
}
1212

13+
export function npmxFileUrl(name: string, version: string, path: string, line?: number): string {
14+
return `${NPMX_DEV}/package-code/${name}/v/${version}/${path}${line !== undefined ? `#L${line}` : ''}`
15+
}
16+
1317
export function jsrPackageUrl(name: string, version: string): string {
1418
return `https://jsr.io/${name}@${version}`
1519
}

src/utils/resolve.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Uri, workspace } from 'vscode'
2+
3+
/**
4+
* Resolves the relative path of a file within its package.
5+
*
6+
* @param uri The URI of the file to resolve.
7+
* @returns A promise that resolves to the package manifest and relative path,
8+
* or `undefined` if not found.
9+
*/
10+
export async function resolvePackageRelativePath(uri: Uri): Promise<{ manifest: PackageManifest, relativePath: string } | undefined> {
11+
const result = await findPackageJson(uri)
12+
if (!result)
13+
return undefined
14+
15+
const { uri: pkgUri, manifest } = result
16+
const relativePath = uri.path.slice(pkgUri.path.lastIndexOf('/') + 1)
17+
18+
return { manifest, relativePath }
19+
}
20+
21+
/** A parsed `package.json` manifest file. */
22+
interface PackageManifest {
23+
/** Package name. */
24+
name: string
25+
26+
/** Package version specifier. */
27+
version: string
28+
}
29+
30+
/**
31+
* Finds the nearest package.json file by searching upwards from the given URI.
32+
*
33+
* @param file The URI to start the search from.
34+
* @returns The URI and parsed content of the package.json, or `undefined` if
35+
* not found.
36+
*/
37+
async function findPackageJson(file: Uri): Promise<{ uri: Uri, manifest: PackageManifest } | undefined> {
38+
// Start from the directory, so we don't look for
39+
// `node_modules/foo/bar.js/package.json`
40+
const startDir = Uri.joinPath(file, '..')
41+
42+
for (const dir of walkAncestors(startDir)) {
43+
const pkgUri = Uri.joinPath(dir, 'package.json')
44+
45+
let pkg: Partial<PackageManifest> | undefined
46+
try {
47+
const content = await workspace.fs.readFile(pkgUri)
48+
pkg = JSON.parse(new TextDecoder().decode(content)) as Partial<PackageManifest>
49+
} catch {
50+
continue
51+
}
52+
53+
if (isValidManifest(pkg)) {
54+
return {
55+
uri: pkgUri,
56+
manifest: pkg,
57+
}
58+
}
59+
}
60+
61+
return undefined
62+
}
63+
64+
function* walkAncestors(start: Uri): Generator<Uri, void, void> {
65+
let currentUri = start
66+
while (true) {
67+
yield currentUri
68+
69+
if (currentUri.path.endsWith('/node_modules'))
70+
return
71+
72+
const parentUri = Uri.joinPath(currentUri, '..')
73+
if (parentUri.toString() === currentUri.toString())
74+
return
75+
76+
currentUri = parentUri
77+
}
78+
}
79+
80+
/**
81+
* Check for valid package manifest, as it might be a manifest which just
82+
* configures a setting without really being a package (such as
83+
* `{sideEffects: false}`).
84+
*/
85+
function isValidManifest(json: Partial<PackageManifest>): json is PackageManifest {
86+
return Boolean(json && json.name && json.version)
87+
}

tests/__mocks__/filesystem.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect, vi } from 'vitest'
2+
import { workspace } from 'vscode'
3+
4+
/**
5+
* Mocks the VS Code filesystem by intercepting {@link workspace.fs}.
6+
*
7+
* @param files A record mapping file paths to their string content.
8+
*/
9+
export function mockFileSystem(files: Record<string, string>) {
10+
// Make all functions throw by default.
11+
for (const [name, fn] of Object.entries(workspace.fs)) {
12+
if (typeof fn === 'function') {
13+
vi.mocked(fn).mockImplementation(() => {
14+
expect.fail(`\`workspace.fs.${name}\` is not supported as a fake.`)
15+
})
16+
}
17+
}
18+
19+
vi.mocked(workspace.fs.readFile).mockImplementation(async (uri) => {
20+
const path = uri.path
21+
const content = files[path]
22+
if (content === undefined) {
23+
throw new Error(`File not found: ${uri.toString()}`)
24+
}
25+
return new TextEncoder().encode(content)
26+
})
27+
}

tests/__mocks__/vscode.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createVSCodeMock } from 'jest-mock-vscode'
2+
import { vi } from 'vitest'
3+
4+
const vscode = createVSCodeMock(vi)
5+
6+
export const Uri = vscode.Uri
7+
export const workspace = vscode.workspace
8+
export const Range = vscode.Range
9+
export const Position = vscode.Position
10+
export const Location = vscode.Location
11+
export const Selection = vscode.Selection
12+
export const ThemeColor = vscode.ThemeColor
13+
export const ThemeIcon = vscode.ThemeIcon
14+
export const TreeItem = vscode.TreeItem
15+
export const TreeItemCollapsibleState = vscode.TreeItemCollapsibleState
16+
export const Disposable = vscode.Disposable
17+
export default vscode

tests/filesystem.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { Uri, workspace } from 'vscode'
3+
import { mockFileSystem } from './__mocks__/filesystem'
4+
5+
describe('mockFileSystem', () => {
6+
beforeEach(() => {
7+
vi.resetAllMocks()
8+
})
9+
10+
describe('`readFile`', () => {
11+
it('should mock matched paths', async () => {
12+
mockFileSystem({
13+
'/test/file.txt': 'hello world',
14+
})
15+
16+
const uri = Uri.file('/test/file.txt')
17+
const content = await workspace.fs.readFile(uri)
18+
19+
expect(new TextDecoder().decode(content)).toBe('hello world')
20+
})
21+
22+
it('should throw error for unmatched paths', async () => {
23+
mockFileSystem({})
24+
25+
const uri = Uri.file('/does-not-exist.txt')
26+
await expect(workspace.fs.readFile(uri)).rejects.toThrow('File not found')
27+
})
28+
29+
it('should handle multiple files', async () => {
30+
mockFileSystem({
31+
'/a.js': 'content a',
32+
'/b.js': 'content b',
33+
})
34+
35+
const contentA = await workspace.fs.readFile(Uri.file('/a.js'))
36+
const contentB = await workspace.fs.readFile(Uri.file('/b.js'))
37+
38+
expect(new TextDecoder().decode(contentA)).toBe('content a')
39+
expect(new TextDecoder().decode(contentB)).toBe('content b')
40+
})
41+
})
42+
})

0 commit comments

Comments
 (0)