|
| 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 | +} |
0 commit comments