diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md index 554da52e4f..71c92cab27 100644 --- a/docs/guide/ide-integration.md +++ b/docs/guide/ide-integration.md @@ -99,3 +99,58 @@ You can also manually set up the Zed config: ``` Setting `oxfmt.fmt.configPath` to `./vite.config.ts` keeps editor format-on-save aligned with the `fmt` block in your Vite+ config. The full generated config covers additional languages (CSS, HTML, JSON, Markdown, etc.) — run `vp create` or `vp migrate` to get the complete file written automatically. + +## JetBrains (IntelliJ, WebStorm, etc...) + +For the best Vite+ experience with JetBrains IDEs such as IntelliJ & WebStorm, install the [Oxc](https://plugins.jetbrains.com/plugin/27061-oxc) plugin from the JetBrains marketplace. + +When you create or migrate a project, Vite+ prompts you to choose whether you want the editor config written for JetBrains IDEs. + +::: tip Vite+ does not merge with existing config files +Due to some complexities with merging XML files, Vite+ currently does not merge your current files if the files already exist. +You'll be given the opportunity to replace your files, instead of merging. +::: + +You can also manually set up the IDE configuration to match your Vite+ setup: + +```xml [.idea/externalDependencies.xml] + + + + + + + +``` + +```xml [.idea/workspace.xml] + + + + + + + +``` + +```xml [.idea/OxfmtSettings.xml] + + + + + +``` + +```gitignore [.idea/.gitignore] +!externalDependencies.xml +``` 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..c23b222518 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -150,6 +150,21 @@ const ZED_SETTINGS = { }, } as const; +// TODO Replace this raw XML template with a JSON-object definition +// once the XML parser/merge support below is in place. +const JETBRAINS_EXTERNAL_DEPENDENCIES = ` + + + + + + +`; + +// TODO: Extend this to allow XML files to be authored as JSON objects too +// e.g. `Record | string` -> add a dedicated XML-object type once an XML parser/differ is written +type EditorConfigValue = Record | string; + export const EDITORS = [ { id: 'vscode', @@ -168,8 +183,25 @@ 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; +// Since some file types may not be obvious (e.g. `.editorconfig`), it may be beneficial to add a property for declaring file type overrides for both read and write +// The only problem is that now we have to maintain a list of file types and their extensions, which is not trivial. For now, we can just use the file extension to determine the file type and just deal with corner-case scenarios as we go. +// TODO: Replace values with custom parser classes/objects implemented specifically for each file type +export const FILE_TYPE_MAPPINGS = { + '.json': 'jsonc', + '.jsonc': 'jsonc', + '.xml': 'xml', +} + export type EditorId = (typeof EDITORS)[number]['id']; type EditorSelection = EditorId | readonly EditorId[] | undefined; @@ -382,11 +414,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 +435,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 +457,21 @@ 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 +480,53 @@ 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'; +} + +// TODO: Add an `isXmlFile()` extension check (`.xml`) alongside `isJsonLikeFile()`, +// and dispatch `.xml` files to write/merge instead of write/skip-on-conflict +// See `writeEditorConfig()`'s `jsonFormat` branching for the call sites that need a parallel `xmlFormat` branch (conflict prompt copy, merge dispatch, initial write). +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; + } + + // TODO: Once XML merge support exists, this plain overwrite-or-skip behavior should be replaced with a merge that preserves comments and formatting, similar to `mergeAndWriteEditorConfig()`. + // Likely will need to be extended for other file types too, so consider a generic `mergeAndWriteFile()` that dispatches to JSON/XML/text merge strategies based on file extension. + fs.writeFileSync(filePath, incoming, 'utf-8'); +} + function normalizeEditorSelection(editorId: EditorSelection): EditorId[] { if (!editorId) { return [];