Skip to content

Commit ee7bf1d

Browse files
committed
feat: add pnpm link packages
1 parent 23fcd19 commit ee7bf1d

File tree

6 files changed

+222
-8
lines changed

6 files changed

+222
-8
lines changed

.vscode/launch.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
"--disable-extensions"
88
],
99
"name": "Launch Extension",
10-
"outFiles": ["${workspaceFolder}/out/**/*.js"],
10+
"outFiles": [
11+
"${workspaceFolder}/out/**/*.js"
12+
],
1113
"request": "launch",
1214
"type": "extensionHost"
1315
}

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@
108108
{
109109
"command": "updatePackages",
110110
"title": "Update Packages"
111+
},
112+
{
113+
"command": "linkPackages",
114+
"title": "Link Packages (pnpm overrides)"
111115
}
112116
],
113117
"menus": {
@@ -262,5 +266,6 @@
262266
"trailingComma": "all",
263267
"arrowParens": "avoid",
264268
"printWidth": 160
265-
}
269+
},
270+
"packageManager": "[email protected]"
266271
}

src/commands/linkPackages.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import * as vscode from 'vscode'
2+
import { getCurrentWorkspaceRoot } from '@zardoy/vscode-utils/build/fs'
3+
import { getExtensionSetting, registerExtensionCommand, showQuickPick, VSCodeQuickPickItem } from 'vscode-framework'
4+
import { Utils } from 'vscode-uri'
5+
import { packageJsonInstallDependenciesKeys, readDirPackageJson } from '../commands-core/packageJson'
6+
import { getPrefferedPackageManager, packageManagerCommand } from '../commands-core/packageManager'
7+
import { joinPackageJson } from '../commands-core/util'
8+
9+
export const registerLinkPackages = () => {
10+
// eslint-disable-next-line complexity
11+
registerExtensionCommand('linkPackages' as any, async () => {
12+
const { uri: workspaceUri } = getCurrentWorkspaceRoot()
13+
const pm = await getPrefferedPackageManager(workspaceUri)
14+
if (pm !== 'pnpm') {
15+
void vscode.window.showWarningMessage('linkPackages currently supports pnpm only')
16+
return
17+
}
18+
19+
const rootPkg = await readDirPackageJson(workspaceUri)
20+
const allDeps = new Set<string>()
21+
for (const key of packageJsonInstallDependenciesKeys) for (const dep of Object.keys(rootPkg[key] ?? {})) allDeps.add(dep)
22+
23+
const parent = Utils.joinPath(workspaceUri, '..')
24+
console.time('linkPackages: scan candidates')
25+
const dirs = await vscode.workspace.fs
26+
.readDirectory(parent)
27+
// eslint-disable-next-line no-bitwise
28+
.then(entries => entries.filter(([, type]) => type & vscode.FileType.Directory))
29+
const candidates = [] as Array<{ name: string; dirPath: string; dirUri: vscode.Uri }>
30+
for (const [dirName] of dirs) {
31+
const dirUri = Utils.joinPath(parent, dirName)
32+
try {
33+
const pkg = await readDirPackageJson(dirUri)
34+
if (pkg.name && allDeps.has(pkg.name)) {
35+
const dirPath = `../${dirName}`
36+
candidates.push({ name: pkg.name, dirPath, dirUri: Utils.joinPath(workspaceUri, dirPath) })
37+
}
38+
} catch {}
39+
}
40+
41+
console.timeEnd('linkPackages: scan candidates')
42+
43+
const overrides = (rootPkg as any).pnpm?.overrides as Record<string, string> | undefined
44+
const isLinked = (dep: string, dirPath: string) => {
45+
const picked = overrides && typeof overrides[dep] === 'string' && overrides[dep].startsWith(`file:${dirPath}`)
46+
return picked
47+
}
48+
49+
const items: Array<VSCodeQuickPickItem<CandidateData>> = candidates
50+
.map(
51+
(candidate): VSCodeQuickPickItem<CandidateData> => ({
52+
label: candidate.name,
53+
description: candidate.dirPath,
54+
value: candidate,
55+
picked: isLinked(candidate.name, candidate.dirPath),
56+
}),
57+
)
58+
.sort((a, b) => Number(b.picked) - Number(a.picked) || a.label.localeCompare(b.label))
59+
60+
type CandidateData = { name: string; dirPath: string; dirUri: vscode.Uri }
61+
const selected = await vscode.window.showQuickPick(items, {
62+
title: `Link packages using ${pm}`,
63+
canPickMany: true,
64+
matchOnDescription: true,
65+
})
66+
if (selected === undefined) return
67+
68+
const selectedNames = new Set(selected.map(s => s.label))
69+
70+
// determine operations based on current overrides
71+
const toRemove = new Set(candidates.filter(c => !selectedNames.has(c.name) && overrides?.[c.name]?.startsWith('file:')).map(c => c.name))
72+
const toAdd = candidates.filter(c => selectedNames.has(c.name) && !overrides?.[c.name]?.startsWith('file:'))
73+
74+
// Raw text edit of package.json
75+
const decoder = new TextDecoder()
76+
const encoder = new TextEncoder()
77+
const pkgUri = joinPackageJson(workspaceUri)
78+
const originalText = decoder.decode(await vscode.workspace.fs.readFile(pkgUri))
79+
const lines = originalText.split(/\r?\n/)
80+
81+
// find pnpm block
82+
const pnpmLineIndex = lines.findIndex(l => /^\s*"pnpm"\s*:\s*{\s*$/.test(l))
83+
if (pnpmLineIndex === -1) {
84+
void vscode.window.showWarningMessage('Could not find pnpm block in package.json to edit overrides')
85+
return
86+
}
87+
88+
// find overrides block start and end within pnpm block
89+
const pnpmIndent = /^(\s*)/.exec(lines[pnpmLineIndex]!)![1] || ''
90+
let overridesStart = -1
91+
let pnpmEnd = -1
92+
for (let i = pnpmLineIndex + 1; i < lines.length; i++) {
93+
if (overridesStart === -1 && /^\s*"overrides"\s*:\s*{\s*$/.test(lines[i]!)) overridesStart = i
94+
if (new RegExp(`^${pnpmIndent}}`).test(lines[i]!)) {
95+
pnpmEnd = i
96+
break
97+
}
98+
}
99+
100+
if (overridesStart === -1) {
101+
void vscode.window.showWarningMessage('Could not find pnpm.overrides block in package.json')
102+
return
103+
}
104+
105+
const overridesIndent = /^(\s*)/.exec(lines[overridesStart]!)![1] || ''
106+
let overridesEnd = -1
107+
for (let i = overridesStart + 1; i < pnpmEnd; i++)
108+
if (new RegExp(`^${overridesIndent}}`).test(lines[i]!)) {
109+
overridesEnd = i
110+
break
111+
}
112+
113+
if (overridesEnd === -1) {
114+
void vscode.window.showWarningMessage('Malformed pnpm.overrides block in package.json')
115+
return
116+
}
117+
118+
// map existing override lines by name and mark deletions
119+
const entryLineIndicesByName = new Map<string, number>()
120+
for (let i = overridesStart + 1; i < overridesEnd; i++) {
121+
const line = lines[i]!
122+
const m = /^\s*"([^"]+)"\s*:\s*"([^"]*)"\s*,?\s*$/.exec(line)
123+
if (m) entryLineIndicesByName.set(m[1]!, i)
124+
}
125+
126+
const toDeleteLineIdx = new Set<number>()
127+
for (const name of toRemove) {
128+
const idx = entryLineIndicesByName.get(name)
129+
if (idx !== undefined) toDeleteLineIdx.add(idx)
130+
}
131+
132+
// remove existing lines for those we will add (replace)
133+
for (const { name } of toAdd) {
134+
const idx = entryLineIndicesByName.get(name)
135+
if (idx !== undefined) toDeleteLineIdx.add(idx)
136+
}
137+
138+
// apply deletions from bottom to top to keep indices stable
139+
const deleteIndicesSorted = [...toDeleteLineIdx].sort((a, b) => b - a)
140+
for (const idx of deleteIndicesSorted) lines.splice(idx, 1)
141+
142+
// adjust overridesEnd after deletions
143+
const removedBeforeEnd = deleteIndicesSorted.filter(i => i < overridesEnd).length
144+
overridesEnd -= removedBeforeEnd
145+
146+
// compute remaining entry count
147+
const remainingEntries = Math.max(0, overridesEnd - (overridesStart + 1))
148+
149+
// insert new entries directly after overrides line, maintain commas
150+
const childIndent = `${overridesIndent} `
151+
const additions = toAdd.map(({ name, dirPath }) => ({ name, dirPath }))
152+
const hasFollowingAfterAdditions = remainingEntries > 0
153+
const newLines: string[] = []
154+
for (const [idx, add] of additions.entries()) {
155+
const isLastAdded = idx === additions.length - 1
156+
const needComma = hasFollowingAfterAdditions || !isLastAdded
157+
newLines.push(`${childIndent}"${add.name}": "file:${add.dirPath}"${needComma ? ',' : ''}`)
158+
}
159+
160+
// splice new lines right after overridesStart
161+
if (newLines.length > 0) {
162+
lines.splice(overridesStart + 1, 0, ...newLines)
163+
overridesEnd += newLines.length
164+
}
165+
166+
// normalize commas: every entry line except the last should end with a comma
167+
const entryIdxs: number[] = []
168+
for (let i = overridesStart + 1; i < overridesEnd; i++) {
169+
const line = lines[i]!
170+
if (/^\s*"[^"]+"\s*:\s*"[^"]*"\s*,?\s*$/.test(line)) entryIdxs.push(i)
171+
}
172+
173+
for (const [pos, idx] of entryIdxs.entries()) {
174+
const isLast = pos === entryIdxs.length - 1
175+
const m = /^(\s*)"([^"]+)"\s*:\s*"([^"]*)"\s*,?\s*$/.exec(lines[idx]!)!
176+
const indent = m[1]!
177+
const key = m[2]!
178+
const val = m[3]!
179+
lines[idx] = `${indent}"${key}": "${val}"${isLast ? '' : ','}`
180+
}
181+
182+
const updatedText = lines.join('\n')
183+
await vscode.workspace.fs.writeFile(pkgUri, encoder.encode(updatedText))
184+
185+
// Run install
186+
const runInstall = getExtensionSetting('linkCommand.runInstall')
187+
// eslint-disable-next-line curly
188+
if (runInstall) {
189+
await packageManagerCommand({
190+
command: 'install',
191+
cwd: workspaceUri,
192+
forcePm: 'pnpm',
193+
})
194+
}
195+
})
196+
}

src/configurationType.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export type Configuration = {
4646
* By default (when null) first installed is picked: pnpm, yarn, npm
4747
* @default null
4848
* */
49-
leadingPackageManager: 'pnpm' | 'yarn' | 'npm' | null
49+
leadingPackageManager: 'pnpm' | 'yarn' | 'npm' | 'bun' | null
5050
/**
5151
* What to do on clipboard detection for copied command to install packages
5252
* @default ask
@@ -256,4 +256,8 @@ export type Configuration = {
256256
// enableTerminalLinkProvider: boolean
257257
/** @default true */
258258
useNoJsonDiagnosticsWorkaround: boolean
259+
/**
260+
* @default true
261+
*/
262+
'linkCommand.runInstall': boolean
259263
}

src/configurationTypeCache.jsonc

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
// GENERATED. DON'T EDIT MANUALLY
2-
// md5hash: 4a565685fba6c6f1922f0623441870fc
2+
// md5hash: 9e2055807d09dfdfff849f2de4f78a40
33
{
44
"type": "object",
55
"properties": {
66
"leadingPackageManager": {
7-
"description": "Your main package manager that leads your projects\nUsed when no lockfile is detected\nBy default (when null) first installed is picked.",
7+
"description": "Your main package manager that leads your projects\nUsed when no lockfile is detected\nBy default (when null) first installed is picked: pnpm, yarn, npm",
88
"default": null,
99
"enum": [
10+
"bun",
1011
"npm",
1112
"pnpm",
12-
"yarn",
13-
"bun"
13+
"yarn"
1414
],
1515
"type": "string"
1616
},
@@ -329,6 +329,10 @@
329329
"useNoJsonDiagnosticsWorkaround": {
330330
"default": true,
331331
"type": "boolean"
332+
},
333+
"linkCommand.runInstall": {
334+
"default": true,
335+
"type": "boolean"
332336
}
333337
},
334338
"required": [
@@ -342,6 +346,7 @@
342346
"install.watchLockfiles",
343347
"install.watchLockfilesGitCheckouts",
344348
"leadingPackageManager",
349+
"linkCommand.runInstall",
345350
"onPackageManagerCommandFail",
346351
"packageJsonIntellisense",
347352
"packageJsonLinks",
@@ -361,4 +366,4 @@
361366
"useIntegratedTerminal",
362367
"useNoJsonDiagnosticsWorkaround"
363368
]
364-
}
369+
}

src/extension.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { registerRunOnSave } from './features/runOnSave'
2121
import openWorkspacePackageJson from './commands/openWorkspacePackageJson'
2222
import { registerPackageJsonLinks } from './packageJsonLinks'
2323
import { registerLinkPackage } from './commands/linkPackage'
24+
import { registerLinkPackages } from './commands/linkPackages'
2425

2526
// TODO command for package diff
2627

@@ -51,6 +52,7 @@ export const activate = () => {
5152

5253
registerOpenPackageAtCommands()
5354
registerLinkPackage()
55+
registerLinkPackages()
5456
openWorkspacePackageJson()
5557
registerRunOnSave()
5658
registerClipboardDetection()

0 commit comments

Comments
 (0)