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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'] })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user