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 [];