diff --git a/docs/guide/ide-integration.md b/docs/guide/ide-integration.md
index 9710d76a85..4aef237b1d 100644
--- a/docs/guide/ide-integration.md
+++ b/docs/guide/ide-integration.md
@@ -100,3 +100,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 2979ce976c..351767bb53 100644
--- a/packages/cli/src/create/bin.ts
+++ b/packages/cli/src/create/bin.ts
@@ -1089,6 +1089,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 695a5e054e..9292770b36 100644
--- a/packages/cli/src/migration/__tests__/migrator.spec.ts
+++ b/packages/cli/src/migration/__tests__/migrator.spec.ts
@@ -8977,3 +8977,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 76a5a23d3a..d856ef8d9d 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
@@ -1480,6 +1481,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 b4e513a3f6..d6eb7a2645 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,
@@ -142,7 +147,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 7af435ae0f..bf8746f06a 100644
--- a/packages/cli/src/utils/__tests__/editor.spec.ts
+++ b/packages/cli/src/utils/__tests__/editor.spec.ts
@@ -6,7 +6,12 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts';
import { parse as parseJsonc } from 'jsonc-parser';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js';
+import {
+ detectExistingEditors,
+ selectEditor,
+ selectEditors,
+ writeEditorConfigs,
+} from '../editor.js';
const tempDirs: string[] = [];
@@ -75,6 +80,80 @@ describe('selectEditors', () => {
}),
).resolves.toEqual(['zed']);
});
+
+ it('resolves --editor intellij to jetbrains and warns about the non-canonical ID used', async () => {
+ const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
+
+ await expect(
+ selectEditors({
+ interactive: false,
+ editor: 'intellij',
+ onCancel: vi.fn(),
+ }),
+ ).resolves.toEqual(['jetbrains']);
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "--editor intellij was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
+ ),
+ );
+ });
+
+ it('resolves --editor webstorm to jetbrains and warns about the non-canonical ID used', async () => {
+ const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
+
+ await expect(
+ selectEditors({
+ interactive: false,
+ editor: 'webstorm',
+ onCancel: vi.fn(),
+ }),
+ ).resolves.toEqual(['jetbrains']);
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "--editor webstorm was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
+ ),
+ );
+ });
+
+ it('does not show intellij/webstorm aliases as interactive TUI options', async () => {
+ const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['vscode']);
+
+ await selectEditors({
+ interactive: true,
+ onCancel: vi.fn(),
+ });
+
+ expect(multiselectSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ options: expect.not.arrayContaining([
+ expect.objectContaining({ value: 'intellij' }),
+ expect.objectContaining({ value: 'webstorm' }),
+ ]),
+ }),
+ );
+ });
+});
+
+describe('selectEditor', () => {
+ it('resolves --editor intellij to jetbrains and warns about the non-canonical ID used', async () => {
+ const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
+
+ await expect(
+ selectEditor({
+ interactive: false,
+ editor: 'intellij',
+ onCancel: vi.fn(),
+ }),
+ ).resolves.toBe('jetbrains');
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "--editor intellij was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
+ ),
+ );
+ });
});
describe('detectExistingEditors', () => {
@@ -91,6 +170,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', () => {
@@ -541,4 +628,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": "$USER_HOME$/.vite-plus/bin/node.exe"`,
+ );
+ 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 2801e30749..5de892ba43 100644
--- a/packages/cli/src/utils/editor.ts
+++ b/packages/cli/src/utils/editor.ts
@@ -13,6 +13,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
@@ -93,6 +94,48 @@ 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',
@@ -111,6 +154,24 @@ 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;
+
+// Deprecated aliases kept for backwards-compatible `--editor` values; not shown as TUI options.
+const EDITOR_ALIASES = [
+ { id: 'intellij', alias: 'jetbrains' },
+ { id: 'webstorm', alias: 'jetbrains' },
] as const;
export type EditorId = (typeof EDITORS)[number]['id'];
@@ -276,6 +337,7 @@ export async function writeEditorConfigs({
conflictDecisions,
silent = false,
extraVsCodeSettings,
+ packageManager = PackageManager.pnpm,
}: {
projectRoot: string;
editorId: EditorSelection;
@@ -283,6 +345,7 @@ export async function writeEditorConfigs({
conflictDecisions?: Map;
silent?: boolean;
extraVsCodeSettings?: Record;
+ packageManager?: PackageManager;
}) {
const editorIds = normalizeEditorSelection(editorId);
if (editorIds.length === 0) {
@@ -297,6 +360,7 @@ export async function writeEditorConfigs({
conflictDecisions,
silent,
extraVsCodeSettings,
+ packageManager,
});
}
}
@@ -308,6 +372,7 @@ async function writeEditorConfig({
conflictDecisions,
silent,
extraVsCodeSettings,
+ packageManager,
}: {
projectRoot: string;
editorId: EditorId;
@@ -315,6 +380,7 @@ async function writeEditorConfig({
conflictDecisions?: Map;
silent: boolean;
extraVsCodeSettings?: Record;
+ packageManager: PackageManager;
}) {
const editorConfig = EDITORS.find((e) => e.id === editorId);
if (!editorConfig) {
@@ -325,11 +391,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}`;
@@ -345,13 +414,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',
@@ -363,12 +436,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}`);
@@ -377,13 +459,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 [];
@@ -521,7 +638,19 @@ function resolveEditorId(editor: string): EditorId | undefined {
const match = EDITORS.find(
(option) => option.id === normalized || option.label.toLowerCase() === normalized,
);
- return match?.id;
+ if (match) {
+ return match.id;
+ }
+
+ const aliasMatch = EDITOR_ALIASES.find((option) => option.id === normalized);
+ if (aliasMatch) {
+ prompts.log.warn(
+ `--editor ${aliasMatch.id} was passed; use --editor ${aliasMatch.alias} instead, as it's the canonical ID for that editor.`,
+ );
+ return aliasMatch.alias;
+ }
+
+ return undefined;
}
function resolveEditorIds(editors: readonly string[]): EditorId[] | undefined {