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
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>');
});
});
80 changes: 72 additions & 8 deletions packages/cli/src/utils/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ const ZED_SETTINGS = {
},
} as const;

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>
`;

type EditorConfigValue = Record<string, unknown> | string;

export const EDITORS = [
{
id: 'vscode',
Expand All @@ -168,6 +179,14 @@ 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;

export type EditorId = (typeof EDITORS)[number]['id'];
Expand Down Expand Up @@ -382,11 +401,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 +422,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 +444,19 @@ 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 +465,46 @@ 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';
}

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