Add project creation wizard (step 7)

New project flow: native directory picker, project name (auto-filled
from folder name), project type selector (Auto-detect plus common
CMS/framework types), optional docroot, and a "start after creating"
checkbox. Runs ddev config with cwd = the chosen directory (streamed
through the existing commandRunner), then reuses the existing
useStartProject() mutation for the start step rather than inventing
multi-phase operation semantics — ddev config registers the project
by name, so a plain `ddev start <name>` works identically to starting
any other project afterward.

Scoped down from the original app's wizard: skipped scaffolding a
fresh CMS codebase (composer create-project / wp core download /
etc.) since each framework needs different install commands — this
covers project registration + config + start, with ddev's own
--auto/--project-type detection handling "auto-detect for existing
folders" for free (no custom detection heuristics needed).

Verified end-to-end against the real scratch environment: called the
create.configure IPC directly (the picker itself opens a native OS
dialog CDP can't drive, same constraint as import/export in step 4)
with a real target directory, confirmed the generated .ddev/config.yaml
has the correct name/type/docroot, confirmed the new project appears
in the sidebar via the existing polling within one refresh cycle, and
verified the modal's form guards (Create disabled until a directory
is chosen) and all type-selector options render correctly. Cleaned up
via `ddev delete`.
This commit is contained in:
R3ap3R
2026-08-03 00:19:39 -05:00
parent 49293ac34c
commit 541d719680
6 changed files with 251 additions and 1 deletions
+2
View File
@@ -7,6 +7,7 @@ import { registerTerminalIpc } from './ipc/terminal'
import { registerDatabaseIpc } from './ipc/database'
import { registerAddonsIpc } from './ipc/addons'
import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create'
import { killAllRunningCommands } from './commandRunner'
function createWindow(): void {
@@ -61,6 +62,7 @@ app.whenReady().then(() => {
registerDatabaseIpc()
registerAddonsIpc()
registerLogsIpc()
registerCreateIpc()
createWindow()
+28
View File
@@ -0,0 +1,28 @@
import { dialog, ipcMain } from 'electron'
import { runStreamed } from '../commandRunner'
export function registerCreateIpc(): void {
ipcMain.handle('create:pickDirectory', async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory']
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle(
'create:configure',
(
event,
operationId: string,
directory: string,
projectName: string,
projectType: string,
docroot: string
) => {
const args = ['config', `--project-name=${projectName}`, '--auto']
if (projectType) args.push(`--project-type=${projectType}`)
if (docroot.trim()) args.push(`--docroot=${docroot.trim()}`)
return runStreamed(operationId, args, event.sender, { cwd: directory })
}
)
}
+18
View File
@@ -79,6 +79,24 @@ const api = {
ipcRenderer.on('logs:exit', listener)
return () => ipcRenderer.removeListener('logs:exit', listener)
}
},
create: {
pickDirectory: (): Promise<string | null> => ipcRenderer.invoke('create:pickDirectory'),
configure: (
operationId: string,
directory: string,
projectName: string,
projectType: string,
docroot: string
): Promise<void> =>
ipcRenderer.invoke(
'create:configure',
operationId,
directory,
projectName,
projectType,
docroot
)
}
}
+14 -1
View File
@@ -1,21 +1,33 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel'
import { StatusBar } from './components/layout/StatusBar'
import { Toaster } from './components/ui/Toaster'
import { CreateProjectModal } from './components/create/CreateProjectModal'
import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents'
function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
const [isCreateOpen, setIsCreateOpen] = useState(false)
useTerminalEvents()
return (
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
<div className="border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
<button
type="button"
onClick={() => setIsCreateOpen(true)}
title="New Project"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<ProjectList />
@@ -34,6 +46,7 @@ function App(): React.JSX.Element {
<TerminalPanel />
<StatusBar />
<Toaster />
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
</div>
)
}
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { FolderOpen, X } from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject'
import { useStartProject } from '../../hooks/useDdev'
import { useAppStore } from '../../stores/appStore'
const PROJECT_TYPES = [
{ value: '', label: 'Auto-detect' },
{ value: 'php', label: 'PHP (generic)' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'drupal', label: 'Drupal' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'backdrop', label: 'Backdrop' },
{ value: 'craftcms', label: 'Craft CMS' },
{ value: 'magento2', label: 'Magento 2' },
{ value: 'shopware6', label: 'Shopware 6' },
{ value: 'symfony', label: 'Symfony' },
{ value: 'typo3', label: 'TYPO3' }
]
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const [directory, setDirectory] = useState<string | null>(null)
const [projectName, setProjectName] = useState('')
const [projectType, setProjectType] = useState('')
const [docroot, setDocroot] = useState('')
const [startAfterCreate, setStartAfterCreate] = useState(true)
const createProject = useCreateProject()
const startProject = useStartProject()
const selectProject = useAppStore((s) => s.selectProject)
const isSubmitting = createProject.isPending || startProject.isPending
const canSubmit = directory !== null && projectName.trim().length > 0 && !isSubmitting
async function handlePickDirectory(): Promise<void> {
const picked = await window.api.create.pickDirectory()
if (!picked) return
setDirectory(picked)
if (!projectName) {
setProjectName(picked.split('/').filter(Boolean).pop() ?? '')
}
}
async function handleSubmit(): Promise<void> {
if (!directory) return
await createProject.mutateAsync({
directory,
projectName: projectName.trim(),
projectType,
docroot
})
if (startAfterCreate) {
startProject.mutate(projectName.trim())
}
selectProject(projectName.trim())
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h2 className="text-sm font-semibold">New Project</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-4 p-4">
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project folder
</label>
<button
type="button"
onClick={handlePickDirectory}
className="flex w-full items-center gap-2 rounded-md border border-dashed border-neutral-300 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
>
<FolderOpen size={16} className="flex-shrink-0 text-neutral-400" />
<span className="truncate">{directory ?? 'Choose a folder…'}</span>
</button>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project name
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-project"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project type
</label>
<select
value={projectType}
onChange={(e) => setProjectType(e.target.value)}
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
>
{PROJECT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Docroot (optional)
</label>
<input
type="text"
value={docroot}
onChange={(e) => setDocroot(e.target.value)}
placeholder="e.g. web, public — leave blank for project root"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={startAfterCreate}
onChange={(e) => setStartAfterCreate(e.target.checked)}
/>
Start project after creating
</label>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
Cancel
</button>
<button
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900"
>
Create
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,28 @@
import { useMutation, useQueryClient, type UseMutationResult } from '@tanstack/react-query'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
export interface CreateProjectInput {
directory: string
projectName: string
projectType: string
docroot: string
}
// Only runs `ddev config`, not `ddev start` — the caller is expected to
// follow a successful creation with the existing useStartProject() mutation,
// reusing its own tracked operation/toast lifecycle rather than needing this
// one to juggle two unrelated command phases under a single operationId.
export function useCreateProject(): UseMutationResult<void, Error, CreateProjectInput> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ directory, projectName, projectType, docroot }: CreateProjectInput) => {
const operationId = crypto.randomUUID()
const label = `Create project ${projectName}`
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
await window.api.create.configure(operationId, directory, projectName, projectType, docroot)
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
})
}