Skip to content

Commit 9525587

Browse files
authored
feat(cli): add support for JetBrains editors during the editor setup question (#2204)
Closes #1987 This PR adds JetBrains editors as an option during the editor setup step. As of writing this, the PR simply writes the Oxc plugin ID to `.idea/externalDependencies.xml`. I plan to go through editor settings (for WebStorm at least) to evaluate which keys are relevant to Oxc and should be set, hence the draft status. ## Up for debate - Should the option be named "JetBrains (IntelliJ, WebStorm, etc)" or is just "WebStorm"/"IntelliJ" enough in the menu? - ~~The CLI also writes `intellij.vitejs` to the `externalDependencies` file. Should this remain in the PR? I'm not confident it supports Vite+ properly.~~ Removed pending better integration from JetBrains' side - Currently the system is a bit hacky given that all other editors read JSON instead of XML. If this becomes a consistent problem (as in, editors start having their own different file formats), then it may be better to rewrite the system a bit so that the file type is automatically detected from the file extension, and all that needs to be supplied to the entry in EDITORS is a key noting the name of the file, and an object describing the shape of the file, with the writing stage formatting it to the expected file type inferred from the file extension before putting it in the file. - ~~It should not 100% work with merging with existing externalDependencies.xml files yet (already having some changes lined up to get this properly working)~~ turns out merging the XML configs is extremely complicated! - ~~Should the CLI also try and set up the JS runtime & package manager options?~~ package manager is set to pnpm by default and it should point to the Vite+ Node shim)
1 parent 842ac5f commit 9525587

7 files changed

Lines changed: 421 additions & 13 deletions

File tree

docs/guide/ide-integration.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,59 @@ You can also manually set up the Zed config:
100100
```
101101

102102
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.
103+
104+
## JetBrains (IntelliJ, WebStorm, etc...)
105+
106+
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.
107+
108+
When you create or migrate a project, Vite+ prompts you to choose whether you want the editor config written for JetBrains IDEs.
109+
110+
::: tip Vite+ does not merge with existing config files
111+
Due to some complexities with merging XML files, Vite+ currently does not merge your current files if the files already exist.
112+
You'll be given the opportunity to replace any existing files, instead of merging.
113+
:::
114+
115+
You can also manually set up the IDE configuration to match your Vite+ setup:
116+
117+
```xml [.idea/externalDependencies.xml]
118+
<?xml version="1.0" encoding="UTF-8"?>
119+
<project version="4">
120+
<component name="ExternalDependencies">
121+
<plugin id="com.github.oxc.project.oxcintellijplugin" />
122+
</component>
123+
</project>
124+
```
125+
126+
```xml [.idea/workspace.xml]
127+
<?xml version="1.0" encoding="UTF-8"?>
128+
<project version="4">
129+
<!-- other settings... -->
130+
<component name="PropertiesComponent">
131+
<![CDATA[{
132+
"keyToString": {
133+
// other settings
134+
"javascript.nodejs.core.library.configured.version": "24.18.0", // Replace with your selected Node.js version
135+
"javascript.nodejs.core.library.typings.version": "24.13.3", // Replace with the version of @types/node that corresponds to your runtime (or omit if you don't want it)
136+
"javascript.preferred.runtime.type.id": "node",
137+
"nodejs_interpreter_path": "$USER_HOME$/.vite-plus/bin/node",
138+
"nodejs_package_manager_path": "pnpm" // Replace with your package manager of choice
139+
}
140+
}]]>
141+
</component>
142+
</project>
143+
```
144+
145+
```xml [.idea/OxfmtSettings.xml]
146+
<?xml version="1.0" encoding="UTF-8"?>
147+
<project version="4">
148+
<component name="OxfmtSettings">
149+
<option name="preferOxfmtCodeStyleSettings" value="true" />
150+
</component>
151+
</project>
152+
```
153+
154+
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:
155+
156+
```gitignore [.idea/.gitignore]
157+
!externalDependencies.xml
158+
```

packages/cli/src/create/bin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
10891089
interactive: options.interactive,
10901090
silent: compactOutput,
10911091
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
1092+
packageManager,
10921093
});
10931094
if (selectedEditors?.includes('vscode')) {
10941095
ensureGitignoreVsCodeEditorConfigs(fullPath);
@@ -1269,6 +1270,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
12691270
interactive: options.interactive,
12701271
silent: compactOutput,
12711272
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
1273+
packageManager,
12721274
});
12731275
if (selectedEditors?.includes('vscode')) {
12741276
ensureGitignoreVsCodeEditorConfigs(fullPath);

packages/cli/src/migration/__tests__/migrator.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8977,3 +8977,41 @@ describe('collectMigrationSetupPlan ESLint gating', () => {
89778977
},
89788978
);
89798979
});
8980+
8981+
describe('collectMigrationSetupPlan non-interactive editor conflicts', () => {
8982+
let tmpDir: string;
8983+
8984+
beforeEach(() => {
8985+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-setup-plan-editor-'));
8986+
writePkgAt(tmpDir, { name: 'x' });
8987+
});
8988+
8989+
afterEach(() => {
8990+
fs.rmSync(tmpDir, { recursive: true, force: true });
8991+
});
8992+
8993+
it('skips (never overwrites) existing non-JSON jetbrains files, only merges JSON-like ones', async () => {
8994+
fs.mkdirSync(path.join(tmpDir, '.idea'), { recursive: true });
8995+
fs.writeFileSync(
8996+
path.join(tmpDir, '.idea', 'externalDependencies.xml'),
8997+
'<project version="4"><component name="Custom"/></project>',
8998+
);
8999+
fs.writeFileSync(path.join(tmpDir, '.idea', 'workspace.xml'), '<project version="4"/>');
9000+
9001+
const plan = await collectMigrationSetupPlan(
9002+
tmpDir,
9003+
PackageManager.pnpm,
9004+
{
9005+
interactive: false,
9006+
hooks: false,
9007+
agent: false as const,
9008+
editor: 'jetbrains',
9009+
},
9010+
undefined,
9011+
false,
9012+
);
9013+
9014+
expect(plan.editorConflictDecisions.get('externalDependencies.xml')).toBe('skip');
9015+
expect(plan.editorConflictDecisions.get('workspace.xml')).toBe('skip');
9016+
});
9017+
});

packages/cli/src/migration/bin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,7 @@ async function executeMigrationPlan(
958958
interactive,
959959
conflictDecisions: plan.editorConflictDecisions,
960960
silent: true,
961+
packageManager: plan.packageManager,
961962
});
962963

963964
// 11. Add framework shims if requested
@@ -1480,6 +1481,7 @@ async function main() {
14801481
interactive: options.interactive,
14811482
conflictDecisions: plan.editorConflictDecisions,
14821483
silent: true,
1484+
packageManager,
14831485
});
14841486
didMigrate = true;
14851487
}

packages/cli/src/migration/setup-plan.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
detectExistingAgentTargetPaths,
99
selectAgentTargetPaths,
1010
} from '../utils/agent.ts';
11-
import { detectEditorConflicts, type EditorId, selectEditor } from '../utils/editor.ts';
11+
import {
12+
detectEditorConflicts,
13+
type EditorId,
14+
isJsonLikeFile,
15+
selectEditor,
16+
} from '../utils/editor.ts';
1217
import { cancelAndExit, promptGitHooks } from '../utils/prompts.ts';
1318
import {
1419
confirmEslintMigration,
@@ -142,7 +147,11 @@ async function collectEditorConfigPlan(
142147
}
143148
editorConflictDecisions.set(conflict.fileName, action);
144149
} else {
145-
editorConflictDecisions.set(conflict.fileName, 'merge');
150+
// Non-JSON files (e.g. JetBrains XML) can't be merged, so only skip them here.
151+
editorConflictDecisions.set(
152+
conflict.fileName,
153+
isJsonLikeFile(conflict.fileName) ? 'merge' : 'skip',
154+
);
146155
}
147156
}
148157

packages/cli/src/utils/__tests__/editor.spec.ts

Lines changed: 173 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import * as prompts from '@voidzero-dev/vite-plus-prompts';
66
import { parse as parseJsonc } from 'jsonc-parser';
77
import { afterEach, describe, expect, it, vi } from 'vitest';
88

9-
import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js';
9+
import {
10+
detectExistingEditors,
11+
selectEditor,
12+
selectEditors,
13+
writeEditorConfigs,
14+
} from '../editor.js';
1015

1116
const tempDirs: string[] = [];
1217

@@ -75,6 +80,80 @@ describe('selectEditors', () => {
7580
}),
7681
).resolves.toEqual(['zed']);
7782
});
83+
84+
it('resolves --editor intellij to jetbrains and warns about the non-canonical ID used', async () => {
85+
const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
86+
87+
await expect(
88+
selectEditors({
89+
interactive: false,
90+
editor: 'intellij',
91+
onCancel: vi.fn(),
92+
}),
93+
).resolves.toEqual(['jetbrains']);
94+
95+
expect(warnSpy).toHaveBeenCalledWith(
96+
expect.stringContaining(
97+
"--editor intellij was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
98+
),
99+
);
100+
});
101+
102+
it('resolves --editor webstorm to jetbrains and warns about the non-canonical ID used', async () => {
103+
const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
104+
105+
await expect(
106+
selectEditors({
107+
interactive: false,
108+
editor: 'webstorm',
109+
onCancel: vi.fn(),
110+
}),
111+
).resolves.toEqual(['jetbrains']);
112+
113+
expect(warnSpy).toHaveBeenCalledWith(
114+
expect.stringContaining(
115+
"--editor webstorm was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
116+
),
117+
);
118+
});
119+
120+
it('does not show intellij/webstorm aliases as interactive TUI options', async () => {
121+
const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['vscode']);
122+
123+
await selectEditors({
124+
interactive: true,
125+
onCancel: vi.fn(),
126+
});
127+
128+
expect(multiselectSpy).toHaveBeenCalledWith(
129+
expect.objectContaining({
130+
options: expect.not.arrayContaining([
131+
expect.objectContaining({ value: 'intellij' }),
132+
expect.objectContaining({ value: 'webstorm' }),
133+
]),
134+
}),
135+
);
136+
});
137+
});
138+
139+
describe('selectEditor', () => {
140+
it('resolves --editor intellij to jetbrains and warns about the non-canonical ID used', async () => {
141+
const warnSpy = vi.spyOn(prompts.log, 'warn').mockImplementation(() => {});
142+
143+
await expect(
144+
selectEditor({
145+
interactive: false,
146+
editor: 'intellij',
147+
onCancel: vi.fn(),
148+
}),
149+
).resolves.toBe('jetbrains');
150+
151+
expect(warnSpy).toHaveBeenCalledWith(
152+
expect.stringContaining(
153+
"--editor intellij was passed; use --editor jetbrains instead, as it's the canonical ID for that editor.",
154+
),
155+
);
156+
});
78157
});
79158

80159
describe('detectExistingEditors', () => {
@@ -91,6 +170,14 @@ describe('detectExistingEditors', () => {
91170
it('returns undefined when no editor config files exist', () => {
92171
expect(detectExistingEditors(createTempDir())).toBeUndefined();
93172
});
173+
174+
it('detects existing jetbrains editor config files', () => {
175+
const projectRoot = createTempDir();
176+
fs.mkdirSync(path.join(projectRoot, '.idea'), { recursive: true });
177+
fs.writeFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), '<project />');
178+
179+
expect(detectExistingEditors(projectRoot)).toEqual(['jetbrains']);
180+
});
94181
});
95182

96183
describe('writeEditorConfigs', () => {
@@ -541,4 +628,89 @@ describe('writeEditorConfigs', () => {
541628
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
542629
expect(zedSettings.lsp).toBeDefined();
543630
});
631+
632+
it('writes all jetbrains editor config files', async () => {
633+
const projectRoot = createTempDir();
634+
635+
await writeEditorConfigs({
636+
projectRoot,
637+
editorId: 'jetbrains',
638+
interactive: false,
639+
silent: true,
640+
});
641+
642+
const externalDependenciesXml = fs.readFileSync(
643+
path.join(projectRoot, '.idea', 'externalDependencies.xml'),
644+
'utf8',
645+
);
646+
expect(externalDependenciesXml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
647+
expect(externalDependenciesXml).toContain(
648+
'<plugin id="com.github.oxc.project.oxcintellijplugin" />',
649+
);
650+
651+
const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
652+
expect(workspaceXml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
653+
expect(workspaceXml).toContain('"javascript.preferred.runtime.type.id": "node"');
654+
expect(workspaceXml).toContain(
655+
`"nodejs_interpreter_path": "$USER_HOME$/.vite-plus/bin/node.exe"`,
656+
);
657+
expect(workspaceXml).toContain('"nodejs_package_manager_path": "pnpm"');
658+
659+
const oxfmtSettingsXml = fs.readFileSync(
660+
path.join(projectRoot, '.idea', 'OxfmtSettings.xml'),
661+
'utf8',
662+
);
663+
expect(oxfmtSettingsXml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
664+
expect(oxfmtSettingsXml).toContain('<component name="OxfmtSettings">');
665+
expect(oxfmtSettingsXml).toContain(
666+
'<option name="preferOxfmtCodeStyleSettings" value="true" />',
667+
);
668+
669+
const gitignore = fs.readFileSync(path.join(projectRoot, '.idea', '.gitignore'), 'utf8');
670+
expect(gitignore).toBe('**\n!externalDependencies.xml\n');
671+
});
672+
673+
it('writes workspace.xml with the resolved package manager', async () => {
674+
const projectRoot = createTempDir();
675+
676+
await writeEditorConfigs({
677+
projectRoot,
678+
editorId: 'jetbrains',
679+
interactive: false,
680+
silent: true,
681+
packageManager: 'yarn',
682+
});
683+
684+
const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
685+
expect(workspaceXml).toContain('"nodejs_package_manager_path": "yarn"');
686+
});
687+
688+
it('does not overwrite existing non-JSON jetbrains file in non-interactive mode', async () => {
689+
const projectRoot = createTempDir();
690+
const xmlPath = path.join(projectRoot, '.idea', 'externalDependencies.xml');
691+
fs.mkdirSync(path.dirname(xmlPath), { recursive: true });
692+
fs.writeFileSync(xmlPath, '<project version="4"><component name="Custom"/></project>', 'utf8');
693+
694+
await writeEditorConfigs({
695+
projectRoot,
696+
editorId: 'jetbrains',
697+
interactive: false,
698+
silent: true,
699+
});
700+
701+
const xml = fs.readFileSync(xmlPath, 'utf8');
702+
expect(xml).toBe('<project version="4"><component name="Custom"/></project>');
703+
704+
const workspaceXml = fs.readFileSync(path.join(projectRoot, '.idea', 'workspace.xml'), 'utf8');
705+
expect(workspaceXml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
706+
707+
const oxfmtSettingsXml = fs.readFileSync(
708+
path.join(projectRoot, '.idea', 'OxfmtSettings.xml'),
709+
'utf8',
710+
);
711+
expect(oxfmtSettingsXml).toContain('<component name="OxfmtSettings">');
712+
713+
const gitignore = fs.readFileSync(path.join(projectRoot, '.idea', '.gitignore'), 'utf8');
714+
expect(gitignore).toBe('**\n!externalDependencies.xml\n');
715+
});
544716
});

0 commit comments

Comments
 (0)