Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29158d32ba |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aurora-dockside",
|
"name": "aurora-dockside",
|
||||||
"version": "1.0.1",
|
"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",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 "{projectName}"?
|
||||||
|
</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'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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user