4 Commits
Author SHA1 Message Date
reaperandClaude Sonnet 5 29158d32ba Fix Drupal 403 (missing core) and add opt-in file deletion on project delete
Same class of gap as the earlier WordPress fix: `ddev config
--project-type=drupal*` only scaffolds DDEV's settings.php bridge, not
Drupal core itself, so a freshly created Drupal project 404/403'd with
nothing for the webserver to serve. The wizard's Drupal path now chains
configure -> start -> composer create-project drupal/recommended-project
-> composer require drush/drush -> drush site:install, with site
name/admin fields shown only for that project type. Docroot defaults to
`web` when Drupal is selected, matching what recommended-project's
installer-paths expect.

Also: Delete previously always left the project folder on disk with no
way to remove it from the app, which surprised a user expecting it gone.
Replaced the window.confirm with a proper modal that has an opt-in
"also delete project files from disk" checkbox, defaulting to the
previous (safe) behavior.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 20:38:11 -05:00
reaperandClaude Sonnet 5 9e2b0d00b9 Bump version to 1.0.1
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 20:13:02 -05:00
reaperandClaude Sonnet 5 0fc1a10a18 Remove stray npm lockfile
This project uses pnpm (pnpm-lock.yaml, pnpm-workspace.yaml) — the
package-lock.json was an accidental npm-install artifact. Ignore it
going forward so it doesn't get re-added by mistake.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 20:07:36 -05:00
reaperandClaude Sonnet 5 68a5f80c31 Power off all DDEV projects on app quit
Sites were previously left running in the background after closing the
app. before-quit now defers quitting until `ddev poweroff` finishes (or
times out), stopping every running project's containers in one shot.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-08 19:58:00 -05:00
14 changed files with 471 additions and 11586 deletions
+1
View File
@@ -5,3 +5,4 @@ out
._* ._*
.eslintcache .eslintcache
*.log* *.log*
package-lock.json
-11561
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aurora-dockside", "name": "aurora-dockside",
"version": "1.0.0", "version": "1.0.2",
"description": "A desktop GUI for managing DDEV local development environments", "description": "A desktop GUI for managing DDEV local development environments",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"homepage": "http://localhost:3000/reaper/aurora-dockside", "homepage": "http://localhost:3000/reaper/aurora-dockside",
+20
View File
@@ -155,3 +155,23 @@ export function killAllRunningCommands(): void {
} }
running.clear() running.clear()
} }
// Stops every running project's containers in one shot (equivalent to
// `ddev stop` on each of them, but faster) so nothing is left running in the
// background after the app quits. Resolves rather than rejects on any
// failure — a missing/unresponsive ddev or docker shouldn't block quitting —
// and the timeout guards against poweroff hanging if the docker daemon is
// stuck, which would otherwise stall app exit indefinitely.
export function powerOffAllProjects(): Promise<void> {
return new Promise((resolve) => {
const child = spawn('ddev', ['poweroff'], { env: ENV_WITH_DDEV_PATH, stdio: 'ignore' })
const timeout = setTimeout(() => child.kill(), 10_000)
const finish = (): void => {
clearTimeout(timeout)
resolve()
}
child.on('error', finish)
child.on('close', finish)
})
}
+13 -4
View File
@@ -9,7 +9,7 @@ import { registerAddonsIpc } from './ipc/addons'
import { registerLogsIpc } from './ipc/logs' import { registerLogsIpc } from './ipc/logs'
import { registerCreateIpc } from './ipc/create' import { registerCreateIpc } from './ipc/create'
import { registerWindowIpc } from './ipc/window' import { registerWindowIpc } from './ipc/window'
import { killAllRunningCommands } from './commandRunner' import { killAllRunningCommands, powerOffAllProjects } from './commandRunner'
import { warmupDdevImagesIfNeeded } from './imageWarmup' import { warmupDdevImagesIfNeeded } from './imageWarmup'
function createWindow(): void { function createWindow(): void {
@@ -89,10 +89,19 @@ app.on('window-all-closed', () => {
} }
}) })
// Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream) // Kill any still-running ddev processes (e.g. an open `ddev logs -f` stream),
// so they don't linger as orphans after the app exits. // then power off every running project so sites don't keep running in the
app.on('before-quit', () => { // background after the app exits. Quit is deferred until poweroff finishes
// (or times out), so this intercepts the first before-quit and re-fires
// app.quit() itself once cleanup is done — the isQuitting guard stops that
// from looping back into this handler.
let isQuitting = false
app.on('before-quit', (event) => {
if (isQuitting) return
event.preventDefault()
isQuitting = true
killAllRunningCommands() killAllRunningCommands()
void powerOffAllProjects().finally(() => app.quit())
}) })
// In this file you can include the rest of your app's specific main process // In this file you can include the rest of your app's specific main process
+53
View File
@@ -21,6 +21,9 @@ export function registerCreateIpc(): void {
) => { ) => {
const args = ['config', `--project-name=${projectName}`, '--auto'] const args = ['config', `--project-name=${projectName}`, '--auto']
if (projectType) args.push(`--project-type=${projectType}`) if (projectType) args.push(`--project-type=${projectType}`)
// ddev creates a missing docroot automatically (--create-docroot is a
// deprecated no-op as of DDEV v1.25) — Drupal's docroot ('web') gets
// created here, ahead of the composer install that populates it.
if (docroot.trim()) args.push(`--docroot=${docroot.trim()}`) if (docroot.trim()) args.push(`--docroot=${docroot.trim()}`)
return runStreamed(operationId, args, event.sender, { cwd: directory }) return runStreamed(operationId, args, event.sender, { cwd: directory })
} }
@@ -71,4 +74,54 @@ export function registerCreateIpc(): void {
return runStreamed(operationId, args, event.sender, { cwd: directory }) return runStreamed(operationId, args, event.sender, { cwd: directory })
} }
) )
// Same class of gap as WordPress: `ddev config --project-type=drupal*`
// only writes DDEV's settings.php bridge, not Drupal core — that's what
// was producing the 403 (empty docroot, nothing for the webserver to
// serve). `drupal/recommended-project`'s own composer.json places core
// under web/ via installer-paths, so this runs from the project root
// (matching the --docroot=web set by create:configure), not inside web/.
ipcMain.handle('create:downloadDrupal', (event, operationId: string, directory: string) =>
runStreamed(
operationId,
['composer', 'create-project', 'drupal/recommended-project'],
event.sender,
{ cwd: directory }
)
)
// drush was dropped from drupal/recommended-project's default
// dependencies in recent Drupal releases, so `drush site:install` has
// nothing to run without adding it explicitly first.
ipcMain.handle('create:requireDrush', (event, operationId: string, directory: string) =>
runStreamed(operationId, ['composer', 'require', 'drush/drush'], event.sender, {
cwd: directory
})
)
ipcMain.handle(
'create:setupDrupal',
(
event,
operationId: string,
directory: string,
siteName: string,
adminUser: string,
adminPassword: string,
adminEmail: string,
profile: string
) => {
const args = [
'drush',
'site:install',
profile || 'standard',
`--site-name=${siteName}`,
`--account-name=${adminUser}`,
`--account-pass=${adminPassword}`,
`--account-mail=${adminEmail}`,
'-y'
]
return runStreamed(operationId, args, event.sender, { cwd: directory })
}
)
} }
+14 -4
View File
@@ -1,4 +1,5 @@
import { ipcMain } from 'electron' import { ipcMain } from 'electron'
import { rm } from 'fs/promises'
import { describeProject, listProjects } from '../ddev' import { describeProject, listProjects } from '../ddev'
import { runStreamed } from '../commandRunner' import { runStreamed } from '../commandRunner'
@@ -19,10 +20,19 @@ export function registerProjectsIpc(): void {
runStreamed(operationId, ['restart', name, '-y'], event.sender) runStreamed(operationId, ['restart', name, '-y'], event.sender)
) )
// Removes DDEV's project registration + containers + database (auto- // Removes DDEV's project registration + containers + database (auto-
// snapshotted first, unless omitted) — does not touch the project's files // snapshotted first, unless omitted). Project files on disk are only
// on disk. // touched if the caller opts in via deleteFiles — approot comes from the
ipcMain.handle('projects:delete', (event, operationId: string, name: string) => // renderer's already-fetched project data (not user-typed), and is only
runStreamed(operationId, ['delete', name, '--yes'], event.sender) // used once `ddev delete` has actually succeeded, so a failed delete never
// touches the filesystem.
ipcMain.handle(
'projects:delete',
async (event, operationId: string, name: string, approot: string, deleteFiles: boolean) => {
await runStreamed(operationId, ['delete', name, '--yes'], event.sender)
if (deleteFiles) {
await rm(approot, { recursive: true, force: true })
}
}
) )
// Reconfigures a project's PHP version, web server, database, or Xdebug // Reconfigures a project's PHP version, web server, database, or Xdebug
+30 -2
View File
@@ -25,8 +25,13 @@ const api = {
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> => delete: (
ipcRenderer.invoke('projects:delete', operationId, name), operationId: string,
name: string,
approot: string,
deleteFiles: boolean
): Promise<void> =>
ipcRenderer.invoke('projects:delete', operationId, name, approot, deleteFiles),
updateEnvironment: ( updateEnvironment: (
operationId: string, operationId: string,
name: string, name: string,
@@ -129,6 +134,29 @@ const api = {
adminPassword, adminPassword,
adminEmail, adminEmail,
multisite multisite
),
downloadDrupal: (operationId: string, directory: string): Promise<void> =>
ipcRenderer.invoke('create:downloadDrupal', operationId, directory),
requireDrush: (operationId: string, directory: string): Promise<void> =>
ipcRenderer.invoke('create:requireDrush', operationId, directory),
setupDrupal: (
operationId: string,
directory: string,
siteName: string,
adminUser: string,
adminPassword: string,
adminEmail: string,
profile: string
): Promise<void> =>
ipcRenderer.invoke(
'create:setupDrupal',
operationId,
directory,
siteName,
adminUser,
adminPassword,
adminEmail,
profile
) )
}, },
zoom: { zoom: {
@@ -16,6 +16,7 @@ import {
import { useCreateProject } from '../../hooks/useCreateProject' import { useCreateProject } from '../../hooks/useCreateProject'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
import { getTypeLabel, PROJECT_TYPES } from './types/registry' import { getTypeLabel, PROJECT_TYPES } from './types/registry'
import { DrupalSetup } from './types/DrupalSetup'
import { GenericSetup } from './types/GenericSetup' import { GenericSetup } from './types/GenericSetup'
import { WordpressSetup } from './types/WordpressSetup' import { WordpressSetup } from './types/WordpressSetup'
import type { TypeSetupHandle } from './types/shared' import type { TypeSetupHandle } from './types/shared'
@@ -228,7 +229,15 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
<button <button
key={t.value} key={t.value}
type="button" type="button"
onClick={() => setProjectType(t.value)} onClick={() => {
setProjectType(t.value)
// drupal/recommended-project's installer-paths
// expect a `web` docroot — default it in so a
// fresh Drupal project doesn't end up serving
// from an unexpected root (only if the user
// hasn't already typed a docroot themselves).
if (t.value === 'drupal' && !docroot.trim()) setDocroot('web')
}}
className={clsx( className={clsx(
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition', 'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
isSelected isSelected
@@ -275,6 +284,12 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
projectName={projectName.trim()} projectName={projectName.trim()}
onValidityChange={setSetupValid} onValidityChange={setSetupValid}
/> />
) : projectType === 'drupal' ? (
<DrupalSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
) : ( ) : (
<GenericSetup <GenericSetup
ref={setupRef} ref={setupRef}
@@ -0,0 +1,170 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { ChevronDown, ChevronUp, KeyRound, Layers3, Mail, Type, UserRound } from 'lucide-react'
import { useDownloadDrupal, useRequireDrush, useSetupDrupal } from '../../../hooks/useCreateProject'
import { useStartProject } from '../../../hooks/useDdev'
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
const PROFILES = [
{ value: 'standard', label: 'Standard' },
{ value: 'minimal', label: 'Minimal' },
{ value: 'demo_umami', label: 'Umami demo' }
]
const inputClass =
'w-full rounded-lg border border-neutral-300 bg-white/80 px-3 py-2 text-sm shadow-sm transition placeholder:text-neutral-400 focus:border-cyan-400 dark:border-white/10 dark:bg-neutral-950/70 dark:placeholder:text-neutral-600'
const labelClass =
'mb-1.5 block text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400'
export const DrupalSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function DrupalSetup(
{ projectName, onValidityChange },
ref
) {
const [siteName, setSiteName] = useState('')
const [adminUser, setAdminUser] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [profile, setProfile] = useState('standard')
const startProject = useStartProject()
const downloadDrupal = useDownloadDrupal()
const requireDrush = useRequireDrush()
const setupDrupal = useSetupDrupal()
const isValid =
adminUser.trim().length > 0 && adminPassword.trim().length > 0 && adminEmail.trim().length > 0
useEffect(() => {
onValidityChange(isValid)
}, [isValid, onValidityChange])
useImperativeHandle(ref, () => ({
runPostCreate: async ({ directory, projectName: name }: TypeSetupContext) => {
// drush needs the containers running, so this ignores any "start
// after creating" preference — an unstarted Drupal project would just
// be the same half-built state this whole flow exists to avoid.
await startProject.mutateAsync(name)
await downloadDrupal.mutateAsync({ directory })
await requireDrush.mutateAsync({ directory })
await setupDrupal.mutateAsync({
directory,
siteName: siteName.trim() || name,
adminUser: adminUser.trim(),
adminPassword: adminPassword.trim(),
adminEmail: adminEmail.trim(),
profile
})
}
}))
return (
<div className="grid gap-4">
<div className="rounded-xl border border-cyan-200 bg-cyan-50 p-4 dark:border-cyan-400/20 dark:bg-cyan-400/10">
<div className="flex items-start gap-3">
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950">
<Layers3 size={18} />
</div>
<div>
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">Drupal install</p>
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
Core downloads via Composer and the project starts for setup.
</p>
</div>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className={labelClass}>Site name</label>
<div className="relative">
<Type
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
/>
<input
type="text"
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder={projectName || 'My Drupal Site'}
className={`${inputClass} pl-9`}
/>
</div>
</div>
<div>
<label className={labelClass}>Admin username</label>
<div className="relative">
<UserRound
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
/>
<input
type="text"
value={adminUser}
onChange={(e) => setAdminUser(e.target.value)}
className={`${inputClass} pl-9`}
/>
</div>
</div>
<div>
<label className={labelClass}>Admin password</label>
<div className="relative">
<KeyRound
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
/>
<input
type="password"
value={adminPassword}
onChange={(e) => setAdminPassword(e.target.value)}
className={`${inputClass} pl-9`}
/>
</div>
</div>
<div className="sm:col-span-2">
<label className={labelClass}>Admin email</label>
<div className="relative">
<Mail
size={15}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400"
/>
<input
type="email"
value={adminEmail}
onChange={(e) => setAdminEmail(e.target.value)}
placeholder="[email protected]"
className={`${inputClass} pl-9`}
/>
</div>
</div>
</div>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="flex items-center justify-between rounded-xl border border-neutral-200 bg-white/70 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 transition hover:border-cyan-200 hover:bg-cyan-50/50 hover:text-cyan-800 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-200"
>
<span>Advanced options</span>
{showAdvanced ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
</button>
{showAdvanced && (
<div className="grid gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<div>
<label className={labelClass}>Install profile</label>
<select
value={profile}
onChange={(e) => setProfile(e.target.value)}
className={inputClass}
>
{PROFILES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</div>
</div>
)}
</div>
)
})
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
export function DeleteProjectModal({
projectName,
onCancel,
onConfirm
}: {
projectName: string
onCancel: () => void
onConfirm: (deleteFiles: boolean) => void
}): React.JSX.Element {
const [deleteFiles, setDeleteFiles] = useState(false)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
<div className="grid w-full max-w-md gap-4 rounded-2xl border border-white/70 bg-white p-5 shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950">
<div className="flex items-start gap-3">
<div className="grid size-10 flex-shrink-0 place-items-center rounded-lg bg-red-100 text-red-600 dark:bg-red-500/10 dark:text-red-400">
<AlertTriangle size={18} />
</div>
<div>
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">
Delete &quot;{projectName}&quot;?
</h2>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
DDEV will take a database snapshot first, then remove the project&apos;s containers
and DDEV registration.
</p>
</div>
</div>
<label className="flex items-start gap-3 rounded-xl border border-neutral-200 bg-neutral-50/70 p-3 text-sm dark:border-white/10 dark:bg-white/[0.04]">
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
className="mt-0.5 size-4 accent-red-600"
/>
<span>
<span className="block font-semibold text-neutral-900 dark:text-neutral-100">
Also delete project files from disk
</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
Permanently removes the project folder. This cannot be undone by the database
snapshot.
</span>
</span>
</label>
<div className="flex items-center justify-end gap-3 pt-1">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
Cancel
</button>
<button
type="button"
onClick={() => onConfirm(deleteFiles)}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-red-900/20 transition hover:bg-red-500"
>
{deleteFiles ? 'Delete project + files' : 'Delete project'}
</button>
</div>
</div>
</div>
)
}
@@ -27,6 +27,7 @@ import {
import { StatusBadge } from './StatusBadge' import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection' import { DatabaseSection } from './DatabaseSection'
import { AddonsSection } from './AddonsSection' import { AddonsSection } from './AddonsSection'
import { DeleteProjectModal } from './DeleteProjectModal'
import { LogViewer } from '../logs/LogViewer' import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore' import { useAppStore } from '../../stores/appStore'
@@ -75,6 +76,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const updateEnvironment = useUpdateEnvironment() const updateEnvironment = useUpdateEnvironment()
const selectProject = useAppStore((s) => s.selectProject) const selectProject = useAppStore((s) => s.selectProject)
const [isLogsOpen, setIsLogsOpen] = useState(false) const [isLogsOpen, setIsLogsOpen] = useState(false)
const [isDeleteOpen, setIsDeleteOpen] = useState(false)
const isBusy = const isBusy =
startProject.isPending || startProject.isPending ||
@@ -84,16 +86,13 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
function handleDelete(): void { function handleDeleteConfirm(deleteFiles: boolean): void {
if (!project) return if (!project) return
const confirmed = window.confirm( setIsDeleteOpen(false)
`Delete "${project.name}"? DDEV will take a database snapshot first, then remove the ` + deleteProject.mutate(
`project's containers and DDEV registration. Your project files on disk are not touched.` { name: project.name, approot: project.approot, deleteFiles },
{ onSuccess: () => selectProject(null) }
) )
if (!confirmed) return
deleteProject.mutate(project.name, {
onSuccess: () => selectProject(null)
})
} }
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> { async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
@@ -210,7 +209,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
<button <button
type="button" type="button"
disabled={isBusy} disabled={isBusy}
onClick={handleDelete} onClick={() => setIsDeleteOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40" className="inline-flex items-center gap-1.5 rounded-md border border-red-300/20 bg-red-400/10 px-3 py-1.5 text-sm font-medium text-red-100 transition hover:bg-red-400/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
> >
<Trash2 size={14} /> Delete <Trash2 size={14} /> Delete
@@ -395,6 +394,14 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
onClose={() => setIsLogsOpen(false)} onClose={() => setIsLogsOpen(false)}
/> />
)} )}
{isDeleteOpen && (
<DeleteProjectModal
projectName={project.name}
onCancel={() => setIsDeleteOpen(false)}
onConfirm={handleDeleteConfirm}
/>
)}
</div> </div>
) )
} }
@@ -19,6 +19,15 @@ export interface WordpressSetupInput {
multisite: 'none' | 'subdirectory' | 'subdomain' multisite: 'none' | 'subdirectory' | 'subdomain'
} }
export interface DrupalSetupInput {
directory: string
siteName: string
adminUser: string
adminPassword: string
adminEmail: string
profile: string
}
function beginOperation(label: string): string { function beginOperation(label: string): string {
const operationId = crypto.randomUUID() const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label) useTerminalStore.getState().startOperation(operationId, label)
@@ -83,3 +92,42 @@ export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSet
} }
}) })
} }
// `ddev config --project-type=drupal*` only scaffolds DDEV's settings.php
// bridge, not Drupal core itself — see create.ts in the main process for why
// this, useRequireDrush, and useSetupDrupal are separate tracked operations
// run after the project has been started.
export function useDownloadDrupal(): UseMutationResult<void, Error, { directory: string }> {
return useMutation({
mutationFn: async ({ directory }) => {
const operationId = beginOperation('Download Drupal core')
await window.api.create.downloadDrupal(operationId, directory)
}
})
}
export function useRequireDrush(): UseMutationResult<void, Error, { directory: string }> {
return useMutation({
mutationFn: async ({ directory }) => {
const operationId = beginOperation('Add Drush')
await window.api.create.requireDrush(operationId, directory)
}
})
}
export function useSetupDrupal(): UseMutationResult<void, Error, DrupalSetupInput> {
return useMutation({
mutationFn: async ({ directory, siteName, adminUser, adminPassword, adminEmail, profile }) => {
const operationId = beginOperation('Install Drupal')
await window.api.create.setupDrupal(
operationId,
directory,
siteName,
adminUser,
adminPassword,
adminEmail,
profile
)
}
})
}
+19 -4
View File
@@ -70,10 +70,25 @@ export function useRestartProject(): UseMutationResult<void, Error, string> {
) )
} }
export function useDeleteProject(): UseMutationResult<void, Error, string> { export function useDeleteProject(): UseMutationResult<
return useProjectAction('Delete', (operationId, name) => void,
window.api.projects.delete(operationId, name) Error,
) { name: string; approot: string; deleteFiles: boolean }
> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name, approot, deleteFiles }) => {
const operationId = crypto.randomUUID()
const label = `Delete ${name}`
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
await window.api.projects.delete(operationId, name, approot, deleteFiles)
},
onSettled: (_data, _error, { name }) => {
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
}
})
} }
// Only runs `ddev config` — the caller is expected to follow a successful // Only runs `ddev config` — the caller is expected to follow a successful