diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts index 12eeabd64c..9a329ed433 100644 --- a/packages/cli/src/utils/__tests__/editor.spec.ts +++ b/packages/cli/src/utils/__tests__/editor.spec.ts @@ -91,6 +91,14 @@ describe('detectExistingEditors', () => { it('returns undefined when no editor config files exist', () => { expect(detectExistingEditors(createTempDir())).toBeUndefined(); }); + + it('detects existing jetbrains editor config files', () => { + const projectRoot = createTempDir(); + fs.mkdirSync(path.join(projectRoot, '.idea'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.idea', 'externalDependencies.xml'), ''); + + expect(detectExistingEditors(projectRoot)).toEqual(['jetbrains']); + }); }); describe('writeEditorConfigs', () => { @@ -539,4 +547,36 @@ describe('writeEditorConfigs', () => { expect(zedSettings['npm.scriptRunner']).toBeUndefined(); expect(zedSettings.lsp).toBeDefined(); }); + + it('writes jetbrains config as XML based on file extension', async () => { + const projectRoot = createTempDir(); + + await writeEditorConfigs({ + projectRoot, + editorId: 'jetbrains', + interactive: false, + silent: true, + }); + + const xml = fs.readFileSync(path.join(projectRoot, '.idea', 'externalDependencies.xml'), 'utf8'); + expect(xml).toContain(''); + expect(xml).toContain(''); + }); + + it('does not overwrite existing non-JSON editor config in non-interactive mode', async () => { + const projectRoot = createTempDir(); + const xmlPath = path.join(projectRoot, '.idea', 'externalDependencies.xml'); + fs.mkdirSync(path.dirname(xmlPath), { recursive: true }); + fs.writeFileSync(xmlPath, '', 'utf8'); + + await writeEditorConfigs({ + projectRoot, + editorId: 'jetbrains', + interactive: false, + silent: true, + }); + + const xml = fs.readFileSync(xmlPath, 'utf8'); + expect(xml).toBe(''); + }); }); diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts index 560eec021b..9ab3598e1b 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -150,6 +150,17 @@ const ZED_SETTINGS = { }, } as const; +const JETBRAINS_EXTERNAL_DEPENDENCIES = ` + + + + + + +`; + +type EditorConfigValue = Record | string; + export const EDITORS = [ { id: 'vscode', @@ -168,6 +179,14 @@ export const EDITORS = [ 'settings.json': ZED_SETTINGS as Record, }, }, + { + id: 'jetbrains', + label: 'JetBrains (IntelliJ, WebStorm, etc)', + targetDir: '.idea', + files: { + 'externalDependencies.xml': JETBRAINS_EXTERNAL_DEPENDENCIES, + }, + }, ] as const; export type EditorId = (typeof EDITORS)[number]['id']; @@ -382,11 +401,12 @@ async function writeEditorConfig({ await fsPromises.mkdir(targetDir, { recursive: true }); for (const [fileName, baseIncoming] of Object.entries(editorConfig.files)) { - const incoming = + const incoming: EditorConfigValue = editorId === 'vscode' && fileName === 'settings.json' && extraVsCodeSettings ? { ...extraVsCodeSettings, ...baseIncoming } : baseIncoming; const filePath = path.join(targetDir, fileName); + const jsonFormat = isJsonLikeFile(fileName); if (fs.existsSync(filePath)) { const displayPath = `${editorConfig.targetDir}/${fileName}`; @@ -402,13 +422,17 @@ async function writeEditorConfig({ `${displayPath} already exists.\n ` + styleText( 'gray', - `Vite+ adds ${editorConfig.label} settings for the built-in linter and formatter. Merge adds new keys without overwriting existing ones.`, + jsonFormat + ? `Vite+ adds ${editorConfig.label} settings for the built-in linter and formatter. Merge adds new keys without overwriting existing ones.` + : `Vite+ adds ${editorConfig.label} settings for the built-in linter and formatter. Overwrite replaces the existing file with the generated config.`, ), options: [ { - label: 'Merge', + label: jsonFormat ? 'Merge' : 'Overwrite', value: 'merge', - hint: 'Merge new settings into existing file', + hint: jsonFormat + ? 'Merge new settings into existing file' + : 'Replace existing file with generated config', }, { label: 'Skip', @@ -420,12 +444,19 @@ async function writeEditorConfig({ }); conflictAction = prompts.isCancel(action) || action === 'skip' ? 'skip' : 'merge'; } else { - // Non-interactive: always merge (safe because existing keys are never overwritten) - conflictAction = 'merge'; + // Non-interactive: merge JSON safely, skip non-JSON to avoid destructive overwrite. + conflictAction = jsonFormat ? 'merge' : 'skip'; } if (conflictAction === 'merge') { - mergeAndWriteEditorConfig(filePath, incoming, fileName, displayPath, silent); + if (jsonFormat) { + if (!isPlainObject(incoming)) { + throw new Error(`Cannot merge editor config: ${displayPath} incoming value is not JSON`); + } + mergeAndWriteEditorConfig(filePath, incoming, fileName, displayPath, silent); + } else { + writeTextEditorConfig(filePath, incoming, displayPath, silent); + } } else { if (!silent) { prompts.log.info(`Skipped writing ${displayPath}`); @@ -434,13 +465,46 @@ async function writeEditorConfig({ continue; } - writeJsonFile(filePath, incoming); + if (jsonFormat) { + if (!isPlainObject(incoming)) { + throw new Error(`Cannot write editor config: ${editorConfig.targetDir}/${fileName} must be JSON`); + } + writeJsonFile(filePath, incoming); + } else { + writeTextEditorConfig(filePath, incoming, `${editorConfig.targetDir}/${fileName}`, silent); + } if (!silent) { prompts.log.success(`Wrote editor config to ${editorConfig.targetDir}/${fileName}`); } } } +function isJsonLikeFile(fileName: string): boolean { + const ext = path.extname(fileName).toLowerCase(); + return ext === '.json' || ext === '.jsonc'; +} + +function writeTextEditorConfig( + filePath: string, + incoming: EditorConfigValue, + displayPath: string, + silent = false, +) { + if (typeof incoming !== 'string') { + throw new Error(`Cannot write editor config: ${displayPath} must be text content`); + } + + const existingText = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : undefined; + if (existingText === incoming) { + if (!silent) { + prompts.log.info(`No changes needed for ${displayPath}`); + } + return; + } + + fs.writeFileSync(filePath, incoming, 'utf-8'); +} + function normalizeEditorSelection(editorId: EditorSelection): EditorId[] { if (!editorId) { return [];