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:
@@ -25,4 +25,46 @@ export function registerCreateIpc(): void {
|
||||
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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,4 +14,10 @@ export function registerProjectsIpc(): void {
|
||||
ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
|
||||
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
@@ -23,7 +23,9 @@ const api = {
|
||||
stop: (operationId: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke('projects:stop', operationId, name),
|
||||
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: {
|
||||
cancel: (operationId: string): Promise<boolean> =>
|
||||
@@ -96,6 +98,27 @@ const api = {
|
||||
projectName,
|
||||
projectType,
|
||||
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: {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from '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 { useAppStore } from '../../stores/appStore'
|
||||
|
||||
@@ -25,34 +29,65 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
const [docroot, setDocroot] = useState('')
|
||||
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 startProject = useStartProject()
|
||||
const downloadWordpress = useDownloadWordpress()
|
||||
const setupWordpress = useSetupWordpress()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
|
||||
const isSubmitting = createProject.isPending || startProject.isPending
|
||||
const canSubmit = directory !== null && projectName.trim().length > 0 && !isSubmitting
|
||||
const isWordpress = projectType === 'wordpress'
|
||||
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> {
|
||||
const picked = await window.api.create.pickDirectory()
|
||||
if (!picked) return
|
||||
setDirectory(picked)
|
||||
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> {
|
||||
if (!directory) return
|
||||
await createProject.mutateAsync({
|
||||
directory,
|
||||
projectName: projectName.trim(),
|
||||
projectType,
|
||||
docroot
|
||||
})
|
||||
if (startAfterCreate) {
|
||||
startProject.mutate(projectName.trim())
|
||||
const name = projectName.trim()
|
||||
|
||||
await createProject.mutateAsync({ directory, projectName: name, projectType, docroot })
|
||||
|
||||
if (isWordpress) {
|
||||
// wp-cli needs the containers running, so this ignores the "start
|
||||
// after creating" checkbox — an unstarted WordPress project would
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -70,7 +105,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
</button>
|
||||
</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>
|
||||
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
Project folder
|
||||
@@ -128,14 +163,69 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
|
||||
/>
|
||||
</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>
|
||||
{isWordpress ? (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
WordPress core will be downloaded and installed automatically — the project is
|
||||
started as part of this.
|
||||
</p>
|
||||
<div>
|
||||
<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 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 { FileText, Play, RotateCw, Square } from 'lucide-react'
|
||||
import { FileText, KeyRound, Play, RotateCw, Square, Trash2 } from 'lucide-react'
|
||||
import {
|
||||
useDeleteProject,
|
||||
useProjectDetail,
|
||||
useRestartProject,
|
||||
useStartProject,
|
||||
@@ -10,15 +11,34 @@ import { StatusBadge } from './StatusBadge'
|
||||
import { DatabaseSection } from './DatabaseSection'
|
||||
import { AddonsSection } from './AddonsSection'
|
||||
import { LogViewer } from '../logs/LogViewer'
|
||||
import { useAppStore } from '../../stores/appStore'
|
||||
|
||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
||||
const startProject = useStartProject()
|
||||
const stopProject = useStopProject()
|
||||
const restartProject = useRestartProject()
|
||||
const deleteProject = useDeleteProject()
|
||||
const selectProject = useAppStore((s) => s.selectProject)
|
||||
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) {
|
||||
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
|
||||
</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>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -9,6 +9,22 @@ export interface CreateProjectInput {
|
||||
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
|
||||
// follow a successful creation with the existing useStartProject() mutation,
|
||||
// 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()
|
||||
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)
|
||||
const operationId = beginOperation(`Create project ${projectName}`)
|
||||
await window.api.create.configure(operationId, directory, projectName, projectType, docroot)
|
||||
},
|
||||
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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,3 +69,9 @@ export function useRestartProject(): UseMutationResult<void, Error, string> {
|
||||
window.api.projects.restart(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeleteProject(): UseMutationResult<void, Error, string> {
|
||||
return useProjectAction('Delete', (operationId, name) =>
|
||||
window.api.projects.delete(operationId, name)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user