diff --git a/packages/cli/src/create/bin.ts b/packages/cli/src/create/bin.ts index 9511e8ab9b..b52ba83619 100644 --- a/packages/cli/src/create/bin.ts +++ b/packages/cli/src/create/bin.ts @@ -20,7 +20,7 @@ import { selectAgentTargetPaths, writeAgentInstructions, } from '../utils/agent.ts'; -import { detectExistingEditor, selectEditor, writeEditorConfigs } from '../utils/editor.ts'; +import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../utils/editor.ts'; import { renderCliDoc } from '../utils/help.ts'; import { displayRelative } from '../utils/path.ts'; import { @@ -438,7 +438,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h let selectedTemplateName = templateName as string; let selectedTemplateArgs = [...templateArgs]; let selectedAgentTargetPaths: string[] | undefined; - let selectedEditor: Awaited>; + let selectedEditors: Awaited>; let selectedParentDir: string | undefined; let remoteTargetDir: string | undefined; let shouldSetupHooks = false; @@ -678,13 +678,13 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h onCancel: () => cancelAndExit(), }); - const existingEditor = + const existingEditors = options.editor || !options.interactive ? undefined - : detectExistingEditor(workspaceInfoOptional.rootDir); - selectedEditor = - existingEditor ?? - (await selectEditor({ + : detectExistingEditors(workspaceInfoOptional.rootDir); + selectedEditors = + existingEditors ?? + (await selectEditors({ interactive: options.interactive, editor: options.editor, onCancel: () => cancelAndExit(), @@ -796,7 +796,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h pauseCreateProgress(); await writeEditorConfigs({ projectRoot: fullPath, - editorId: selectedEditor, + editorId: selectedEditors, interactive: options.interactive, silent: compactOutput, extraVsCodeSettings: { 'npm.scriptRunner': 'vp' }, @@ -886,7 +886,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h pauseCreateProgress(); await writeEditorConfigs({ projectRoot: fullPath, - editorId: selectedEditor, + editorId: selectedEditors, interactive: options.interactive, silent: compactOutput, extraVsCodeSettings: { 'npm.scriptRunner': 'vp' }, diff --git a/packages/cli/src/utils/__tests__/editor.spec.ts b/packages/cli/src/utils/__tests__/editor.spec.ts index e1e82d65db..3aab6a6767 100644 --- a/packages/cli/src/utils/__tests__/editor.spec.ts +++ b/packages/cli/src/utils/__tests__/editor.spec.ts @@ -2,9 +2,10 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { writeEditorConfigs } from '../editor.js'; +import { detectExistingEditors, selectEditors, writeEditorConfigs } from '../editor.js'; const tempDirs: string[] = []; @@ -15,11 +16,82 @@ function createTempDir() { } afterEach(() => { + vi.restoreAllMocks(); for (const dir of tempDirs.splice(0, tempDirs.length)) { fs.rmSync(dir, { recursive: true, force: true }); } }); +describe('selectEditors', () => { + it('prompts with editor config targets and supports multiple selections', async () => { + const multiselectSpy = vi.spyOn(prompts, 'multiselect').mockResolvedValue(['vscode', 'zed']); + + await expect( + selectEditors({ + interactive: true, + onCancel: vi.fn(), + }), + ).resolves.toEqual(['vscode', 'zed']); + + expect(multiselectSpy).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Which editors are you using?'), + initialValues: ['vscode'], + required: false, + options: expect.arrayContaining([ + expect.objectContaining({ + label: 'VSCode', + value: 'vscode', + hint: '.vscode', + }), + expect.objectContaining({ + label: 'Zed', + value: 'zed', + hint: '.zed', + }), + ]), + }), + ); + }); + + it('skips editor config selection when no editors are selected', async () => { + vi.spyOn(prompts, 'multiselect').mockResolvedValue([]); + + await expect( + selectEditors({ + interactive: true, + onCancel: vi.fn(), + }), + ).resolves.toBeUndefined(); + }); + + it('keeps explicit --editor selection as a single editor', async () => { + await expect( + selectEditors({ + interactive: false, + editor: 'zed', + onCancel: vi.fn(), + }), + ).resolves.toEqual(['zed']); + }); +}); + +describe('detectExistingEditors', () => { + it('detects multiple existing editor config directories', () => { + const projectRoot = createTempDir(); + fs.mkdirSync(path.join(projectRoot, '.vscode'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.zed'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.vscode', 'settings.json'), '{}'); + fs.writeFileSync(path.join(projectRoot, '.zed', 'settings.json'), '{}'); + + expect(detectExistingEditors(projectRoot)).toEqual(['vscode', 'zed']); + }); + + it('returns undefined when no editor config files exist', () => { + expect(detectExistingEditors(createTempDir())).toBeUndefined(); + }); +}); + describe('writeEditorConfigs', () => { it('writes vscode settings that align formatter config with vite.config.ts', async () => { const projectRoot = createTempDir(); @@ -177,4 +249,31 @@ describe('writeEditorConfigs', () => { './vite.config.ts', ); }); + + it('writes multiple editor configs in one call', async () => { + const projectRoot = createTempDir(); + + await writeEditorConfigs({ + projectRoot, + editorId: ['vscode', 'zed'], + interactive: false, + silent: true, + extraVsCodeSettings: { 'npm.scriptRunner': 'vp' }, + }); + + const vscodeSettings = JSON.parse( + fs.readFileSync(path.join(projectRoot, '.vscode', 'settings.json'), 'utf8'), + ) as Record; + const vscodeExtensions = JSON.parse( + fs.readFileSync(path.join(projectRoot, '.vscode', 'extensions.json'), 'utf8'), + ) as Record; + const zedSettings = JSON.parse( + fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'), + ) as Record; + + expect(vscodeSettings['npm.scriptRunner']).toBe('vp'); + expect(vscodeExtensions.recommendations).toContain('VoidZero.vite-plus-extension-pack'); + expect(zedSettings['npm.scriptRunner']).toBeUndefined(); + expect(zedSettings.lsp).toBeDefined(); + }); }); diff --git a/packages/cli/src/utils/editor.ts b/packages/cli/src/utils/editor.ts index baed2f8579..b73426de94 100644 --- a/packages/cli/src/utils/editor.ts +++ b/packages/cli/src/utils/editor.ts @@ -155,6 +155,7 @@ export const EDITORS = [ ] as const; export type EditorId = (typeof EDITORS)[number]['id']; +type EditorSelection = EditorId | readonly EditorId[] | undefined; export async function selectEditor({ interactive, @@ -210,16 +211,68 @@ export async function selectEditor({ return undefined; } +export async function selectEditors({ + interactive, + editor, + onCancel, +}: { + interactive: boolean; + editor?: string | false; + onCancel: () => void; +}): Promise { + if (editor === false) { + return undefined; + } + + if (interactive && !editor) { + const selectedEditors = await prompts.multiselect({ + message: + 'Which editors are you using?\n ' + + styleText( + 'gray', + 'Writes editor config files to enable recommended extensions and Oxlint/Oxfmt integrations.', + ), + options: EDITORS.map((option) => ({ + label: option.label, + value: option.id, + hint: option.targetDir, + })), + initialValues: ['vscode'], + required: false, + }); + + if (prompts.isCancel(selectedEditors)) { + onCancel(); + return undefined; + } + + return selectedEditors.length === 0 ? undefined : resolveEditorIds(selectedEditors); + } + + if (editor) { + const editorId = resolveEditorId(editor); + return editorId ? [editorId] : undefined; + } + + return undefined; +} + export function detectExistingEditor(projectRoot: string): EditorId | undefined { + return detectExistingEditors(projectRoot)?.[0]; +} + +export function detectExistingEditors(projectRoot: string): EditorId[] | undefined { + const editors: EditorId[] = []; for (const option of EDITORS) { for (const fileName of Object.keys(option.files)) { const filePath = path.join(projectRoot, option.targetDir, fileName); if (fs.existsSync(filePath)) { - return option.id; + editors.push(option.id); + break; } } } - return undefined; + return editors.length === 0 ? undefined : editors; } export interface EditorConflictInfo { @@ -270,16 +323,44 @@ export async function writeEditorConfigs({ extraVsCodeSettings, }: { projectRoot: string; - editorId: EditorId | undefined; + editorId: EditorSelection; interactive: boolean; conflictDecisions?: Map; silent?: boolean; extraVsCodeSettings?: Record; }) { - if (!editorId) { + const editorIds = normalizeEditorSelection(editorId); + if (editorIds.length === 0) { return; } + for (const currentEditorId of editorIds) { + await writeEditorConfig({ + projectRoot, + editorId: currentEditorId, + interactive, + conflictDecisions, + silent, + extraVsCodeSettings, + }); + } +} + +async function writeEditorConfig({ + projectRoot, + editorId, + interactive, + conflictDecisions, + silent, + extraVsCodeSettings, +}: { + projectRoot: string; + editorId: EditorId; + interactive: boolean; + conflictDecisions?: Map; + silent: boolean; + extraVsCodeSettings?: Record; +}) { const editorConfig = EDITORS.find((e) => e.id === editorId); if (!editorConfig) { return; @@ -300,7 +381,7 @@ export async function writeEditorConfigs({ // Determine conflict action from pre-resolved decisions, interactive prompt, or default let conflictAction: 'merge' | 'skip'; - const preResolved = conflictDecisions?.get(fileName); + const preResolved = conflictDecisions?.get(displayPath) ?? conflictDecisions?.get(fileName); if (preResolved) { conflictAction = preResolved; } else if (interactive) { @@ -348,6 +429,13 @@ export async function writeEditorConfigs({ } } +function normalizeEditorSelection(editorId: EditorSelection): EditorId[] { + if (!editorId) { + return []; + } + return [...new Set(Array.isArray(editorId) ? editorId : [editorId])]; +} + function mergeAndWriteEditorConfig( filePath: string, incoming: Record, @@ -416,3 +504,12 @@ function resolveEditorId(editor: string): EditorId | undefined { ); return match?.id; } + +function resolveEditorIds(editors: readonly string[]): EditorId[] | undefined { + const editorIds = editors.flatMap((editor) => { + const editorId = resolveEditorId(editor); + return editorId ? [editorId] : []; + }); + const uniqueEditorIds = [...new Set(editorIds)]; + return uniqueEditorIds.length === 0 ? undefined : uniqueEditorIds; +}