Skip to content
Merged
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
18 changes: 9 additions & 9 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ReturnType<typeof selectEditor>>;
let selectedEditors: Awaited<ReturnType<typeof selectEditors>>;
let selectedParentDir: string | undefined;
let remoteTargetDir: string | undefined;
let shouldSetupHooks = false;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down
103 changes: 101 additions & 2 deletions packages/cli/src/utils/__tests__/editor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand All @@ -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();
Expand Down Expand Up @@ -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<string, unknown>;
const vscodeExtensions = JSON.parse(
fs.readFileSync(path.join(projectRoot, '.vscode', 'extensions.json'), 'utf8'),
) as Record<string, unknown>;
const zedSettings = JSON.parse(
fs.readFileSync(path.join(projectRoot, '.zed', 'settings.json'), 'utf8'),
) as Record<string, unknown>;

expect(vscodeSettings['npm.scriptRunner']).toBe('vp');
expect(vscodeExtensions.recommendations).toContain('VoidZero.vite-plus-extension-pack');
expect(zedSettings['npm.scriptRunner']).toBeUndefined();
expect(zedSettings.lsp).toBeDefined();
});
});
107 changes: 102 additions & 5 deletions packages/cli/src/utils/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EditorId[] | undefined> {
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 {
Expand Down Expand Up @@ -270,16 +323,44 @@ export async function writeEditorConfigs({
extraVsCodeSettings,
}: {
projectRoot: string;
editorId: EditorId | undefined;
editorId: EditorSelection;
interactive: boolean;
conflictDecisions?: Map<string, 'merge' | 'skip'>;
silent?: boolean;
extraVsCodeSettings?: Record<string, string>;
}) {
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<string, 'merge' | 'skip'>;
silent: boolean;
extraVsCodeSettings?: Record<string, string>;
}) {
const editorConfig = EDITORS.find((e) => e.id === editorId);
if (!editorConfig) {
return;
Expand All @@ -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) {
Expand Down Expand Up @@ -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<string, unknown>,
Expand Down Expand Up @@ -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;
}
Loading