Fix WordPress scaffolding gap; add delete project + WP admin link

Found via real usage: creating a WordPress project only produced
wp-content/ + the wp-config.php bridge — ddev config never downloads
WordPress core, and even with core present the site has no database
until `wp core install` runs (confirmed live: 403/302 until fixed).
The wizard's WordPress path now chains configure -> start ->
`wp core download` -> `wp core install` as separate tracked operations
(each needs its own terminal/status lifecycle, same reasoning as the
configure->start split from the original wizard work), with new site
title/admin username/password/email fields shown only for the
WordPress project type. wp-cli requires the containers running, so
"start after creating" is bypassed (always start) for this path rather
than leaving a checkbox that could produce the exact half-built state
this fixes.

Also added, per direct request:
- Delete project: `ddev delete <name> --yes` (keeps ddev's default
  database snapshot as a safety net), confirmed via window.confirm
  with copy clarifying it only removes DDEV's registration/containers/
  database, not the project's files on disk. Clears the app's selection
  on success.
- WP Admin quick-link: for running wordpress-type projects, a header
  button opening {primary_url}/wp-admin/ directly.

Verified end-to-end against a fresh throwaway project via CDP: full
configure->start->download->install chain produced a real working
site (200 on the homepage, proper login redirect on wp-admin, WP Admin
button href matches), and delete genuinely removes the project from
`ddev list` with the confirmation copy rendering correctly. Two
apparent bugs surfaced during this testing turned out to be the test
script reading DOM state before React's re-render or before ddev's
multi-step delete (build+start+snapshot+teardown) had actually
finished — not real defects; re-verified with proper waits.
This commit is contained in:
R3ap3R
2026-08-03 03:15:35 -05:00
parent cf8104f428
commit d1dd8ff87c
7 changed files with 277 additions and 29 deletions
+42
View File
@@ -25,4 +25,46 @@ export function registerCreateIpc(): void {
return runStreamed(operationId, args, event.sender, { cwd: directory }) return runStreamed(operationId, args, event.sender, { cwd: directory })
} }
) )
// `ddev config --project-type=wordpress` only scaffolds the DDEV-managed
// wp-config.php bridge and wp-content/uploads — it doesn't download
// WordPress core (wp-admin, wp-includes, index.php, etc.), and even with
// core downloaded the site has no database tables until `wp core install`
// runs. Both require the project to be started, since wp-cli runs inside
// the web container. Kept as two separate tracked operations rather than
// one combined command so each phase's terminal:exit event correctly owns
// its own status-bar/toast lifecycle (see useCreateProject.ts).
ipcMain.handle('create:downloadWordpress', (event, operationId: string, directory: string) =>
runStreamed(operationId, ['wp', 'core', 'download'], event.sender, { cwd: directory })
)
ipcMain.handle(
'create:setupWordpress',
(
event,
operationId: string,
directory: string,
siteUrl: string,
title: string,
adminUser: string,
adminPassword: string,
adminEmail: string
) =>
runStreamed(
operationId,
[
'wp',
'core',
'install',
`--url=${siteUrl}`,
`--title=${title}`,
`--admin_user=${adminUser}`,
`--admin_password=${adminPassword}`,
`--admin_email=${adminEmail}`,
'--skip-email'
],
event.sender,
{ cwd: directory }
)
)
} }
+6
View File
@@ -14,4 +14,10 @@ export function registerProjectsIpc(): void {
ipcMain.handle('projects:restart', (event, operationId: string, name: string) => ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
runStreamed(operationId, ['restart', name], event.sender) runStreamed(operationId, ['restart', name], event.sender)
) )
// Removes DDEV's project registration + containers + database (auto-
// snapshotted first, unless omitted) — does not touch the project's files
// on disk.
ipcMain.handle('projects:delete', (event, operationId: string, name: string) =>
runStreamed(operationId, ['delete', name, '--yes'], event.sender)
)
} }
+24 -1
View File
@@ -23,7 +23,9 @@ const api = {
stop: (operationId: string, name: string): Promise<void> => stop: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:stop', operationId, name), ipcRenderer.invoke('projects:stop', operationId, name),
restart: (operationId: string, name: string): Promise<void> => restart: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:restart', operationId, name) ipcRenderer.invoke('projects:restart', operationId, name),
delete: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:delete', operationId, name)
}, },
terminal: { terminal: {
cancel: (operationId: string): Promise<boolean> => cancel: (operationId: string): Promise<boolean> =>
@@ -96,6 +98,27 @@ const api = {
projectName, projectName,
projectType, projectType,
docroot docroot
),
downloadWordpress: (operationId: string, directory: string): Promise<void> =>
ipcRenderer.invoke('create:downloadWordpress', operationId, directory),
setupWordpress: (
operationId: string,
directory: string,
siteUrl: string,
title: string,
adminUser: string,
adminPassword: string,
adminEmail: string
): Promise<void> =>
ipcRenderer.invoke(
'create:setupWordpress',
operationId,
directory,
siteUrl,
title,
adminUser,
adminPassword,
adminEmail
) )
}, },
zoom: { zoom: {
@@ -1,6 +1,10 @@
import { useState } from 'react' import { useState } from 'react'
import { FolderOpen, X } from 'lucide-react' import { FolderOpen, X } from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject' import {
useCreateProject,
useDownloadWordpress,
useSetupWordpress
} from '../../hooks/useCreateProject'
import { useStartProject } from '../../hooks/useDdev' import { useStartProject } from '../../hooks/useDdev'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
@@ -25,34 +29,65 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
const [docroot, setDocroot] = useState('') const [docroot, setDocroot] = useState('')
const [startAfterCreate, setStartAfterCreate] = useState(true) const [startAfterCreate, setStartAfterCreate] = useState(true)
const [siteTitle, setSiteTitle] = useState('')
const [adminUser, setAdminUser] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const createProject = useCreateProject() const createProject = useCreateProject()
const startProject = useStartProject() const startProject = useStartProject()
const downloadWordpress = useDownloadWordpress()
const setupWordpress = useSetupWordpress()
const selectProject = useAppStore((s) => s.selectProject) const selectProject = useAppStore((s) => s.selectProject)
const isSubmitting = createProject.isPending || startProject.isPending const isWordpress = projectType === 'wordpress'
const canSubmit = directory !== null && projectName.trim().length > 0 && !isSubmitting const isSubmitting =
createProject.isPending ||
startProject.isPending ||
downloadWordpress.isPending ||
setupWordpress.isPending
const canSubmit =
directory !== null &&
projectName.trim().length > 0 &&
!isSubmitting &&
(!isWordpress || (adminUser.trim() && adminPassword.trim() && adminEmail.trim()))
async function handlePickDirectory(): Promise<void> { async function handlePickDirectory(): Promise<void> {
const picked = await window.api.create.pickDirectory() const picked = await window.api.create.pickDirectory()
if (!picked) return if (!picked) return
setDirectory(picked) setDirectory(picked)
if (!projectName) { if (!projectName) {
setProjectName(picked.split('/').filter(Boolean).pop() ?? '') const name = picked.split('/').filter(Boolean).pop() ?? ''
setProjectName(name)
if (!siteTitle) setSiteTitle(name)
} }
} }
async function handleSubmit(): Promise<void> { async function handleSubmit(): Promise<void> {
if (!directory) return if (!directory) return
await createProject.mutateAsync({ const name = projectName.trim()
directory,
projectName: projectName.trim(), await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
projectType,
docroot if (isWordpress) {
}) // wp-cli needs the containers running, so this ignores the "start
if (startAfterCreate) { // after creating" checkbox — an unstarted WordPress project would
startProject.mutate(projectName.trim()) // just be the same half-built state this whole flow exists to avoid.
await startProject.mutateAsync(name)
await downloadWordpress.mutateAsync({ directory })
await setupWordpress.mutateAsync({
directory,
siteUrl: `https://${name}.ddev.site`,
title: siteTitle.trim() || name,
adminUser: adminUser.trim(),
adminPassword: adminPassword.trim(),
adminEmail: adminEmail.trim()
})
} else if (startAfterCreate) {
startProject.mutate(name)
} }
selectProject(projectName.trim())
selectProject(name)
onClose() onClose()
} }
@@ -70,7 +105,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
</button> </button>
</div> </div>
<div className="flex flex-col gap-4 p-4"> <div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-4">
<div> <div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400"> <label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project folder Project folder
@@ -128,14 +163,69 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
/> />
</div> </div>
<label className="flex items-center gap-2 text-sm"> {isWordpress ? (
<input <div className="flex flex-col gap-3 rounded-md border border-neutral-200 p-3 dark:border-neutral-800">
type="checkbox" <p className="text-xs text-neutral-500 dark:text-neutral-400">
checked={startAfterCreate} WordPress core will be downloaded and installed automatically the project is
onChange={(e) => setStartAfterCreate(e.target.checked)} started as part of this.
/> </p>
Start project after creating <div>
</label> <label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Site title
</label>
<input
type="text"
value={siteTitle}
onChange={(e) => setSiteTitle(e.target.value)}
placeholder={projectName || 'My WordPress Site'}
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">
Admin username
</label>
<input
type="text"
value={adminUser}
onChange={(e) => setAdminUser(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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Admin password
</label>
<input
type="password"
value={adminPassword}
onChange={(e) => setAdminPassword(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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Admin email
</label>
<input
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="[email protected]"
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="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>
<div className="flex justify-end gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800"> <div className="flex justify-end gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { FileText, Play, RotateCw, Square } from 'lucide-react' import { FileText, KeyRound, Play, RotateCw, Square, Trash2 } from 'lucide-react'
import { import {
useDeleteProject,
useProjectDetail, useProjectDetail,
useRestartProject, useRestartProject,
useStartProject, useStartProject,
@@ -10,15 +11,34 @@ import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection' import { DatabaseSection } from './DatabaseSection'
import { AddonsSection } from './AddonsSection' import { AddonsSection } from './AddonsSection'
import { LogViewer } from '../logs/LogViewer' import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore'
export function ProjectDetail({ name }: { name: string }): React.JSX.Element { export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const { data: project, isLoading, isError, error } = useProjectDetail(name) const { data: project, isLoading, isError, error } = useProjectDetail(name)
const startProject = useStartProject() const startProject = useStartProject()
const stopProject = useStopProject() const stopProject = useStopProject()
const restartProject = useRestartProject() const restartProject = useRestartProject()
const deleteProject = useDeleteProject()
const selectProject = useAppStore((s) => s.selectProject)
const [isLogsOpen, setIsLogsOpen] = useState(false) const [isLogsOpen, setIsLogsOpen] = useState(false)
const isBusy = startProject.isPending || stopProject.isPending || restartProject.isPending const isBusy =
startProject.isPending ||
stopProject.isPending ||
restartProject.isPending ||
deleteProject.isPending
function handleDelete(): void {
if (!project) return
const confirmed = window.confirm(
`Delete "${project.name}"? DDEV will take a database snapshot first, then remove the ` +
`project's containers and DDEV registration. Your project files on disk are not touched.`
)
if (!confirmed) return
deleteProject.mutate(project.name, {
onSuccess: () => selectProject(null)
})
}
if (isLoading) { if (isLoading) {
return <div className="p-6 text-sm text-neutral-500">Loading {name}</div> return <div className="p-6 text-sm text-neutral-500">Loading {name}</div>
@@ -77,6 +97,24 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
> >
<FileText size={14} /> Logs <FileText size={14} /> Logs
</button> </button>
{project.type === 'wordpress' && isRunning && (
<a
href={`${project.primary_url}/wp-admin/`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900"
>
<KeyRound size={14} /> WP Admin
</a>
)}
<button
type="button"
disabled={isBusy}
onClick={handleDelete}
className="inline-flex items-center gap-1.5 rounded-md border border-red-300 px-3 py-1.5 text-sm font-medium text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-red-800 dark:text-red-400 dark:hover:bg-red-950"
>
<Trash2 size={14} /> Delete
</button>
</div> </div>
</header> </header>
+47 -4
View File
@@ -9,6 +9,22 @@ export interface CreateProjectInput {
docroot: string docroot: string
} }
export interface WordpressSetupInput {
directory: string
siteUrl: string
title: string
adminUser: string
adminPassword: string
adminEmail: string
}
function beginOperation(label: string): string {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
return operationId
}
// Only runs `ddev config`, not `ddev start` — the caller is expected to // Only runs `ddev config`, not `ddev start` — the caller is expected to
// follow a successful creation with the existing useStartProject() mutation, // follow a successful creation with the existing useStartProject() mutation,
// reusing its own tracked operation/toast lifecycle rather than needing this // reusing its own tracked operation/toast lifecycle rather than needing this
@@ -17,12 +33,39 @@ export function useCreateProject(): UseMutationResult<void, Error, CreateProject
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ directory, projectName, projectType, docroot }: CreateProjectInput) => { mutationFn: async ({ directory, projectName, projectType, docroot }: CreateProjectInput) => {
const operationId = crypto.randomUUID() const operationId = beginOperation(`Create project ${projectName}`)
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) await window.api.create.configure(operationId, directory, projectName, projectType, docroot)
}, },
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }) onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] })
}) })
} }
// `ddev config --project-type=wordpress` only scaffolds the wp-config.php
// bridge, not WordPress core itself — see create.ts in the main process for
// why this and useSetupWordpress are separate tracked operations run after
// the project has been started.
export function useDownloadWordpress(): UseMutationResult<void, Error, { directory: string }> {
return useMutation({
mutationFn: async ({ directory }) => {
const operationId = beginOperation('Download WordPress core')
await window.api.create.downloadWordpress(operationId, directory)
}
})
}
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
return useMutation({
mutationFn: async ({ directory, siteUrl, title, adminUser, adminPassword, adminEmail }) => {
const operationId = beginOperation('Install WordPress')
await window.api.create.setupWordpress(
operationId,
directory,
siteUrl,
title,
adminUser,
adminPassword,
adminEmail
)
}
})
}
+6
View File
@@ -69,3 +69,9 @@ export function useRestartProject(): UseMutationResult<void, Error, string> {
window.api.projects.restart(operationId, name) window.api.projects.restart(operationId, name)
) )
} }
export function useDeleteProject(): UseMutationResult<void, Error, string> {
return useProjectAction('Delete', (operationId, name) =>
window.api.projects.delete(operationId, name)
)
}