Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/guide/ide-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalDependencies">
<plugin id="com.github.oxc.project.oxcintellijplugin" />
<plugin id="intellij.vitejs" />
</component>
</project>
```

```xml [.idea/workspace.xml]
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<!-- other settings... -->
<component name="PropertiesComponent">
<![CDATA[{
"keyToString": {
// other settings
"javascript.nodejs.core.library.configured.version": "24.18.0", // Replace with your selected Node.js version
"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)
"javascript.preferred.runtime.type.id": "node",
"nodejs_interpreter_path": "~/.vite-plus/bin/node", // Replace ~ with the path to your home directory, IntelliJ/WebStorm don't understand ~
"nodejs_package_manager_path": "pnpm", // Replace with your package manager of choice
}
}]]>
</component>
</project>
```

```xml [.idea/OxfmtSettings.xml]
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="OxfmtSettings">
<option name="preferOxfmtCodeStyleSettings" value="true" />
</component>
</project>
```

```gitignore [.idea/.gitignore]
!externalDependencies.xml
```
40 changes: 40 additions & 0 deletions packages/cli/src/utils/__tests__/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '<project />');

expect(detectExistingEditors(projectRoot)).toEqual(['jetbrains']);
});
});

describe('writeEditorConfigs', () => {
Expand Down Expand Up @@ -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('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain('<component name="ExternalDependencies" />');
});

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, '<project version="4"><component name="Custom"/></project>', 'utf8');

await writeEditorConfigs({
projectRoot,
editorId: 'jetbrains',
interactive: false,
silent: true,
});

const xml = fs.readFileSync(xmlPath, 'utf8');
expect(xml).toBe('<project version="4"><component name="Custom"/></project>');
});
});
102 changes: 94 additions & 8 deletions packages/cli/src/utils/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalDependencies">
<plugin id="com.github.oxc.project.oxcintellijplugin" />
<plugin id="intellij.vitejs" />
</component>
</project>
`;

// TODO: Extend this to allow XML files to be authored as JSON objects too
// e.g. `Record<string, unknown> | string` -> add a dedicated XML-object type once an XML parser/differ is written
type EditorConfigValue = Record<string, unknown> | string;

export const EDITORS = [
{
id: 'vscode',
Expand All @@ -168,8 +183,25 @@ export const EDITORS = [
'settings.json': ZED_SETTINGS as Record<string, unknown>,
},
},
{
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;

Expand Down Expand Up @@ -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}`;
Expand All @@ -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',
Expand All @@ -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}`);
Expand All @@ -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 [];
Expand Down