Split project-type setup into pluggable registry; add WordPress admin link and delete flow

This commit is contained in:
R3ap3R
2026-08-05 04:53:52 -05:00
parent d1dd8ff87c
commit ffb7933b6f
26 changed files with 1390 additions and 421 deletions
+173
View File
@@ -0,0 +1,173 @@
# Aurora Dockside — Build Session Log
A record of the conversation that produced this project: an Electron + React +
TypeScript desktop GUI for managing [DDEV](https://ddev.com) local development
environments, built as an independent clone of
[DDEV Manager](https://github.com/DDEV-Manager/ddev-manager) (Tauri + React + Rust).
## Origin
The session started with an unrelated request — install and configure DDEV
itself (Homebrew, OrbStack as the container runtime, mkcert for trusted local
HTTPS). Partway through, the idea came up: rather than using the existing
DDEV Manager app, build an equivalent from scratch, in our own code.
### Tech stack decisions (asked up front)
- **Framework**: Electron + React + TypeScript, chosen over matching the
original's Tauri/Rust stack — no Rust toolchain required.
- **Scope**: Full feature parity with DDEV Manager, built incrementally
(each step runnable/testable) rather than scaffolding everything blind.
- **Package manager**: pnpm, enabled via `corepack enable`.
- **Name**: Aurora Dockside (chosen by the user over a couple of suggested
alternatives).
A formal plan was written (Plan Mode) covering architecture (Electron main
process owns all `ddev` CLI execution; typed `window.api` surface via
contextBridge; TanStack Query + Zustand on the renderer side) and a 9-step
build order before any code was written.
## Build steps (each committed separately, each verified against a real
running DDEV project — not just typechecked)
1. **Scaffold**`electron-vite` + React 19 + TypeScript template via
`@quick-start/create-electron`, git-initialized on `main`.
2. **Tailwind CSS 4, Vitest, core libraries** — Tailwind via the Vite
plugin, Vitest + React Testing Library, Zustand, TanStack Query,
Lucide icons. Stripped the electron-vite demo boilerplate.
3. **Core project management**`ddev.ts` CLI wrapper (execFile +
JSON envelope parsing), IPC handlers, typed preload API, TanStack Query
hooks, two-pane UI (project list + detail panel). Includes a PATH
fallback in the main process, since GUI apps launched outside a
terminal don't inherit the shell's PATH and can't find Homebrew's
`ddev` binary otherwise.
4. **Terminal panel, status bar, toasts** — long-running commands
(start/stop/restart) now spawn via a `commandRunner.ts` module and
stream stdout/stderr to the renderer over IPC instead of waiting for
the whole command to finish. Cancel support kills the tracked child
process by operation id.
5. **Database tools** — snapshot create/list/restore/delete, DB
import/export via native file dialogs. `ddev snapshot restore` has no
project-name flag (unlike other ddev commands), so it runs with
`cwd` set to the project's approot instead.
6. **Add-on management** — registry browser (~270 third-party add-ons)
with search, install/remove via `ddev add-on get/remove`. Verified
working with the project stopped.
7. **Log viewer**`ddev logs -f` streamed over a dedicated IPC channel
(decoupled from the terminal/status-bar model, since log tailing runs
indefinitely rather than completing). Service switcher, text filter.
8. **Project creation wizard** — native directory picker, project name,
project type selector, optional docroot, streamed `ddev config`.
9. **Settings** — theme (light/dark/system, persisted), zoom controls via
a small main-process IPC (`webContents.setZoomLevel`), keyboard
shortcuts (Cmd/Ctrl+N, +comma, +=/-/0).
10. **Packaging** — cleaned up `electron-builder.yml` (correct appId/
productName, removed irrelevant camera/mic/Documents/Downloads
Info.plist entries, removed a placeholder auto-update publish
config). Verified with a real `pnpm build:unpack` run, launching the
packaged (unsigned, no Developer ID cert available) `.app` directly
and confirming it could still find and run `ddev`.
Scoped down from full parity, on purpose: composer/wp-cli-based CMS
scaffolding for Drupal/Laravel/Shopware, and auto-update infrastructure.
## Bugs found via live testing (not just code review)
Every feature was verified by actually driving the running app — mostly via
a small CDP (Chrome DevTools Protocol) driver script that clicked real
buttons in the real Electron window, since a lot of this app's correctness
depends on real subprocess/IPC/timing behavior that static review can't
catch. This caught several real defects:
- **`window.prompt()` doesn't work in Electron's renderer** — it returns
`null` immediately with no dialog, unlike `window.confirm()` which does
show a real native dialog. The snapshot-naming UI was rewritten to use
an inline text input instead.
- **`ddev addon list --installed` omits the `raw` JSON key entirely** when
nothing is installed (unlike `ddev list`/`ddev snapshot --list`, which
include `raw: null`). The shared JSON-parsing helper treated a missing
`raw` as an error, so after removing the last add-on the query would
error on refetch and React Query kept showing the stale cached row.
Fixed by splitting the helper into a strict variant (for `describe`,
where missing data really is an error) and a lenient list variant.
- **Log filtering operated on raw stream chunks, not lines** — a single
chunk of stdout/stderr can bundle many lines or split one across chunk
boundaries, so filtering by chunk let unrelated lines through. Fixed by
buffering partial lines per stream and only filtering once full lines
are assembled.
- **A `react-hooks/set-state-in-effect` lint violation** in the log-stream
hook, from resetting state synchronously inside an effect body. Fixed
by keying the streaming component by `service` so switching services
remounts it — state resets via fresh `useState` initializers instead,
which is the React-recommended pattern for this.
## Post-launch bug reports and fixes
After the initial 9-step build was declared done, real usage surfaced three
more gaps:
1. **"I created a new container for wordpress, only wp-content was
created."** — `ddev config --project-type=wordpress` only scaffolds the
DDEV-managed `wp-config.php` bridge and `wp-content/uploads`; it never
downloads WordPress core (`wp-admin/`, `wp-includes/`, `index.php`,
etc.). Fixed the user's existing project directly (`ddev wp core
download`), then added the missing step to the wizard.
2. **"Now I get 403 Forbidden. Shouldn't we have an option to set admin
username, password, and email while setting it up?"** — right call:
`wp core download` only fetches files, `wp core install` is what
actually creates the database tables and admin user. The wizard's
WordPress path now chains `configure → start → wp core download →
wp core install`, with site title/admin username/password/email
fields shown only for that project type. wp-cli needs the containers
running, so this path always starts the project regardless of the
generic "start after creating" checkbox.
3. **"What about the ability to delete the site? I can create all day
long but not able to delete them from the software."** — a real gap;
the original DDEV Manager has this and it hadn't been built yet. Added
a Delete button (`ddev delete <name> --yes`, keeping ddev's default
database snapshot as a safety net) with a confirmation dialog that
clarifies it only removes DDEV's registration/containers/database, not
the project's files on disk.
4. Also added, per a follow-up request: a **WP Admin quick-link** button
for running WordPress-type projects, opening `{primary_url}/wp-admin/`
directly.
All three were verified end-to-end against a fresh throwaway project:
full configure→start→download→install chain producing a genuinely working
site (200 on the homepage, correct login redirect on `/wp-admin`), and
delete actually removing the project from `ddev list`. Two apparent bugs
that came up during that verification turned out to be the *test script*
reading DOM state before React had re-rendered, or before ddev's
multi-step delete (build + start + snapshot + teardown) had actually
finished — not real defects.
## Final artifact
A packaged, distributable build was produced on request:
- `dist/aurora-dockside-1.0.0.dmg` — installer
- `dist/Aurora Dockside-1.0.0-arm64-mac.zip` — zipped `.app`
Both unsigned (no Developer ID certificate on this machine) — macOS
Gatekeeper requires right-click → Open on first launch.
## Repository state
10 commits on `main`, one per build step plus the post-launch fixes:
```
Scaffold Aurora Dockside with electron-vite + React + TypeScript
Wire up Tailwind CSS 4, Vitest, and core app libraries
Add core DDEV project management (list/describe/start/stop/restart)
Add streaming terminal panel, status bar, and toast notifications
Add database tools: snapshots and import/export (step 4)
Add add-on management: registry browser, install, remove (step 5)
Add streaming log viewer with service switching and filtering (step 6)
Add project creation wizard (step 7)
Add settings: theme, zoom controls, keyboard shortcuts (step 8)
Finalize packaging config and verify a real build (step 9)
Fix WordPress scaffolding gap; add delete project + WP admin link
```
*(Note: some project files show further edits beyond this log's cutoff —
work continued in the project after this session.)*
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 108 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 356 KiB

+16 -12
View File
@@ -34,8 +34,13 @@ export function registerCreateIpc(): void {
// 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:downloadWordpress',
(event, operationId: string, directory: string, locale: string) => {
const args = ['wp', 'core', 'download']
if (locale.trim() && locale.trim() !== 'en_US') args.push(`--locale=${locale.trim()}`)
return runStreamed(operationId, args, event.sender, { cwd: directory })
}
)
ipcMain.handle(
@@ -48,23 +53,22 @@ export function registerCreateIpc(): void {
title: string,
adminUser: string,
adminPassword: string,
adminEmail: string
) =>
runStreamed(
operationId,
[
adminEmail: string,
multisite: 'none' | 'subdirectory' | 'subdomain'
) => {
const args = [
'wp',
'core',
'install',
multisite === 'none' ? 'install' : 'multisite-install',
`--url=${siteUrl}`,
`--title=${title}`,
`--admin_user=${adminUser}`,
`--admin_password=${adminPassword}`,
`--admin_email=${adminEmail}`,
'--skip-email'
],
event.sender,
{ cwd: directory }
)
]
if (multisite === 'subdomain') args.push('--subdomains')
return runStreamed(operationId, args, event.sender, { cwd: directory })
}
)
}
+34 -1
View File
@@ -11,8 +11,12 @@ export function registerProjectsIpc(): void {
ipcMain.handle('projects:stop', (event, operationId: string, name: string) =>
runStreamed(operationId, ['stop', name], event.sender)
)
// `-y` matters here beyond convenience: we spawn `ddev` with no stdin
// wired up (see commandRunner.ts), so any confirmation prompt ddev tries
// to show — e.g. when a database type change requires it — would hang the
// operation forever with no way for the user to answer it.
ipcMain.handle('projects:restart', (event, operationId: string, name: string) =>
runStreamed(operationId, ['restart', name], event.sender)
runStreamed(operationId, ['restart', name, '-y'], event.sender)
)
// Removes DDEV's project registration + containers + database (auto-
// snapshotted first, unless omitted) — does not touch the project's files
@@ -20,4 +24,33 @@ export function registerProjectsIpc(): void {
ipcMain.handle('projects:delete', (event, operationId: string, name: string) =>
runStreamed(operationId, ['delete', name, '--yes'], event.sender)
)
// Reconfigures a project's PHP version, web server, database, or Xdebug
// state via `ddev config` (writes .ddev/config.yaml) — callers are
// expected to follow a successful call with a restart (if the project is
// running) to actually apply it, same two-phase pattern as create.ts.
ipcMain.handle(
'projects:updateEnvironment',
(
event,
operationId: string,
name: string,
approot: string,
updates: {
phpVersion?: string
webserverType?: string
database?: string
xdebugEnabled?: boolean
}
) => {
const args = ['config', `--project-name=${name}`]
if (updates.phpVersion) args.push(`--php-version=${updates.phpVersion}`)
if (updates.webserverType) args.push(`--webserver-type=${updates.webserverType}`)
if (updates.database) args.push(`--database=${updates.database}`)
if (updates.xdebugEnabled !== undefined) {
args.push(`--xdebug-enabled=${updates.xdebugEnabled}`)
}
return runStreamed(operationId, args, event.sender, { cwd: approot })
}
)
}
+15 -5
View File
@@ -6,6 +6,7 @@ import type {
DdevProjectDetail,
DdevProjectSummary,
DdevSnapshot,
EnvironmentUpdate,
LogDataEvent,
LogExitEvent,
TerminalDataEvent,
@@ -25,7 +26,14 @@ const api = {
restart: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:restart', operationId, name),
delete: (operationId: string, name: string): Promise<void> =>
ipcRenderer.invoke('projects:delete', operationId, name)
ipcRenderer.invoke('projects:delete', operationId, name),
updateEnvironment: (
operationId: string,
name: string,
approot: string,
updates: EnvironmentUpdate
): Promise<void> =>
ipcRenderer.invoke('projects:updateEnvironment', operationId, name, approot, updates)
},
terminal: {
cancel: (operationId: string): Promise<boolean> =>
@@ -99,8 +107,8 @@ const api = {
projectType,
docroot
),
downloadWordpress: (operationId: string, directory: string): Promise<void> =>
ipcRenderer.invoke('create:downloadWordpress', operationId, directory),
downloadWordpress: (operationId: string, directory: string, locale: string): Promise<void> =>
ipcRenderer.invoke('create:downloadWordpress', operationId, directory, locale),
setupWordpress: (
operationId: string,
directory: string,
@@ -108,7 +116,8 @@ const api = {
title: string,
adminUser: string,
adminPassword: string,
adminEmail: string
adminEmail: string,
multisite: 'none' | 'subdirectory' | 'subdomain'
): Promise<void> =>
ipcRenderer.invoke(
'create:setupWordpress',
@@ -118,7 +127,8 @@ const api = {
title,
adminUser,
adminPassword,
adminEmail
adminEmail,
multisite
)
},
zoom: {
+69 -9
View File
@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react'
import { Plus, Settings } from 'lucide-react'
import { Anchor, FolderOpen, Plus, Settings, Sparkles, TerminalSquare } from 'lucide-react'
import { ProjectDetail } from './components/projects/ProjectDetail'
import { ProjectList } from './components/projects/ProjectList'
import { TerminalPanel } from './components/terminal/TerminalPanel'
@@ -11,6 +11,7 @@ import { useAppStore } from './stores/appStore'
import { useTerminalEvents } from './hooks/useTerminalEvents'
import { useAppliedTheme } from './hooks/useAppliedTheme'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import docksideIcon from './assets/dockside-icon.png'
function App(): React.JSX.Element {
const selectedProjectName = useAppStore((s) => s.selectedProjectName)
@@ -24,17 +25,29 @@ function App(): React.JSX.Element {
})
return (
<div className="flex h-screen w-screen flex-col bg-white text-neutral-900 dark:bg-neutral-950 dark:text-neutral-100">
<div className="flex h-screen w-screen flex-col bg-[linear-gradient(135deg,rgba(8,145,178,0.12)_0%,transparent_36%),linear-gradient(180deg,#f8fafc_0%,#eef6f5_52%,#e7eef5_100%)] text-neutral-900 dark:bg-[linear-gradient(135deg,rgba(45,212,191,0.10)_0%,transparent_36%),linear-gradient(180deg,#070a0f_0%,#0f172a_54%,#092f34_100%)] dark:text-neutral-100">
<div className="flex flex-1 overflow-hidden">
<aside className="flex w-72 flex-shrink-0 flex-col border-r border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h1 className="text-sm font-semibold">Aurora Dockside</h1>
<aside className="flex w-80 flex-shrink-0 flex-col border-r border-white/70 bg-white/[0.78] shadow-[8px_0_30px_rgba(15,23,42,0.06)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.72] dark:shadow-black/25">
<div className="flex items-center justify-between border-b border-neutral-200/70 px-4 py-3 dark:border-white/10">
<div className="flex min-w-0 items-center gap-3">
<img
src={docksideIcon}
alt=""
className="size-10 rounded-xl shadow-sm shadow-cyan-900/20"
/>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-wide">Aurora Dockside</h1>
<p className="truncate text-xs text-neutral-500 dark:text-neutral-400">
DDEV command deck
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setIsCreateOpen(true)}
title="New Project"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-cyan-50 hover:text-cyan-700 dark:hover:bg-cyan-400/10 dark:hover:text-cyan-300"
>
<Plus size={16} />
</button>
@@ -42,7 +55,7 @@ function App(): React.JSX.Element {
type="button"
onClick={() => setIsSettingsOpen(true)}
title="Settings"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
className="rounded-md p-1.5 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
<Settings size={16} />
</button>
@@ -56,8 +69,55 @@ function App(): React.JSX.Element {
{selectedProjectName ? (
<ProjectDetail name={selectedProjectName} />
) : (
<div className="flex h-full items-center justify-center text-sm text-neutral-500">
Select a project to see its details.
<div className="flex h-full items-center justify-center p-8">
<div className="relative grid w-full max-w-3xl overflow-hidden rounded-2xl border border-white/70 bg-white/[0.84] shadow-[0_24px_80px_rgba(15,23,42,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.70] dark:shadow-black/30">
<div className="absolute inset-0 bg-[linear-gradient(rgba(8,145,178,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(8,145,178,0.08)_1px,transparent_1px)] bg-[size:34px_34px] dark:bg-[linear-gradient(rgba(45,212,191,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.08)_1px,transparent_1px)]" />
<div className="relative grid gap-7 p-8">
<div className="flex items-start justify-between gap-6">
<div>
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-cyan-200 bg-cyan-50 px-3 py-1 text-xs font-medium text-cyan-800 dark:border-cyan-400/20 dark:bg-cyan-400/10 dark:text-cyan-200">
<Sparkles size={13} />
Local environments, neatly handled
</div>
<h2 className="max-w-xl text-3xl font-semibold leading-tight text-neutral-950 dark:text-white">
Your DDEV projects deserve a better cockpit.
</h2>
<p className="mt-3 max-w-xl text-sm leading-6 text-neutral-600 dark:text-neutral-300">
Choose a project from the sidebar to manage lifecycle actions, URLs, logs,
database snapshots, and add-ons from one polished workspace.
</p>
</div>
<img
src={docksideIcon}
alt=""
className="hidden size-24 flex-shrink-0 rounded-3xl shadow-lg shadow-cyan-900/20 sm:block"
/>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<FolderOpen size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Project overview</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Status, stack details, paths, and services.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<TerminalSquare size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Live operations</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Start, stop, restart, and inspect logs.
</p>
</div>
<div className="rounded-xl border border-neutral-200 bg-white/80 p-4 dark:border-white/10 dark:bg-white/[0.05]">
<Anchor size={18} className="mb-3 text-cyan-700 dark:text-cyan-300" />
<p className="text-sm font-semibold">Database control</p>
<p className="mt-1 text-xs leading-5 text-neutral-500 dark:text-neutral-400">
Snapshots, imports, exports, and add-ons.
</p>
</div>
</div>
</div>
</div>
</div>
)}
</main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

+43
View File
@@ -3,7 +3,12 @@
@custom-variant dark (&:where(.dark, .dark *));
body {
margin: 0;
user-select: none;
background: #f8fafc;
font-feature-settings:
'liga' 1,
'calt' 1;
}
code {
@@ -16,3 +21,41 @@ code {
Liberation Mono,
monospace;
}
button,
a,
input {
-webkit-tap-highlight-color: transparent;
}
button:not(:disabled),
a {
cursor: pointer;
}
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 2px solid #06b6d4;
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(100, 116, 139, 0.35);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 116, 139, 0.55);
}
@@ -1,56 +1,63 @@
import { useState } from 'react'
import { FolderOpen, X } from 'lucide-react'
import { useRef, useState } from 'react'
import { clsx } from 'clsx'
import {
useCreateProject,
useDownloadWordpress,
useSetupWordpress
} from '../../hooks/useCreateProject'
import { useStartProject } from '../../hooks/useDdev'
ArrowLeft,
ArrowRight,
Boxes,
Check,
FileCode2,
FolderOpen,
Globe2,
Layers3,
Package,
Sparkles,
X
} from 'lucide-react'
import { useCreateProject } from '../../hooks/useCreateProject'
import { useAppStore } from '../../stores/appStore'
import { getTypeLabel, PROJECT_TYPES } from './types/registry'
import { GenericSetup } from './types/GenericSetup'
import { WordpressSetup } from './types/WordpressSetup'
import type { TypeSetupHandle } from './types/shared'
import docksideIcon from '../../assets/dockside-icon.png'
const PROJECT_TYPES = [
{ value: '', label: 'Auto-detect' },
{ value: 'php', label: 'PHP (generic)' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'drupal', label: 'Drupal' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'backdrop', label: 'Backdrop' },
{ value: 'craftcms', label: 'Craft CMS' },
{ value: 'magento2', label: 'Magento 2' },
{ value: 'shopware6', label: 'Shopware 6' },
{ value: 'symfony', label: 'Symfony' },
{ value: 'typo3', label: 'TYPO3' }
]
type Step = 'site' | 'setup'
const fieldClass =
'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'
const TYPE_ICONS: Record<string, typeof Globe2> = {
'': Sparkles,
php: FileCode2,
wordpress: Globe2,
drupal: Layers3,
laravel: FileCode2,
backdrop: Layers3,
craftcms: Package,
magento2: Package,
shopware6: Package,
symfony: Boxes,
typo3: Layers3
}
export function CreateProjectModal({ onClose }: { onClose: () => void }): React.JSX.Element {
const [step, setStep] = useState<Step>('site')
const [directory, setDirectory] = useState<string | null>(null)
const [projectName, setProjectName] = useState('')
const [projectType, setProjectType] = useState('')
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 [setupValid, setSetupValid] = useState(true)
const [isSubmitting, setIsSubmitting] = useState(false)
const createProject = useCreateProject()
const startProject = useStartProject()
const downloadWordpress = useDownloadWordpress()
const setupWordpress = useSetupWordpress()
const selectProject = useAppStore((s) => s.selectProject)
const setupRef = useRef<TypeSetupHandle>(null)
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()))
const canContinue = directory !== null && projectName.trim().length > 0
const canSubmit = canContinue && setupValid && !isSubmitting
async function handlePickDirectory(): Promise<void> {
const picked = await window.api.create.pickDirectory()
@@ -59,191 +66,238 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }): React.
if (!projectName) {
const name = picked.split('/').filter(Boolean).pop() ?? ''
setProjectName(name)
if (!siteTitle) setSiteTitle(name)
}
}
async function handleSubmit(): Promise<void> {
if (!directory) return
if (!directory || !canSubmit) return
const name = projectName.trim()
setIsSubmitting(true)
try {
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)
}
await setupRef.current?.runPostCreate({ directory, projectName: name })
selectProject(name)
onClose()
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-8">
<div className="flex w-full max-w-md flex-col rounded-xl bg-white shadow-2xl dark:bg-neutral-900">
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<h2 className="text-sm font-semibold">New Project</h2>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-950/55 p-8 backdrop-blur-sm">
<div className="grid max-h-[86vh] w-full max-w-4xl overflow-hidden rounded-2xl border border-white/70 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.28)] dark:border-white/10 dark:bg-neutral-950 md:grid-cols-[0.82fr_1.18fr]">
<aside className="relative hidden overflow-hidden bg-neutral-950 p-6 text-white md:block">
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.11)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.11)_1px,transparent_1px)] bg-[size:34px_34px]" />
<div className="absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-cyan-500/20 to-transparent" />
<div className="relative flex h-full flex-col justify-between">
<div>
<img
src={docksideIcon}
alt=""
className="mb-5 size-16 rounded-2xl shadow-lg shadow-cyan-950/30"
/>
<p className="text-xs font-semibold uppercase tracking-wide text-cyan-200">
Project launch
</p>
<h2 className="mt-3 text-3xl font-semibold leading-tight">
Create a local site that feels ready to work.
</h2>
</div>
<div className="grid gap-3">
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Destination
</p>
<p className="mt-1 truncate text-sm font-semibold">
{directory ? directory.split('/').filter(Boolean).pop() : 'Choose a folder'}
</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4">
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Project
</p>
<p className="mt-1 truncate text-sm font-semibold">
{projectName.trim() || 'Name pending'}
</p>
</div>
</div>
</div>
</aside>
<div className="flex min-h-0 flex-col">
<div className="flex items-center justify-between border-b border-neutral-200/80 px-5 py-4 dark:border-white/10">
<div>
<h2 className="text-base font-semibold">
{step === 'site' ? 'New Project' : `Set up ${getTypeLabel(projectType)}`}
</h2>
<div className="mt-2 flex items-center gap-2">
{(['site', 'setup'] as const).map((item, index) => (
<div
key={item}
className={clsx(
'flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs font-medium',
step === item
? 'border-cyan-200 bg-cyan-50 text-cyan-800 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-cyan-200'
: 'border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-neutral-400'
)}
>
<span className="grid size-4 place-items-center rounded-full bg-current text-[10px]">
<span className="text-white dark:text-neutral-950">{index + 1}</span>
</span>
{item === 'site' ? 'Project' : 'Setup'}
</div>
))}
</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-200"
className="rounded-lg p-1.5 text-neutral-400 transition hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-white/10 dark:hover:text-neutral-200"
>
<X size={16} />
</button>
</div>
<div className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-4">
<div className="flex min-h-0 flex-col gap-5 overflow-y-auto p-5">
{step === 'site' ? (
<>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project folder
</label>
<label className={labelClass}>Project folder</label>
<button
type="button"
onClick={handlePickDirectory}
className="flex w-full items-center gap-2 rounded-md border border-dashed border-neutral-300 px-3 py-2 text-left text-sm hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-800"
className={clsx(
'group flex w-full items-center gap-3 rounded-xl border border-dashed px-3 py-3 text-left text-sm transition',
directory
? 'border-cyan-200 bg-cyan-50/60 text-neutral-900 dark:border-cyan-400/25 dark:bg-cyan-400/10 dark:text-neutral-100'
: 'border-neutral-300 bg-neutral-50/70 text-neutral-500 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
)}
>
<FolderOpen size={16} className="flex-shrink-0 text-neutral-400" />
<span className="truncate">{directory ?? 'Choose a folder…'}</span>
<span className="grid size-9 flex-shrink-0 place-items-center rounded-lg bg-white text-cyan-700 shadow-sm dark:bg-neutral-950/70 dark:text-cyan-300">
<FolderOpen size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{directory ?? 'Choose a folder…'}
</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
This becomes the project root.
</span>
</span>
{directory && <Check size={16} className="text-cyan-700 dark:text-cyan-300" />}
</button>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project name
</label>
<label className={labelClass}>Project name</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder="my-project"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
className={fieldClass}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Project type
</label>
<select
value={projectType}
onChange={(e) => setProjectType(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"
<label className={labelClass}>Project type</label>
<div className="grid max-h-64 gap-2 overflow-y-auto pr-1 sm:grid-cols-2">
{PROJECT_TYPES.map((t) => {
const Icon = TYPE_ICONS[t.value] ?? Boxes
const isSelected = projectType === t.value
return (
<button
key={t.value}
type="button"
onClick={() => setProjectType(t.value)}
className={clsx(
'flex min-h-16 items-center gap-3 rounded-xl border px-3 py-3 text-left transition',
isSelected
? 'border-cyan-300 bg-cyan-50 text-cyan-950 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-100'
: 'border-neutral-200 bg-white/70 hover:border-cyan-200 hover:bg-cyan-50/50 dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/25 dark:hover:bg-cyan-400/10'
)}
>
{PROJECT_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<span
className={clsx(
'grid size-9 flex-shrink-0 place-items-center rounded-lg',
isSelected
? 'bg-cyan-600 text-white dark:bg-cyan-300 dark:text-neutral-950'
: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-950/70 dark:text-neutral-400'
)}
>
<Icon size={17} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold">{t.label}</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
{t.value ? 'Use DDEV type preset' : 'Let DDEV inspect it'}
</span>
</span>
</button>
)
})}
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-neutral-500 dark:text-neutral-400">
Docroot (optional)
</label>
<label className={labelClass}>Docroot (optional)</label>
<input
type="text"
value={docroot}
onChange={(e) => setDocroot(e.target.value)}
placeholder="e.g. web, public — leave blank for project root"
className="w-full rounded-md border border-neutral-300 px-3 py-1.5 text-sm dark:border-neutral-700 dark:bg-neutral-950"
className={fieldClass}
/>
</div>
{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"
</>
) : projectType === 'wordpress' ? (
<WordpressSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
</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)}
<GenericSetup
ref={setupRef}
projectName={projectName.trim()}
onValidityChange={setSetupValid}
/>
Start project after creating
</label>
)}
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
<div className="flex items-center justify-between gap-3 border-t border-neutral-200/80 bg-neutral-50/80 p-4 dark:border-white/10 dark:bg-white/[0.03]">
<button
type="button"
onClick={onClose}
className="rounded-md px-3 py-1.5 text-sm font-medium text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"
onClick={step === 'site' ? onClose : () => setStep('site')}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-neutral-500 transition hover:bg-white hover:text-neutral-900 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
Cancel
{step === 'site' ? null : <ArrowLeft size={14} />}
{step === 'site' ? 'Cancel' : 'Go back'}
</button>
{step === 'site' ? (
<button
type="button"
disabled={!canContinue}
onClick={() => setStep('setup')}
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Continue
<ArrowRight size={14} />
</button>
) : (
<button
type="button"
disabled={!canSubmit}
onClick={handleSubmit}
className="rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900"
className="inline-flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-semibold text-white shadow-sm shadow-cyan-900/20 transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
Create
{isSubmitting ? 'Creating…' : 'Add Site'}
</button>
)}
</div>
</div>
</div>
</div>
@@ -0,0 +1,63 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { PlayCircle } from 'lucide-react'
import { useStartProject } from '../../../hooks/useDdev'
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
// Fallback setup panel for any project type without a dedicated one yet
// (see registry.ts). Just scaffolds via `ddev config` and optionally starts
// the project — no type-specific installer.
export const GenericSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function GenericSetup(
{ onValidityChange },
ref
) {
const [startAfterCreate, setStartAfterCreate] = useState(true)
const startProject = useStartProject()
useEffect(() => {
onValidityChange(true)
}, [onValidityChange])
useImperativeHandle(ref, () => ({
runPostCreate: async ({ projectName }: TypeSetupContext) => {
if (startAfterCreate) {
await startProject.mutateAsync(projectName)
}
}
}))
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">
<PlayCircle size={18} />
</div>
<div>
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
Ready after DDEV config
</p>
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
This type uses DDEV defaults, then you can finish the app-specific install in the
project.
</p>
</div>
</div>
</div>
<label className="flex items-center justify-between gap-3 rounded-xl border border-neutral-200 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/[0.04]">
<span>
<span className="block font-semibold">Start after creating</span>
<span className="mt-0.5 block text-xs text-neutral-500 dark:text-neutral-400">
Launch the environment as soon as config is written.
</span>
</span>
<input
type="checkbox"
checked={startAfterCreate}
onChange={(e) => setStartAfterCreate(e.target.checked)}
className="size-4 accent-cyan-600"
/>
</label>
</div>
)
})
@@ -0,0 +1,200 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { ChevronDown, ChevronUp, Globe2, KeyRound, Mail, Type, UserRound } from 'lucide-react'
import { useDownloadWordpress, useSetupWordpress } from '../../../hooks/useCreateProject'
import { useStartProject } from '../../../hooks/useDdev'
import type { TypeSetupContext, TypeSetupHandle, TypeSetupProps } from './shared'
const LANGUAGES = [
{ value: 'en_US', label: 'English (United States)' },
{ value: 'en_GB', label: 'English (UK)' },
{ value: 'de_DE', label: 'German' },
{ value: 'es_ES', label: 'Spanish (Spain)' },
{ value: 'fr_FR', label: 'French (France)' },
{ value: 'it_IT', label: 'Italian' },
{ value: 'pt_BR', label: 'Portuguese (Brazil)' },
{ value: 'nl_NL', label: 'Dutch' },
{ value: 'ja', label: 'Japanese' }
]
type Multisite = 'none' | 'subdirectory' | 'subdomain'
const MULTISITE_OPTIONS: { value: Multisite; label: string }[] = [
{ value: 'none', label: 'No' },
{ value: 'subdirectory', label: 'Yes Subdirectory' },
{ value: 'subdomain', label: 'Yes Subdomain' }
]
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 WordpressSetup = forwardRef<TypeSetupHandle, TypeSetupProps>(function WordpressSetup(
{ projectName, onValidityChange },
ref
) {
const [siteTitle, setSiteTitle] = useState('')
const [adminUser, setAdminUser] = useState('admin')
const [adminPassword, setAdminPassword] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [showAdvanced, setShowAdvanced] = useState(false)
const [language, setLanguage] = useState('en_US')
const [multisite, setMultisite] = useState<Multisite>('none')
const startProject = useStartProject()
const downloadWordpress = useDownloadWordpress()
const setupWordpress = useSetupWordpress()
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) => {
// wp-cli needs the containers running, so this ignores any "start
// after creating" preference — 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, locale: language })
await setupWordpress.mutateAsync({
directory,
siteUrl: `https://${name}.ddev.site`,
title: siteTitle.trim() || name,
adminUser: adminUser.trim(),
adminPassword: adminPassword.trim(),
adminEmail: adminEmail.trim(),
multisite
})
}
}))
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">
<Globe2 size={18} />
</div>
<div>
<p className="text-sm font-semibold text-cyan-950 dark:text-cyan-100">
WordPress install
</p>
<p className="mt-1 text-xs leading-5 text-cyan-800/80 dark:text-cyan-100/75">
Core downloads automatically 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 title</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={siteTitle}
onChange={(e) => setSiteTitle(e.target.value)}
placeholder={projectName || 'My WordPress 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}>Select language</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className={inputClass}
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>Is this a WordPress Multisite?</label>
<select
value={multisite}
onChange={(e) => setMultisite(e.target.value as Multisite)}
className={inputClass}
>
{MULTISITE_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
</div>
)}
</div>
)
})
@@ -0,0 +1,18 @@
export const PROJECT_TYPES = [
{ value: '', label: 'Auto-detect' },
{ value: 'php', label: 'PHP (generic)' },
{ value: 'wordpress', label: 'WordPress' },
{ value: 'drupal', label: 'Drupal' },
{ value: 'laravel', label: 'Laravel' },
{ value: 'backdrop', label: 'Backdrop' },
{ value: 'craftcms', label: 'Craft CMS' },
{ value: 'magento2', label: 'Magento 2' },
{ value: 'shopware6', label: 'Shopware 6' },
{ value: 'symfony', label: 'Symfony' },
{ value: 'typo3', label: 'TYPO3' }
]
export function getTypeLabel(projectType: string): string {
const found = PROJECT_TYPES.find((t) => t.value === projectType)
return found?.value ? found.label : 'project'
}
@@ -0,0 +1,22 @@
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
export interface TypeSetupContext {
directory: string
projectName: string
}
// Each project type's setup panel exposes this so the wizard can trigger
// whatever post-`ddev config` work that type needs (downloading app core,
// running an installer, seeding a database, ...) without knowing the details.
export interface TypeSetupHandle {
runPostCreate: (ctx: TypeSetupContext) => Promise<void>
}
export interface TypeSetupProps {
projectName: string
onValidityChange: (valid: boolean) => void
}
export type TypeSetupComponent = ForwardRefExoticComponent<
TypeSetupProps & RefAttributes<TypeSetupHandle>
>
@@ -9,7 +9,7 @@ export function StatusBar(): React.JSX.Element {
const setPanelOpen = useTerminalStore((s) => s.setPanelOpen)
return (
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-neutral-200 bg-neutral-50 px-3 text-xs text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400">
<footer className="flex h-8 flex-shrink-0 items-center justify-between border-t border-white/70 bg-white/75 px-3 text-xs text-neutral-500 backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/75 dark:text-neutral-400">
{operationId && label ? (
<>
<button
@@ -18,7 +18,7 @@ export function StatusBar(): React.JSX.Element {
setActiveOperation(operationId)
setPanelOpen(true)
}}
className="flex items-center gap-1.5 hover:text-neutral-900 dark:hover:text-neutral-100"
className="flex items-center gap-1.5 transition hover:text-neutral-900 dark:hover:text-neutral-100"
>
<Loader2 size={12} className="animate-spin" />
{label}
@@ -26,7 +26,7 @@ export function StatusBar(): React.JSX.Element {
<button
type="button"
onClick={() => window.api.terminal.cancel(operationId)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950"
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-400/10"
>
<X size={12} />
Cancel
@@ -9,26 +9,32 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
const [isBrowserOpen, setIsBrowserOpen] = useState(false)
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">Add-ons</h3>
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Add-ons
</h3>
<button
type="button"
onClick={() => setIsBrowserOpen(true)}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900"
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
>
<Puzzle size={12} /> Browse Add-ons
</button>
</div>
{isLoading ? (
<p className="text-sm text-neutral-500">Loading add-ons</p>
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
Loading add-ons
</p>
) : !installed || installed.length === 0 ? (
<p className="text-sm text-neutral-500">No add-ons installed.</p>
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
No add-ons installed.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Version</th>
@@ -40,7 +46,7 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
{installed.map((addon) => (
<tr
key={addon.Name}
className="border-t border-neutral-200 dark:border-neutral-800"
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
>
<td className="px-3 py-2 font-medium">{addon.Name}</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
@@ -60,7 +66,7 @@ export function AddonsSection({ name }: { name: string }): React.JSX.Element {
}
}}
title="Remove"
className="rounded p-1 text-red-500 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-950"
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
>
<Trash2 size={14} />
</button>
@@ -47,10 +47,12 @@ export function DatabaseSection({
}
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-500 dark:text-neutral-400">Database</h3>
<div className="flex items-center gap-2">
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Database
</h3>
<div className="flex flex-wrap items-center gap-2">
{isNaming ? (
<>
<input
@@ -63,19 +65,19 @@ export function DatabaseSection({
if (e.key === 'Enter') submitSnapshotName()
if (e.key === 'Escape') setIsNaming(false)
}}
className="rounded-md border border-neutral-300 px-2 py-1 text-xs dark:border-neutral-700 dark:bg-neutral-900"
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs dark:border-white/10 dark:bg-neutral-950"
/>
<button
type="button"
onClick={submitSnapshotName}
className="rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
className="rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white transition hover:bg-cyan-500"
>
Create
</button>
<button
type="button"
onClick={() => setIsNaming(false)}
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 transition hover:bg-neutral-100 dark:hover:bg-white/10"
>
Cancel
</button>
@@ -86,7 +88,7 @@ export function DatabaseSection({
type="button"
disabled={isBusy}
onClick={() => importDatabase.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
>
<Upload size={12} /> Import
</button>
@@ -94,7 +96,7 @@ export function DatabaseSection({
type="button"
disabled={isBusy}
onClick={() => exportDatabase.mutate()}
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 px-2.5 py-1 text-xs font-medium hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
className="inline-flex items-center gap-1.5 rounded-md border border-neutral-300 bg-white/70 px-2.5 py-1 text-xs font-medium transition hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40 dark:border-white/10 dark:bg-white/[0.05] dark:hover:bg-white/10"
>
<Download size={12} /> Export
</button>
@@ -102,7 +104,7 @@ export function DatabaseSection({
type="button"
disabled={isBusy}
onClick={() => setIsNaming(true)}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-2.5 py-1 text-xs font-medium text-white hover:bg-neutral-700 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-600 px-2.5 py-1 text-xs font-medium text-white shadow-sm shadow-cyan-900/[0.15] transition hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-40"
>
<Camera size={12} /> Snapshot
</button>
@@ -112,13 +114,17 @@ export function DatabaseSection({
</div>
{isLoading ? (
<p className="text-sm text-neutral-500">Loading snapshots</p>
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
Loading snapshots
</p>
) : !snapshots || snapshots.length === 0 ? (
<p className="text-sm text-neutral-500">No snapshots yet.</p>
<p className="rounded-lg border border-dashed border-neutral-300 bg-neutral-50/70 px-3 py-4 text-sm text-neutral-500 dark:border-white/10 dark:bg-white/[0.03] dark:text-neutral-400">
No snapshots yet.
</p>
) : (
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<div className="overflow-hidden rounded-lg border border-neutral-200/80 bg-white dark:border-white/10 dark:bg-neutral-950/70">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-white/[0.04] dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Created</th>
@@ -129,7 +135,7 @@ export function DatabaseSection({
{snapshots.map((snapshot) => (
<tr
key={snapshot.Name}
className="border-t border-neutral-200 dark:border-neutral-800"
className="border-t border-neutral-200/80 transition hover:bg-cyan-50/40 dark:border-white/10 dark:hover:bg-cyan-400/5"
>
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
@@ -142,7 +148,7 @@ export function DatabaseSection({
disabled={isBusy}
onClick={() => restoreSnapshot.mutate(snapshot.Name)}
title="Restore"
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-neutral-800"
className="rounded p-1 text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10 dark:hover:text-neutral-100"
>
<RotateCcw size={14} />
</button>
@@ -155,7 +161,7 @@ export function DatabaseSection({
}
}}
title="Delete"
className="rounded p-1 text-red-500 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-950"
className="rounded p-1 text-red-500 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-red-400/10"
>
<Trash2 size={14} />
</button>
@@ -1,11 +1,28 @@
import { useState } from 'react'
import { FileText, KeyRound, Play, RotateCw, Square, Trash2 } from 'lucide-react'
import { clsx } from 'clsx'
import {
Boxes,
Code2,
Database,
ExternalLink,
FileText,
Gauge,
KeyRound,
Play,
RotateCw,
Server,
Square,
Trash2,
Zap
} from 'lucide-react'
import type { EnvironmentUpdate } from '@shared/types'
import {
useDeleteProject,
useProjectDetail,
useRestartProject,
useStartProject,
useStopProject
useStopProject,
useUpdateEnvironment
} from '../../hooks/useDdev'
import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection'
@@ -13,12 +30,49 @@ import { AddonsSection } from './AddonsSection'
import { LogViewer } from '../logs/LogViewer'
import { useAppStore } from '../../stores/appStore'
const PHP_VERSIONS = [
'5.6',
'7.0',
'7.1',
'7.2',
'7.3',
'7.4',
'8.0',
'8.1',
'8.2',
'8.3',
'8.4',
'8.5'
]
const WEBSERVER_TYPES = [
{ value: 'nginx-fpm', label: 'nginx' },
{ value: 'apache-fpm', label: 'Apache' },
{ value: 'generic', label: 'Generic' }
]
const DATABASE_OPTIONS = [
{ value: 'mariadb:11.8', label: 'MariaDB 11.8' },
{ value: 'mariadb:10.11', label: 'MariaDB 10.11' },
{ value: 'mariadb:10.6', label: 'MariaDB 10.6' },
{ value: 'mysql:8.4', label: 'MySQL 8.4' },
{ value: 'mysql:8.0', label: 'MySQL 8.0' },
{ value: 'mysql:5.7', label: 'MySQL 5.7' },
{ value: 'postgres:17', label: 'PostgreSQL 17' },
{ value: 'postgres:16', label: 'PostgreSQL 16' },
{ value: 'postgres:15', label: 'PostgreSQL 15' }
]
const heroFieldClass =
'w-full rounded-md border border-white/10 bg-white/5 px-1.5 py-1 text-sm font-semibold text-white transition hover:border-cyan-300/40 hover:bg-white/10 focus:border-cyan-300/60 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50'
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 updateEnvironment = useUpdateEnvironment()
const selectProject = useAppStore((s) => s.selectProject)
const [isLogsOpen, setIsLogsOpen] = useState(false)
@@ -28,6 +82,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
restartProject.isPending ||
deleteProject.isPending
const isEnvUpdating = updateEnvironment.isPending || restartProject.isPending
function handleDelete(): void {
if (!project) return
const confirmed = window.confirm(
@@ -40,8 +96,26 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
})
}
async function applyEnvironmentChange(updates: EnvironmentUpdate): Promise<void> {
if (!project) return
await updateEnvironment.mutateAsync({ name: project.name, approot: project.approot, updates })
if (project.status === 'running') {
await restartProject.mutateAsync(project.name)
}
}
function handleDatabaseChange(value: string): void {
const confirmed = window.confirm(
'Changing the database type restarts the project and may require DDEV to migrate or ' +
'recreate the database. Consider taking a snapshot first if this project has data you ' +
'want to keep. Continue?'
)
if (!confirmed) return
void applyEnvironmentChange({ database: value })
}
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 dark:text-neutral-400">Loading {name}</div>
}
if (isError || !project) {
@@ -53,23 +127,49 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
}
const isRunning = project.status === 'running'
const services = Object.values(project.services)
const runningServices = services.filter((service) => service.status === 'running').length
const phpVersions =
project.php_version && !PHP_VERSIONS.includes(project.php_version)
? [project.php_version, ...PHP_VERSIONS]
: PHP_VERSIONS
const currentDatabase = `${project.dbinfo.database_type}:${project.dbinfo.database_version}`
const databaseOptions = DATABASE_OPTIONS.some((o) => o.value === currentDatabase)
? DATABASE_OPTIONS
: [
{
value: currentDatabase,
label: `${project.dbinfo.database_type} ${project.dbinfo.database_version}`
},
...DATABASE_OPTIONS
]
return (
<div className="flex flex-col gap-6 p-6">
<header className="flex items-center justify-between">
<div>
<div className="mx-auto flex w-full max-w-6xl flex-col gap-5 p-6">
<header className="overflow-hidden rounded-2xl border border-white/70 bg-neutral-950 text-white shadow-[0_24px_70px_rgba(15,23,42,0.18)] dark:border-white/10">
<div className="relative p-5">
<div className="absolute inset-0 bg-[linear-gradient(rgba(45,212,191,0.09)_1px,transparent_1px),linear-gradient(90deg,rgba(45,212,191,0.09)_1px,transparent_1px)] bg-[size:36px_36px]" />
<div className="absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-cyan-500/10 to-transparent" />
<div className="relative flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs font-medium text-cyan-100">
<Gauge size={13} />
{runningServices} of {services.length} services running
</div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{project.name}</h2>
<h2 className="truncate text-3xl font-semibold">{project.name}</h2>
<StatusBadge status={project.status} />
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400">{project.approot}</p>
<p className="mt-2 max-w-3xl truncate text-sm text-neutral-300">{project.approot}</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
disabled={isRunning || isBusy}
onClick={() => startProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-3 py-1.5 text-sm font-semibold text-white shadow-sm shadow-emerald-950/20 transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-40"
>
<Play size={14} /> Start
</button>
@@ -77,7 +177,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
type="button"
disabled={!isRunning || isBusy}
onClick={() => stopProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
>
<Square size={14} /> Stop
</button>
@@ -85,7 +185,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
type="button"
disabled={!isRunning || isBusy}
onClick={() => restartProject.mutate(project.name)}
className="inline-flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex items-center gap-1.5 rounded-md bg-cyan-400 px-3 py-1.5 text-sm font-semibold text-neutral-950 shadow-sm shadow-cyan-950/20 transition hover:bg-cyan-300 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCw size={14} /> Restart
</button>
@@ -93,7 +193,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
type="button"
disabled={!isRunning}
onClick={() => setIsLogsOpen(true)}
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 disabled:cursor-not-allowed disabled:opacity-40 dark:border-neutral-700 dark:hover:bg-neutral-900"
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-40"
>
<FileText size={14} /> Logs
</button>
@@ -102,7 +202,7 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
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"
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-white/[0.15]"
>
<KeyRound size={14} /> WP Admin
</a>
@@ -111,87 +211,182 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
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"
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
</button>
</div>
</div>
<div className="relative mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Boxes size={17} className="mb-3 text-cyan-200" />
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">
Project type
</p>
<p className="mt-1 truncate text-sm font-semibold text-white">{project.type}</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Code2 size={17} className="mb-3 text-cyan-200" />
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
PHP
</p>
<select
value={project.php_version ?? ''}
disabled={isEnvUpdating}
onChange={(e) => void applyEnvironmentChange({ phpVersion: e.target.value })}
className={heroFieldClass}
>
{phpVersions.map((v) => (
<option key={v} value={v} className="text-neutral-900">
{v}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<Server size={17} className="mb-3 text-cyan-200" />
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-neutral-400">
Web server
</p>
<select
value={project.webserver_type ?? 'nginx-fpm'}
disabled={isEnvUpdating}
onChange={(e) => void applyEnvironmentChange({ webserverType: e.target.value })}
className={heroFieldClass}
>
{WEBSERVER_TYPES.map((w) => (
<option key={w.value} value={w.value} className="text-neutral-900">
{w.label}
</option>
))}
</select>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.08] p-4 backdrop-blur">
<div className="mb-3 flex items-center justify-between">
<Zap size={17} className="text-cyan-200" />
<button
type="button"
role="switch"
aria-checked={project.xdebug_enabled}
aria-label="Toggle Xdebug"
disabled={isEnvUpdating}
onClick={() =>
void applyEnvironmentChange({ xdebugEnabled: !project.xdebug_enabled })
}
className={clsx(
'relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition disabled:cursor-not-allowed disabled:opacity-50',
project.xdebug_enabled ? 'bg-cyan-400' : 'bg-white/15'
)}
>
<span
className={clsx(
'inline-block size-3.5 transform rounded-full bg-white shadow transition',
project.xdebug_enabled ? 'translate-x-4' : 'translate-x-1'
)}
/>
</button>
</div>
<p className="text-xs font-medium uppercase tracking-wide text-neutral-400">Xdebug</p>
<p className="mt-1 truncate text-sm font-semibold text-white">
{project.xdebug_enabled ? 'Enabled' : 'Off'}
</p>
</div>
</div>
</div>
</header>
<section>
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">URLs</h3>
<ul className="flex flex-col gap-1">
<div className="grid gap-5 xl:grid-cols-[1.05fr_0.95fr]">
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
URLs
</h3>
<ul className="grid gap-2">
{project.urls.map((url) => (
<li key={url}>
<a
href={url}
target="_blank"
rel="noreferrer"
className="text-sm text-blue-600 hover:underline dark:text-blue-400"
className="group flex items-center justify-between gap-3 rounded-lg border border-neutral-200/70 bg-neutral-50/70 px-3 py-2 text-sm text-cyan-700 transition hover:border-cyan-200 hover:bg-cyan-50 dark:border-white/10 dark:bg-white/[0.04] dark:text-cyan-300 dark:hover:border-cyan-400/30 dark:hover:bg-cyan-400/10"
>
{url}
<span className="truncate">{url}</span>
<ExternalLink
size={14}
className="flex-shrink-0 text-neutral-400 transition group-hover:text-cyan-600 dark:group-hover:text-cyan-300"
/>
</a>
</li>
))}
</ul>
</section>
<section>
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<h3 className="mb-3 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<Database size={14} className="text-cyan-600 dark:text-cyan-300" />
Database credentials
</h3>
<dl className="grid grid-cols-[minmax(110px,0.45fr)_1fr] items-center gap-x-4 gap-y-2 text-sm">
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
<dd className="font-medium">
<select
value={currentDatabase}
disabled={isEnvUpdating}
onChange={(e) => handleDatabaseChange(e.target.value)}
className="w-full max-w-[220px] rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm transition hover:border-cyan-300 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-neutral-950"
>
{databaseOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
<dd className="font-medium">{project.dbinfo.dbname}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
<dd className="font-medium">{project.dbinfo.username}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
<dd className="font-mono text-xs">{project.dbinfo.password}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
<dd className="font-medium">{project.dbinfo.published_port}</dd>
</dl>
</section>
</div>
<section className="rounded-xl border border-white/70 bg-white/[0.78] p-4 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-neutral-950/[0.55]">
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
Services
</h3>
<div className="overflow-hidden rounded-lg border border-neutral-200 dark:border-neutral-800">
<table className="w-full text-sm">
<thead className="bg-neutral-50 text-left text-xs uppercase text-neutral-500 dark:bg-neutral-900 dark:text-neutral-400">
<tr>
<th className="px-3 py-2 font-medium">Service</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Image</th>
</tr>
</thead>
<tbody>
{Object.values(project.services).map((service) => (
<tr
<div className="grid gap-3 md:grid-cols-2">
{services.map((service) => (
<div
key={service.short_name}
className="border-t border-neutral-200 dark:border-neutral-800"
className="rounded-xl border border-neutral-200/80 bg-white/80 p-4 transition hover:border-cyan-200 hover:shadow-sm dark:border-white/10 dark:bg-white/[0.04] dark:hover:border-cyan-400/30"
>
<td className="px-3 py-2 font-medium">{service.short_name}</td>
<td className="px-3 py-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{service.short_name}</p>
<p className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
{service.full_name}
</p>
</div>
<StatusBadge status={service.status} />
</td>
<td className="truncate px-3 py-2 text-neutral-500 dark:text-neutral-400">
</div>
<p className="mt-4 truncate rounded-lg bg-neutral-100 px-3 py-2 font-mono text-xs text-neutral-600 dark:bg-neutral-950/70 dark:text-neutral-300">
{service.image}
</td>
</tr>
</p>
</div>
))}
</tbody>
</table>
</div>
</section>
<section>
<h3 className="mb-2 text-sm font-semibold text-neutral-500 dark:text-neutral-400">
Database credentials
</h3>
<dl className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt className="text-neutral-500 dark:text-neutral-400">Type</dt>
<dd>
{project.dbinfo.database_type} {project.dbinfo.database_version}
</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Database</dt>
<dd>{project.dbinfo.dbname}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Username</dt>
<dd>{project.dbinfo.username}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Password</dt>
<dd>{project.dbinfo.password}</dd>
<dt className="text-neutral-500 dark:text-neutral-400">Port</dt>
<dd>{project.dbinfo.published_port}</dd>
</dl>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<DatabaseSection name={project.name} approot={project.approot} />
<AddonsSection name={project.name} />
</div>
{isLogsOpen && (
<LogViewer
@@ -1,4 +1,5 @@
import { clsx } from 'clsx'
import { FolderKanban, Loader2 } from 'lucide-react'
import { useProjects } from '../../hooks/useDdev'
import { useAppStore } from '../../stores/appStore'
import { StatusBadge } from './StatusBadge'
@@ -9,7 +10,12 @@ export function ProjectList(): React.JSX.Element {
const selectProject = useAppStore((s) => s.selectProject)
if (isLoading) {
return <div className="p-4 text-sm text-neutral-500">Loading projects</div>
return (
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500 dark:text-neutral-400">
<Loader2 size={15} className="animate-spin text-cyan-600 dark:text-cyan-300" />
Loading projects
</div>
)
}
if (isError) {
@@ -22,28 +28,46 @@ export function ProjectList(): React.JSX.Element {
if (!projects || projects.length === 0) {
return (
<div className="p-4 text-sm text-neutral-500">
No DDEV projects found. Run <code>ddev start</code> in a project directory to see it here.
<div className="m-3 rounded-lg border border-dashed border-neutral-300 bg-white/60 p-4 text-sm text-neutral-500 dark:border-neutral-700 dark:bg-white/[0.03] dark:text-neutral-400">
<div className="mb-3 grid size-10 place-items-center rounded-lg bg-cyan-50 text-cyan-700 dark:bg-cyan-400/10 dark:text-cyan-300">
<FolderKanban size={18} />
</div>
<p className="font-medium text-neutral-800 dark:text-neutral-200">No DDEV projects found</p>
<p className="mt-1 leading-5">
Run{' '}
<code className="rounded bg-neutral-100 px-1 py-0.5 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200">
ddev start
</code>{' '}
in a project directory to see it here.
</p>
</div>
)
}
return (
<ul className="flex flex-col gap-1 p-2">
<ul className="flex flex-col gap-1.5 p-2.5">
{projects.map((project) => (
<li key={project.name}>
<button
type="button"
onClick={() => selectProject(project.name)}
className={clsx(
'flex w-full flex-col gap-1 rounded-lg px-3 py-2 text-left transition-colors',
'group relative flex w-full flex-col gap-1 overflow-hidden rounded-lg border px-3 py-2.5 text-left transition',
selectedProjectName === project.name
? 'bg-blue-100 dark:bg-blue-900/40'
: 'hover:bg-neutral-100 dark:hover:bg-neutral-800'
? 'border-cyan-200 bg-cyan-50/90 shadow-sm shadow-cyan-900/5 dark:border-cyan-400/25 dark:bg-cyan-400/10'
: 'border-transparent hover:border-neutral-200 hover:bg-white/70 hover:shadow-sm dark:hover:border-white/10 dark:hover:bg-white/[0.04]'
)}
>
<span
className={clsx(
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-opacity',
selectedProjectName === project.name
? 'bg-cyan-500 opacity-100'
: 'bg-neutral-300 opacity-0 group-hover:opacity-100 dark:bg-neutral-600'
)}
/>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium">{project.name}</span>
<span className="truncate text-sm font-semibold">{project.name}</span>
<StatusBadge status={project.status} />
</div>
<span className="truncate text-xs text-neutral-500 dark:text-neutral-400">
@@ -1,21 +1,35 @@
import { clsx } from 'clsx'
const STATUS_STYLES: Record<string, string> = {
running: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400',
stopped: 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400',
paused: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400'
running:
'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300',
stopped:
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400',
paused:
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300'
}
const DEFAULT_STYLE = 'bg-neutral-200 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'
const DEFAULT_STYLE =
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/10 dark:bg-white/[0.08] dark:text-neutral-400'
export function StatusBadge({ status }: { status: string }): React.JSX.Element {
return (
<span
className={clsx(
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize',
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize',
STATUS_STYLES[status] ?? DEFAULT_STYLE
)}
>
<span
className={clsx(
'size-1.5 rounded-full',
status === 'running'
? 'bg-emerald-500'
: status === 'paused'
? 'bg-amber-500'
: 'bg-neutral-400'
)}
/>
{status}
</span>
)
@@ -18,13 +18,13 @@ export function TerminalPanel(): React.JSX.Element | null {
if (!isPanelOpen || !activeOperationId || !operation) return null
return (
<div className="flex h-64 flex-shrink-0 flex-col border-t border-neutral-200 bg-neutral-950 dark:border-neutral-800">
<div className="flex items-center justify-between border-b border-neutral-800 px-3 py-1.5">
<div className="flex h-64 flex-shrink-0 flex-col border-t border-cyan-500/20 bg-neutral-950 shadow-[0_-16px_40px_rgba(15,23,42,0.2)] dark:border-cyan-400/20">
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.03] px-3 py-1.5">
<span className="text-xs font-medium text-neutral-300">{operation.label}</span>
<button
type="button"
onClick={() => setPanelOpen(false)}
className="rounded p-1 text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
className="rounded p-1 text-neutral-400 transition hover:bg-white/10 hover:text-neutral-200"
>
<X size={14} />
</button>
+19 -5
View File
@@ -16,6 +16,7 @@ export interface WordpressSetupInput {
adminUser: string
adminPassword: string
adminEmail: string
multisite: 'none' | 'subdirectory' | 'subdomain'
}
function beginOperation(label: string): string {
@@ -44,18 +45,30 @@ export function useCreateProject(): UseMutationResult<void, Error, CreateProject
// 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 }> {
export function useDownloadWordpress(): UseMutationResult<
void,
Error,
{ directory: string; locale: string }
> {
return useMutation({
mutationFn: async ({ directory }) => {
mutationFn: async ({ directory, locale }) => {
const operationId = beginOperation('Download WordPress core')
await window.api.create.downloadWordpress(operationId, directory)
await window.api.create.downloadWordpress(operationId, directory, locale)
}
})
}
export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSetupInput> {
return useMutation({
mutationFn: async ({ directory, siteUrl, title, adminUser, adminPassword, adminEmail }) => {
mutationFn: async ({
directory,
siteUrl,
title,
adminUser,
adminPassword,
adminEmail,
multisite
}) => {
const operationId = beginOperation('Install WordPress')
await window.api.create.setupWordpress(
operationId,
@@ -64,7 +77,8 @@ export function useSetupWordpress(): UseMutationResult<void, Error, WordpressSet
title,
adminUser,
adminPassword,
adminEmail
adminEmail,
multisite
)
}
})
+24 -1
View File
@@ -5,7 +5,7 @@ import {
type UseMutationResult,
type UseQueryResult
} from '@tanstack/react-query'
import type { DdevProjectDetail, DdevProjectSummary } from '@shared/types'
import type { DdevProjectDetail, DdevProjectSummary, EnvironmentUpdate } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
@@ -75,3 +75,26 @@ export function useDeleteProject(): UseMutationResult<void, Error, string> {
window.api.projects.delete(operationId, name)
)
}
// Only runs `ddev config` — the caller is expected to follow a successful
// call with useRestartProject() if the project is currently running, same
// split as useCreateProject's configure/start pair.
export function useUpdateEnvironment(): UseMutationResult<
void,
Error,
{ name: string; approot: string; updates: EnvironmentUpdate }
> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ name, approot, updates }) => {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, `Update ${name} environment`)
useStatusStore.getState().begin(operationId, `Update ${name} environment`)
await window.api.projects.updateEnvironment(operationId, name, approot, updates)
},
onSettled: (_data, _error, { name }) => {
queryClient.invalidateQueries({ queryKey: PROJECTS_KEY })
queryClient.invalidateQueries({ queryKey: projectDetailKey(name) })
}
})
}
+7
View File
@@ -67,6 +67,13 @@ export interface DdevProjectDetail extends DdevProjectSummary {
xdebug_enabled: boolean
}
export interface EnvironmentUpdate {
phpVersion?: string
webserverType?: string
database?: string
xdebugEnabled?: boolean
}
export interface DdevSnapshot {
Name: string
Created: string