Skip to content

Commit 90e7beb

Browse files
authored
feat(create): support multi-editor selection (#1438)
Closes #1257. ## Changes - Support selecting multiple editors in the interactive `vp create` flow. - Generate both VSCode and Zed editor configs when both are selected. - Keep `--editor NAME` as a single-editor option. ## Tests - `pnpm exec vp test run packages/cli/src/utils/__tests__/editor.spec.ts` - `pnpm --filter vite-plus build-ts`
1 parent de848b0 commit 90e7beb

3 files changed

Lines changed: 212 additions & 16 deletions

File tree

packages/cli/src/create/bin.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
selectAgentTargetPaths,
2121
writeAgentInstructions,
2222
} from '../utils/agent.ts';
23-
import { detectExistingEditor, selectEditor, writeEditorConfigs } from '../utils/editor.ts';
23+
import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../utils/editor.ts';
2424
import { renderCliDoc } from '../utils/help.ts';
2525
import { displayRelative } from '../utils/path.ts';
2626
import {
@@ -438,7 +438,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
438438
let selectedTemplateName = templateName as string;
439439
let selectedTemplateArgs = [...templateArgs];
440440
let selectedAgentTargetPaths: string[] | undefined;
441-
let selectedEditor: Awaited<ReturnType<typeof selectEditor>>;
441+
let selectedEditors: Awaited<ReturnType<typeof selectEditors>>;
442442
let selectedParentDir: string | undefined;
443443
let remoteTargetDir: string | undefined;
444444
let shouldSetupHooks = false;
@@ -678,13 +678,13 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
678678
onCancel: () => cancelAndExit(),
679679
});
680680

