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 { registerDatabaseIpc } from './ipc/database'
|
||||||
import { registerAddonsIpc } from './ipc/addons'
|
import { registerAddonsIpc } from './ipc/addons'
|
||||||
import { registerLogsIpc } from './ipc/logs'
|
import { registerLogsIpc } from './ipc/logs'
|
||||||
|
import { registerCreateIpc } from './ipc/create'
|
||||||
import { killAllRunningCommands } from './commandRunner'
|
import { killAllRunningCommands } from './commandRunner'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
@@ -61,6 +62,7 @@ app.whenReady().then(() => {
|
|||||||
registerDatabaseIpc()
|
registerDatabaseIpc()
|
||||||
registerAddonsIpc()
|
registerAddonsIpc()
|
||||||
registerLogsIpc()
|
registerLogsIpc()
|
||||||
|
registerCreateIpc()
|
||||||
|
|
||||||
createWindow()
|
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)
|
ipcRenderer.on('logs:exit', listener)
|
||||||
return () => ipcRenderer.removeListener('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 { ProjectDetail } from './components/projects/ProjectDetail'
|
||||||
import { ProjectList } from './components/projects/ProjectList'
|
import { ProjectList } from './components/projects/ProjectList'
|
||||||
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
import { TerminalPanel } from './components/terminal/TerminalPanel'
|
||||||
import { StatusBar } from './components/layout/StatusBar'
|
import { StatusBar } from './components/layout/StatusBar'
|
||||||
import { Toaster } from './components/ui/Toaster'
|
import { Toaster } from './components/ui/Toaster'
|
||||||
|
import { CreateProjectModal } from './components/create/CreateProjectModal'
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
import { useTerminalEvents } from './hooks/useTerminalEvents'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||||
useTerminalEvents()
|
useTerminalEvents()
|
||||||
|
|
||||||
return (
|
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 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">
|
<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">
|
<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>
|
<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>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ProjectList />
|
<ProjectList />
|
||||||
@@ -34,6 +46,7 @@ function App(): React.JSX.Element {
|
|||||||
<TerminalPanel />
|
<TerminalPanel />
|
||||||
<StatusBar />
|
<StatusBar />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
{isCreateOpen && <CreateProjectModal onClose={() => setIsCreateOpen(false)} />}
|
||||||
</div>
|
</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