Skip to content
Open
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
57 changes: 28 additions & 29 deletions electron/handlers/projectHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as path from 'path';
import * as fs from 'fs';
import { spawn } from 'child_process';
import { scaffoldProject } from '../project/scaffold';
import { createStarter } from '../project/starter';
import { createStarter, newSiteDirectory } from '../project/starter';
import { isWin, errorMessage } from '../lib/mainHelpers';
import { ensureToolPath, run } from '../lib/nodeTools';
import { isAstroProject, detectPackageManager } from '../project/projectFiles';
Expand Down Expand Up @@ -46,26 +46,6 @@ function registerProjectOpenDialogHandlers({
}
return { canceled: false as const, projectPath: directory };
});

ipcMain.handle('project:newDialog', async () => {
const result = await showOpenDialog({
title: 'Choose an empty folder for the new project',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled || !result.filePaths.length) {
return { canceled: true as const };
}
const directory = result.filePaths[0];
assert(directory !== undefined, 'Accepted directory dialog must have a path');
const entries = fs.readdirSync(directory).filter((name) => !name.startsWith('.'));
if (entries.length > 0) {
return {
canceled: false as const,
error: 'That folder is not empty. Choose or create an empty folder for the new project.',
};
}
return { canceled: false as const, projectPath: directory };
});
}