681-
const existingEditor =
681+
const existingEditors =
682682
options.editor || !options.interactive
683683
? undefined
684-
: detectExistingEditor(workspaceInfoOptional.rootDir);
685-
selectedEditor =
686-
existingEditor ??
687-
(await selectEditor({
684+
: detectExistingEditors(workspaceInfoOptional.rootDir);
685+
selectedEditors =
686+
existingEditors ??
687+
(await selectEditors({
688688
interactive: options.interactive,
689689
editor: options.editor,
690690
onCancel: () => cancelAndExit(),
@@ -796,7 +796,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
796796
pauseCreateProgress();
797797
await writeEditorConfigs({
798798
projectRoot: fullPath,
799-
editorId: selectedEditor,
799+
editorId: selectedEditors,
800800
interactive: options.interactive,
801801
silent: compactOutput,
802802
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
@@ -886,7 +886,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
886886
pauseCreateProgress();
887887
await writeEditorConfigs({
888888
projectRoot: fullPath,
889-
editorId: selectedEditor,
889+
editorId: selectedEditors,
890890
interactive: options.interactive,
891891
silent: compactOutput,
892892
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },

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

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
44

5-
import { afterEach, describe, expect, it } from 'vitest';
5+
import * as prompts from '@voidzero-dev/vite-plus-prompts';
6+
import { afterEach, describe, expect, it, vi } from 'vitest';
67

7-
import { writeEditorConfigs } from '../editor.js';
8+
import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js';
89

910
const tempDirs: string[] = [];
1011

@@ -15,11 +16,82 @@ function createTempDir() {
1516
}
1617

1718
afterEach(() => {
19+
vi.restoreAllMocks();
1820
for (const dir of tempDirs.splice(0, tempDirs.length)) {
1921
fs.rmSync(dir, { recursive: true, force: true });
2022
}
2123
});
2224

25+
describe('selectEditors', () => {
26+
it('prompts with editor config targets and supports multiple selections', async () => {
27+
const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['vscode', 'zed']);
28+
29+
await expect(
30+
selectEditors({
31+
interactive: true,
32+
onCancel: vi.fn(),
33+
}),
34+
).resolves.toEqual(['vscode', 'zed']);
35+
36+
expect(multiselectSpy).toHaveBeenCalledWith(
37+
expect.objectContaining({
38+
message: expect.stringContaining('Which editors are you using?'),
39+
initialValues: ['vscode'],
40+
required: false,
41+
options: expect.arrayContaining([
42+
expect.objectContaining({
43+
label: 'VSCode',
44+
value: 'vscode',
45+
hint: '.vscode',
46+
}),
47+
expect.objectContaining({
48+
label: 'Zed',
49+
value: 'zed',
50+
hint: '.zed',
51+
}),
52+
]),
53+
}),
54+
);
55+
});
56+
57+
it('skips editor config selection when no editors are selected', async () => {
58+
vi.spyOn(prompts, 'multiselect').mockResolvedValue([]);
59+
60+
await expect(
61+
selectEditors({
62+
interactive: true,
63+
onCancel: vi.fn(),
64+
}),
65+
).resolves.toBeUndefined();
66+
});
67+
68+
it('keeps explicit --editor selection as a single editor', async () => {
69+
await expect(
70+
selectEditors({
71+
interactive: false,
72+
editor: 'zed',
73+
onCancel: vi.fn(),
74+
}),
75+
).resolves.toEqual(['zed']);
76+
});
77+
});
78+
79+
describe('detectExistingEditors', () => {
80+
it('detects multiple existing editor config directories', () => {
81+
const projectRoot = createTempDir();
82+
fs.mkdirSync(path.join(projectRoot, '.vscode'), { recursive: true });
83+
fs.mkdirSync(path.join(projectRoot, '.zed'), { recursive: true });
84+
fs.writeFileSync(path.join(projectRoot, '.vscode', 'settings.json'), '{}');
85+
fs.writeFileSync(path.join(projectRoot, '.zed', 'settings.json'), '{}');
86+
87+
expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed']);
88+
});
89+
90+
it('returns undefined when no editor config files exist', () => {
91+
expect(detectExistingEditors(createTempDir())).toBeUndefined();
92+
});
93+
});
94+
2395
describe('writeEditorConfigs', () => {
2496
it('writes vscode settings that align formatter config with vite.config.ts', async () => {
2597
const projectRoot = createTempDir();
@@ -177,4 +249,31 @@ describe('writeEditorConfigs', () => {
177249
'./vite.config.ts',
178250
);
179251
});
252+
253+
it('writes multiple editor configs in one call', async () => {
254+
const projectRoot = createTempDir();
255+
256+
await writeEditorConfigs({
257+
projectRoot,
258+
editorId: ['vscode', 'zed'],
259+
interactive: false,
260+
silent: true,
261+
extraVsCodeSettings: { 'npm.scriptRunner': 'vp' },
262+
});
263+
264+
const vscodeSettings = JSON.parse(
265+
fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'),
266+
) as Record<string, unknown>;
267+
const vscodeExtensions = JSON.parse(
268+
fs.readFileSync(path.join(projectRoot, '.vscode', 'extensions.json'), 'utf8'),
269+
) as Record<string, unknown>;
270+
const zedSettings = JSON.parse(
271+
fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'),
272+
) as Record<string, unknown>;
273+
274+
expect(vscodeSettings['npm.scriptRunner']).toBe('vp');
275+
expect(vscodeExtensions.recommendations).toContain('VoidZero.vite-plus-extension-pack');
276+
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
277+
expect(zedSettings.lsp).toBeDefined();
278+
});
180279
});

packages/cli/src/utils/editor.ts

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export const EDITORS = [
155155
] as const;
156156

157157
export type EditorId = (typeof EDITORS)[number]['id'];
158+
type EditorSelection = EditorId | readonly EditorId[] | undefined;
158159

159160
export async function selectEditor({
160161
interactive,
@@ -210,16 +211,68 @@ export async function selectEditor({
210211
return undefined;
211212
}
212213

214+
export async function selectEditors({
215+
interactive,
216+
editor,
217+
onCancel,
218+
}: {
219+
interactive: boolean;
220+
editor?: string | false;
221+
onCancel: () => void;
222+
}): Promise<EditorId[] | undefined> {
223+
if (editor === false) {
224+
return undefined;
225+
}
226+
227+
if (interactive && !editor) {
228+
const selectedEditors = await prompts.multiselect({
229+
message:
230+
'Which editors are you using?\n ' +
231+
styleText(
232+
'gray',
233+
'Writes editor config files to enable recommended extensions and Oxlint/Oxfmt integrations.',
234+
),
235+
options: EDITORS.map((option) => ({
236+
label: option.label,
237+
value: option.id,
238+
hint: option.targetDir,
239+
})),
240+
initialValues: ['vscode'],
241+
required: false,
242+
});
243+
244+
if (prompts.isCancel(selectedEditors)) {
245+
onCancel();
246+
return undefined;
247+
}
248+
249+
return selectedEditors.length === 0 ? undefined : resolveEditorIds(selectedEditors);
250+
}
251+
252+
if (editor) {
253+
const editorId = resolveEditorId(editor);
254+
return editorId ? [editorId] : undefined;
255+
}
256+
257+
return undefined;
258+
}
259+
213260
export function detectExistingEditor(projectRoot: string): EditorId | undefined {
261+
return detectExistingEditors(projectRoot)?.[0];
262+
}
263+
264+
export function detectExistingEditors(projectRoot: string): EditorId[] | undefined {
265+
const editors: EditorId[] = [];
214266
for (const option of EDITORS) {
215267
for (const fileName of Object.keys(option.files)) {
216268
const filePath = path.join(projectRoot, option.targetDir, fileName);
217269
if (fs.existsSync(filePath)) {
218-
return option.id;
270+
editors.push(option.id);
271+
break;
219272
}
220273
}
221274
}
222-
return undefined;
275+
return editors.length === 0 ? undefined : editors;
223276
}
224277

225278
export interface EditorConflictInfo {
@@ -270,16 +323,44 @@ export async function writeEditorConfigs({
270323
extraVsCodeSettings,
271324
}: {
272325
projectRoot: string;
273-
editorId: EditorId | undefined;
326+
editorId: EditorSelection;
274327
interactive: boolean;
275328
conflictDecisions?: Map<string, 'merge' | 'skip'>;
276329
silent?: boolean;
277330
extraVsCodeSettings?: Record<string, string>;
278331
}) {
279-
if (!editorId) {
332+
const editorIds = normalizeEditorSelection(editorId);
333+
if (editorIds.length === 0) {
280334
return;
281335
}
282336

337+
for (const currentEditorId of editorIds) {
338+
await writeEditorConfig({
339+
projectRoot,
340+
editorId: currentEditorId,
341+
interactive,
342+
conflictDecisions,
343+
silent,
344+
extraVsCodeSettings,
345+
});
346+
}
347+
}
348+
349+
async function writeEditorConfig({
350+
projectRoot,
351+
editorId,
352+
interactive,
353+
conflictDecisions,
354+
silent,
355+
extraVsCodeSettings,
356+
}: {
357+
projectRoot: string;
358+
editorId: EditorId;
359+
interactive: boolean;
360+
conflictDecisions?: Map<string, 'merge' | 'skip'>;
361+
silent: boolean;
362+
extraVsCodeSettings?: Record<string, string>;
363+
}) {
283364
const editorConfig = EDITORS.find((e) => e.id === editorId);
284365
if (!editorConfig) {
285366
return;
@@ -300,7 +381,7 @@ export async function writeEditorConfigs({
300381

301382
// Determine conflict action from pre-resolved decisions, interactive prompt, or default
302383
let conflictAction: 'merge' | 'skip';
303-
const preResolved = conflictDecisions?.get(fileName);
384+
const preResolved = conflictDecisions?.get(displayPath) ?? conflictDecisions?.get(fileName);
304385
if (preResolved) {
305386
conflictAction = preResolved;
306387
} else if (interactive) {
@@ -348,6 +429,13 @@ export async function writeEditorConfigs({
348429
}
349430
}
350431

432+
function normalizeEditorSelection(editorId: EditorSelection): EditorId[] {
433+
if (!editorId) {
434+
return [];
435+
}
436+
return [...new Set(Array.isArray(editorId) ? editorId : [editorId])];
437+
}
438+
351439
function mergeAndWriteEditorConfig(
352440
filePath: string,
353441
incoming: Record<string, unknown>,
@@ -416,3 +504,12 @@ function resolveEditorId(editor: string): EditorId | undefined {
416504
);
417505
return match?.id;
418506
}
507+
508+
function resolveEditorIds(editors: readonly string[]): EditorId[] | undefined {
509+
const editorIds = editors.flatMap((editor) => {
510+
const editorId = resolveEditorId(editor);
511+
return editorId ? [editorId] : [];
512+
});
513+
const uniqueEditorIds = [...new Set(editorIds)];
514+
return uniqueEditorIds.length === 0 ? undefined : uniqueEditorIds;
515+
}

0 commit comments

Comments
 (0)