diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md
index 554da52e4f..305f22c029 100644
--- a/docs/guide/ide-integration.md
+++ b/docs/guide/ide-integration.md
@@ -99,3 +99,59 @@ 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 any existing 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]
+
+
+
+
+
+
+```
+
+Often, `.idea` folders are gitignored in a project, even the `externalDependencies.xml` file. Adding the `.idea/.gitignore` file with the following content will help ensure that it is present:
+
+```gitignore [.idea/.gitignore]
+!externalDependencies.xml
+```
diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts
index 0ef9c028e1..6e7cbe8e46 100644
--- a/packages/cli/src/create/bin.ts
+++ b/packages/cli/src/create/bin.ts
@@ -1101,6 +1101,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
interactive: options.interactive,
silent: compactOutput,
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
+ packageManager,
});
if (selectedEditors?.includes('vscode')) {
ensureGitignoreVsCodeEditorConfigs(fullPath);
@@ -1269,6 +1270,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
interactive: options.interactive,
silent: compactOutput,
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
+ packageManager,
});
if (selectedEditors?.includes('vscode')) {
ensureGitignoreVsCodeEditorConfigs(fullPath);
diff --git a/packages/cli/src/migration/__tests__/migrator.spec.ts b/packages/cli/src/migration/__tests__/migrator.spec.ts
index bcefe8a9f5..005cef7171 100644
--- a/packages/cli/src/migration/__tests__/migrator.spec.ts
+++ b/packages/cli/src/migration/__tests__/migrator.spec.ts
@@ -8593,3 +8593,41 @@ describe('collectMigrationSetupPlan ESLint gating', () => {
},
);
});
+
+describe('collectMigrationSetupPlan non-interactive editor conflicts', () => {
+ let tmpDir: string;
+
+ beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-setup-plan-editor-'));
+ writePkgAt(tmpDir, { name: 'x' });
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ });
+
+ it('skips (never overwrites) existing non-JSON jetbrains files, only merges JSON-like ones', async () => {
+ fs.mkdirSync(path.join(tmpDir, '.idea'), { recursive: true });
+ fs.writeFileSync(
+ path.join(tmpDir, '.idea', 'externalDependencies.xml'),
+ '',
+ );
+ fs.writeFileSync(path.join(tmpDir, '.idea', 'workspace.xml'), '');
+
+ const plan = await collectMigrationSetupPlan(
+ tmpDir,
+ PackageManager.pnpm,
+ {
+ interactive: false,
+ hooks: false,
+ agent: false as const,
+ editor: 'jetbrains',
+ },
+ undefined,
+ false,
+ );
+
+ expect(plan.editorConflictDecisions.get('externalDependencies.xml')).toBe('skip');
+ expect(plan.editorConflictDecisions.get('workspace.xml')).toBe('skip');
+ });
+});
diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts
index 4065458314..1eb928f769 100644
--- a/packages/cli/src/migration/bin.ts
+++ b/packages/cli/src/migration/bin.ts
@@ -958,6 +958,7 @@ async function executeMigrationPlan(
interactive,
conflictDecisions: plan.editorConflictDecisions,
silent: true,
+ packageManager: plan.packageManager,
});
// 11. Add framework shims if requested
@@ -1472,6 +1473,7 @@ async function main() {
interactive: options.interactive,
conflictDecisions: plan.editorConflictDecisions,
silent: true,
+ packageManager,
});
didMigrate = true;
}
diff --git a/packages/cli/src/migration/setup-plan.ts b/packages/cli/src/migration/setup-plan.ts
index 660e46fdb0..d4728eac83 100644
--- a/packages/cli/src/migration/setup-plan.ts
+++ b/packages/cli/src/migration/setup-plan.ts
@@ -8,7 +8,12 @@ import {
detectExistingAgentTargetPaths,
selectAgentTargetPaths,
} from '../utils/agent.ts';
-import { detectEditorConflicts, type EditorId, selectEditor } from '../utils/editor.ts';
+import {
+ detectEditorConflicts,
+ type EditorId,
+ isJsonLikeFile,
+ selectEditor,
+} from '../utils/editor.ts';
import { cancelAndExit, promptGitHooks } from '../utils/prompts.ts';
import {
confirmEslintMigration,
@@ -141,7 +146,11 @@ async function collectEditorConfigPlan(
}
editorConflictDecisions.set(conflict.fileName, action);
} else {
- editorConflictDecisions.set(conflict.fileName, 'merge');
+ // Non-JSON files (e.g. JetBrains XML) can't be merged, so only skip them here.
+ editorConflictDecisions.set(
+ conflict.fileName,
+ isJsonLikeFile(conflict.fileName) ? 'merge' : 'skip',
+ );
}
}
diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts
index 12eeabd64c..360f3c0ca2 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', 'workspace.xml'), '');
+
+ expect(detectExistingEditors(projectRoot)).toEqual(['jetbrains']);
+ });
});
describe('writeEditorConfigs', () => {
@@ -539,4 +547,89 @@ describe('writeEditorConfigs', () => {
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
expect(zedSettings.lsp).toBeDefined();
});
+
+ it('writes all jetbrains editor config files', async () => {
+ const projectRoot = createTempDir();
+
+ await writeEditorConfigs({
+ projectRoot,
+ editorId: 'jetbrains',
+ interactive: false,
+ silent: true,
+ });
+
+ const externalDependenciesXml = fs.readFileSync(
+ path.join(projectRoot, '.idea', 'externalDependencies.xml'),
+ 'utf8',
+ );
+ expect(externalDependenciesXml).toContain('');
+ expect(externalDependenciesXml).toContain(
+ '',
+ );
+
+ const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
+ expect(workspaceXml).toContain('');
+ expect(workspaceXml).toContain('"javascript.preferred.runtime.type.id": "node"');
+ expect(workspaceXml).toContain(
+ `"nodejs_interpreter_path": "${os.homedir()}/.vite-plus/bin/node"`,
+ );
+ expect(workspaceXml).toContain('"nodejs_package_manager_path": "pnpm"');
+
+ const oxfmtSettingsXml = fs.readFileSync(
+ path.join(projectRoot, '.idea', 'OxfmtSettings.xml'),
+ 'utf8',
+ );
+ expect(oxfmtSettingsXml).toContain('');
+ expect(oxfmtSettingsXml).toContain('');
+ expect(oxfmtSettingsXml).toContain(
+ '',
+ );
+
+ const gitignore = fs.readFileSync(path.join(projectRoot, '.idea', '.gitignore'), 'utf8');
+ expect(gitignore).toBe('**\n!externalDependencies.xml\n');
+ });
+
+ it('writes workspace.xml with the resolved package manager', async () => {
+ const projectRoot = createTempDir();
+
+ await writeEditorConfigs({
+ projectRoot,
+ editorId: 'jetbrains',
+ interactive: false,
+ silent: true,
+ packageManager: 'yarn',
+ });
+
+ const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
+ expect(workspaceXml).toContain('"nodejs_package_manager_path": "yarn"');
+ });
+
+ it('does not overwrite existing non-JSON jetbrains file 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('');
+
+ const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
+ expect(workspaceXml).toContain('');
+
+ const oxfmtSettingsXml = fs.readFileSync(
+ path.join(projectRoot, '.idea', 'OxfmtSettings.xml'),
+ 'utf8',
+ );
+ expect(oxfmtSettingsXml).toContain('');
+
+ const gitignore = fs.readFileSync(path.join(projectRoot, '.idea', '.gitignore'), 'utf8');
+ expect(gitignore).toBe('**\n!externalDependencies.xml\n');
+ });
});
diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts
index 560eec021b..23540bc098 100644
--- a/packages/cli/src/utils/editor.ts
+++ b/packages/cli/src/utils/editor.ts
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
+import os from 'node:os';
import path from 'node:path';
import { styleText } from 'node:util';
@@ -13,6 +14,7 @@ import {
parse as parseJsonc,
} from 'jsonc-parser';
+import { PackageManager } from '../types/package.ts';
import { detectFormattingOptions, writeJsonFile } from './json.ts';
// Language-specific overrides because user-level [lang] settings beat the workspace default
@@ -150,6 +152,44 @@ const ZED_SETTINGS = {
},
} as const;
+const JETBRAINS_EXTERNAL_DEPENDENCIES = `
+
+
+
+
+
+`;
+
+function jetbrainsWorkspaceConfig(packageManager: PackageManager): string {
+ return `
+
+
+
+
+
+`;
+}
+
+const JETBRAINS_OXFMT_SETTINGS = `
+
+
+
+
+
+`;
+
+const JETBRAINS_GITIGNORE_ADDITION = `**
+!externalDependencies.xml
+`;
+
+type EditorConfigValue = Record | string;
+
export const EDITORS = [
{
id: 'vscode',
@@ -168,6 +208,18 @@ export const EDITORS = [
'settings.json': ZED_SETTINGS as Record,
},
},
+ {
+ id: 'jetbrains',
+ label: 'JetBrains (IntelliJ, WebStorm, etc)',
+ targetDir: '.idea',
+ files: {
+ 'externalDependencies.xml': JETBRAINS_EXTERNAL_DEPENDENCIES,
+ // Placeholder; writeEditorConfig() regenerates this with the resolved package manager.
+ 'workspace.xml': jetbrainsWorkspaceConfig(PackageManager.pnpm),
+ 'OxfmtSettings.xml': JETBRAINS_OXFMT_SETTINGS,
+ '.gitignore': JETBRAINS_GITIGNORE_ADDITION,
+ },
+ },
] as const;
export type EditorId = (typeof EDITORS)[number]['id'];
@@ -333,6 +385,7 @@ export async function writeEditorConfigs({
conflictDecisions,
silent = false,
extraVsCodeSettings,
+ packageManager = PackageManager.pnpm,
}: {
projectRoot: string;
editorId: EditorSelection;
@@ -340,6 +393,7 @@ export async function writeEditorConfigs({
conflictDecisions?: Map;
silent?: boolean;
extraVsCodeSettings?: Record;
+ packageManager?: PackageManager;
}) {
const editorIds = normalizeEditorSelection(editorId);
if (editorIds.length === 0) {
@@ -354,6 +408,7 @@ export async function writeEditorConfigs({
conflictDecisions,
silent,
extraVsCodeSettings,
+ packageManager,
});
}
}
@@ -365,6 +420,7 @@ async function writeEditorConfig({
conflictDecisions,
silent,
extraVsCodeSettings,
+ packageManager,
}: {
projectRoot: string;
editorId: EditorId;
@@ -372,6 +428,7 @@ async function writeEditorConfig({
conflictDecisions?: Map;
silent: boolean;
extraVsCodeSettings?: Record;
+ packageManager: PackageManager;
}) {
const editorConfig = EDITORS.find((e) => e.id === editorId);
if (!editorConfig) {
@@ -382,11 +439,14 @@ 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;
+ : editorId === 'jetbrains' && fileName === 'workspace.xml'
+ ? jetbrainsWorkspaceConfig(packageManager)
+ : baseIncoming;
const filePath = path.join(targetDir, fileName);
+ const jsonFormat = isJsonLikeFile(fileName);
if (fs.existsSync(filePath)) {
const displayPath = `${editorConfig.targetDir}/${fileName}`;
@@ -402,13 +462,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 +484,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 +507,48 @@ 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}`);
}
}
}
+export 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 [];