function registerProjectCreateAstroHandlers({
Expand All @@ -76,20 +56,21 @@ function registerProjectCreateAstroHandlers({
// Runs the real `npm create astro@latest`, answering the questions the CLI
// would ask interactively with the choices collected in the app's wizard.
// Output is streamed to the renderer so the user sees the same progress the
// terminal would show. The chosen folder is the cwd and "." the target, so
// create-astro never has to guess a name from a parent directory.
// terminal would show. The wizard names the site, so its folder is made here,
// empty, and create-astro runs in it with "." as the target rather than
// guessing a name.
ipcMain.handle('project:createAstro', async (_event, options) => {
const {
dir: directory,
parentPath,
name,
template = 'basics',
install = true,
git = true,
ai = false,
} = options || {};
if (!directory || !fs.existsSync(directory)) {
throw new Error('Choose a folder for the new project first.');
}
const directory = newSiteDirectory(parentPath, name);
ensureToolPath(); // npm is a Node shim — same PATH problem as astro
fs.mkdirSync(directory);

const args = [
'create',
Expand All @@ -106,8 +87,8 @@ function registerProjectCreateAstroHandlers({
];

send('create:log', `> npm ${args.join(' ')}\n\n`);
await runCreateAstro({ send }, directory, args);
return { ok: true as const, installed: install };
await runCreateAstroInNewFolder({ send }, directory, args);
return { ok: true as const, installed: install, projectPath: directory };
});

// A folder to put a new project IN, rather than the project's own folder: the
Expand Down Expand Up @@ -189,6 +170,24 @@ export async function installDependencies({ send }: Pick<MainHost, 'send'>, dire
// a structured clone, which keeps the absent value.
export const PROGRESS_CLEARED = { message: undefined } as const;

// A run that wrote nothing leaves no folder behind, so Try again can reuse the
// name. Anything it did write stays for the user to inspect; nothing is deleted
// recursively.
async function runCreateAstroInNewFolder(
{ send }: Pick<MainHost, 'send'>,
directory: string,
args: readonly string[],
): Promise<void> {
try {
await runCreateAstro({ send }, directory, args);
} catch (error: unknown) {
if (fs.readdirSync(directory).length === 0) {
fs.rmdirSync(directory);
}
throw error;
}
}

// The create-astro run itself, its output streamed to the wizard's log. It
// settles once the CLI exits and a package.json has landed in `directory`.
function runCreateAstro(
Expand Down
1 change: 0 additions & 1 deletion electron/preload/preloadBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ function projectBridge() {

// Project:
openProjectDialog: invoke('project:openDialog'),
newProjectDialog: invoke('project:newDialog'),
scaffoldProject: invoke('project:scaffold'),
createAstroProject: invoke('project:createAstro'),
parentDialog: invoke('project:parentDialog'),
Expand Down
44 changes: 27 additions & 17 deletions electron/project/starter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,27 +121,14 @@ async function createStarter({
if (!template) {
throw new Error(`${starter} is not a starter this app knows.`);
}
if (!parentPath || !fs.existsSync(parentPath)) {
throw new Error('Choose where the site should go first.');
}

const folder = String(name || '').trim();
if (!folder) {
throw new Error('Give the site a name.');
}
if (!NAME_RE.test(folder)) {
throw new Error('Use letters, numbers, dashes, dots or underscores for the folder name.');
}
const directory = path.join(parentPath, folder);
if (fs.existsSync(directory)) {
throw new Error(`${folder} already exists in that folder.`);
}
const directory = newSiteDirectory(parentPath, name);
const folder = path.basename(directory);

// `--no-install`: the app installs afterwards, where a failure is an error
// the wizard can show rather than a line in a log that scrolled past.
const args = ['create', template.create, folder, '--yes', '--', '--no-install'];
onLog?.(`> npm create ${template.create} ${folder}\n\n`);
await run(npm || (isWin ? 'npm.cmd' : 'npm'), args, parentPath, onLog);
await run(npm || (isWin ? 'npm.cmd' : 'npm'), args, path.dirname(directory), onLog);

if (!fs.existsSync(path.join(directory, 'package.json'))) {
throw new Error('The starter finished but there is no package.json in it.');
Expand Down Expand Up @@ -195,4 +182,27 @@ async function createStarter({
return { ok: true, projectPath: directory };
}

export { createStarter, STARTERS };
/**
* The folder a new site gets, `parentPath/name`, which must not exist yet. The
* starter and the Astro wizard both use it, so the name rules and their
* messages are the same in either flow.
*/
function newSiteDirectory(parentPath: string | undefined, name: string | undefined): string {
if (!parentPath || !fs.existsSync(parentPath)) {
throw new Error('Choose where the site should go first.');
}
const folder = String(name || '').trim();
if (!folder) {
throw new Error('Give the site a name.');
}
if (!NAME_RE.test(folder)) {
throw new Error('Use letters, numbers, dashes, dots or underscores for the folder name.');
}
const directory = path.join(parentPath, folder);
if (fs.existsSync(directory)) {
throw new Error(`${folder} already exists in that folder.`);
}
return directory;
}

export { createStarter, newSiteDirectory, STARTERS };
4 changes: 2 additions & 2 deletions shared/ipc/ipcPayloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ export const IPC_PAYLOADS = {
'project:classes': pathText,
'project:close': maybePath,
'project:createAstro': object({
dir: pathText,
parentPath: pathText,
name: text,
template: optional(text),
install: maybeFlag,
git: maybeFlag,
Expand All @@ -273,7 +274,6 @@ export const IPC_PAYLOADS = {
'project:hasNodeModules': pathText,
'project:injectedRoutes': object({ ...project }),
'project:install': pathText,
'project:newDialog': nothing,
'project:openDialog': nothing,
'project:parentDialog': nothing,
'project:pending': nothing,
Expand Down
17 changes: 1 addition & 16 deletions shared/ipc/ipcResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ export interface IpcResults {
readonly 'project:createAstro': {
readonly ok: true;
readonly installed: boolean;
readonly projectPath: string;
};
readonly 'project:createStarter': {
readonly ok: boolean;
Expand All @@ -571,22 +572,6 @@ export interface IpcResults {
readonly 'project:install': {
readonly ok: true;
};
readonly 'project:newDialog':
| {
readonly canceled: true;
readonly error?: never;
readonly projectPath?: never;
}
| {
readonly canceled: false;
readonly error: string;
readonly projectPath?: never;
}
| {
readonly canceled: false;
readonly projectPath: string;
readonly error?: never;
};
readonly 'project:openDialog':
| {
readonly canceled: true;
Expand Down
1 change: 0 additions & 1 deletion shared/ipc/preloadApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ interface InvokeChannels {
readonly studioStatus: 'studio:status';
readonly installStudio: 'studio:install';
readonly openProjectDialog: 'project:openDialog';
readonly newProjectDialog: 'project:newDialog';
readonly scaffoldProject: 'project:scaffold';
readonly createAstroProject: 'project:createAstro';
readonly parentDialog: 'project:parentDialog';
Expand Down
5 changes: 4 additions & 1 deletion src/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -7982,10 +7982,13 @@ textarea::selection {
}

/* Start from a template: a name, where it goes, and what that means. */
.starter-field {
/* Beats `.modal-body label { display: block }`, which would drop the gap and leave the
field's name jammed against its input. The gap matches the template question's 6px. */
.modal-body label.starter-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 0;
}
.starter-field > span {
font-size: 11.5px;
Expand Down
22 changes: 10 additions & 12 deletions src/features/welcome/WelcomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import WelcomeSplitButton from './WelcomeSplitButton';
import React, { useEffect, useRef, useState } from 'react';
import {
chooseExistingProject,
chooseNewProjectDirectory,
chooseStarterParent,
listRecentProjects,
refreshRecentThumbnail,
Expand All @@ -24,10 +23,10 @@ interface WelcomeScreenProps {
export default function WelcomeScreen({ onOpen, showToast }: WelcomeScreenProps) {
const [cloning, setCloning] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const [newProjectDirectory, setNewProjectDirectory] = useState<string | undefined>(undefined);
const [newProjectParent, setNewProjectParent] = useState<string | undefined>(undefined);
const [starterDirectory, setStarterDirectory] = useState<string | undefined>(undefined);
const recent = useRecentProjects(showToast);
const actions = useWelcomeActions(onOpen, setError, setNewProjectDirectory, setStarterDirectory);
const actions = useWelcomeActions(onOpen, setError, setNewProjectParent, setStarterDirectory);

return (
<div className="welcome">
Expand Down Expand Up @@ -66,12 +65,12 @@ export default function WelcomeScreen({ onOpen, showToast }: WelcomeScreenProps)
}}
/>
)}
{newProjectDirectory && (
{newProjectParent && (
<NewProjectWizard
directory={newProjectDirectory}
onClose={() => setNewProjectDirectory(undefined)}
parentPath={newProjectParent}
onClose={() => setNewProjectParent(undefined)}
onDone={(directory) => {
setNewProjectDirectory(undefined);
setNewProjectParent(undefined);
showToast('Project created', 'success');
onOpen(directory);
}}
Expand All @@ -84,7 +83,7 @@ export default function WelcomeScreen({ onOpen, showToast }: WelcomeScreenProps)
function useWelcomeActions(
onOpen: WelcomeScreenProps['onOpen'],
setError: React.Dispatch<React.SetStateAction<string | undefined>>,
setNewProjectDirectory: React.Dispatch<React.SetStateAction<string | undefined>>,
setNewProjectParent: React.Dispatch<React.SetStateAction<string | undefined>>,
setStarterDirectory: React.Dispatch<React.SetStateAction<string | undefined>>,
) {
const openExisting = async (): Promise<void> => {
Expand All @@ -98,15 +97,14 @@ function useWelcomeActions(
onOpen(result.value.projectPath);
}
};
// Both wizards ask where the site should go and name its folder themselves.
const createNew = async (): Promise<void> => {
setError(undefined);
const result = await chooseNewProjectDirectory();
const result = await chooseStarterParent();
if (!result.ok) {
setError(result.error);
} else if (!result.value.canceled && 'error' in result.value) {
setError(result.value.error);
} else if (!result.value.canceled) {
setNewProjectDirectory(result.value.projectPath);
setNewProjectParent(result.value.parentPath);
}
};
const startFromLumos = async (): Promise<void> => {
Expand Down
Loading
Loading