diff --git a/src/main/ipc/create.ts b/src/main/ipc/create.ts index cee4613..4373155 100644 --- a/src/main/ipc/create.ts +++ b/src/main/ipc/create.ts @@ -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 } + ) + ) } diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 4993c93..fd34035 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -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) + ) } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c3d3d3..fd89b54 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,7 +23,9 @@ const api = { stop: (operationId: string, name: string): Promise => ipcRenderer.invoke('projects:stop', operationId, name), restart: (operationId: string, name: string): Promise => - ipcRenderer.invoke('projects:restart', operationId, name) + ipcRenderer.invoke('projects:restart', operationId, name), + delete: (operationId: string, name: string): Promise => + ipcRenderer.invoke('projects:delete', operationId, name) }, terminal: { cancel: (operationId: string): Promise => @@ -96,6 +98,27 @@ const api = { projectName, projectType, docroot + ), + downloadWordpress: (operationId: string, directory: string): Promise => + ipcRenderer.invoke('create:downloadWordpress', operationId, directory), + setupWordpress: ( + operationId: string, + directory: string, + siteUrl: string, + title: string, + adminUser: string, + adminPassword: string, + adminEmail: string + ): Promise => + ipcRenderer.invoke( + 'create:setupWordpress', + operationId, + directory, + siteUrl, + title, + adminUser, + adminPassword, + adminEmail ) }, zoom: { diff --git a/src/renderer/src/components/create/CreateProjectModal.tsx b/src/renderer/src/components/create/CreateProjectModal.tsx index e90975f..ee8b3d0 100644 --- a/src/renderer/src/components/create/CreateProjectModal.tsx +++ b/src/renderer/src/components/create/CreateProjectModal.tsx @@ -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 { 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 { 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. -
+
- + {isWordpress ? ( +
+

+ WordPress core will be downloaded and installed automatically — the project is + started as part of this. +

+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + setAdminEmail(e.target.value)} + placeholder="admin@example.com" + className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950" + /> +
+
+ ) : ( + + )}
diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index c4e293a..04af83a 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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
Loading {name}…
@@ -77,6 +97,24 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element { > Logs + {project.type === 'wordpress' && isRunning && ( + + WP Admin + + )} +
diff --git a/src/renderer/src/hooks/useCreateProject.ts b/src/renderer/src/hooks/useCreateProject.ts index 71fbdc5..fd99d44 100644 --- a/src/renderer/src/hooks/useCreateProject.ts +++ b/src/renderer/src/hooks/useCreateProject.ts @@ -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 { - 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 { + return useMutation({ + mutationFn: async ({ directory }) => { + const operationId = beginOperation('Download WordPress core') + await window.api.create.downloadWordpress(operationId, directory) + } + }) +} + +export function useSetupWordpress(): UseMutationResult { + 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 + ) + } + }) +} diff --git a/src/renderer/src/hooks/useDdev.ts b/src/renderer/src/hooks/useDdev.ts index eee0194..e194057 100644 --- a/src/renderer/src/hooks/useDdev.ts +++ b/src/renderer/src/hooks/useDdev.ts @@ -69,3 +69,9 @@ export function useRestartProject(): UseMutationResult { window.api.projects.restart(operationId, name) ) } + +export function useDeleteProject(): UseMutationResult { + return useProjectAction('Delete', (operationId, name) => + window.api.projects.delete(operationId, name) + ) +}