Add database tools: snapshots and import/export (step 4)

Snapshot create/list/restore/delete and DB import/export via native
file dialogs, all routed through the streaming commandRunner from
step 3. `ddev snapshot restore` has no project-name flag (unlike every
other ddev command used so far) — it resolves the project from cwd,
so commandRunner/execDdev now accept an optional cwd and every
database command runs with cwd = project approot instead of passing
the name positionally.

Fixed a real bug found via live testing: the snapshot-naming UI used
window.prompt(), which Electron's renderer does not support (it
returns null with no dialog, silently). window.confirm() does work
(verified: real native dialog, respects accept/dismiss) so the delete
confirmation was left as-is. Snapshot naming now uses an inline text
input instead.

Verified end-to-end against the real scratch DDEV project via CDP-
driven clicks: named snapshot creation, deletion, and restore
(streamed output ending in "Database snapshot restoretest was
restored in 7s") all confirmed working through the actual UI.
import-db/export-db verified via direct CLI round-trip using the
exact --file= flag syntax the app invokes (skipped UI-driving these
since they open native OS file dialogs that CDP cannot dismiss).
This commit is contained in:
R3ap3R
2026-08-02 23:51:09 -05:00
parent 199c4d254e
commit 9f67b13a47
9 changed files with 396 additions and 7 deletions
+3 -2
View File
@@ -33,10 +33,11 @@ export class CommandCancelledError extends Error {
export function runStreamed(
operationId: string,
args: string[],
sender: WebContents
sender: WebContents,
options: { cwd?: string } = {}
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH })
const child = spawn('ddev', args, { env: ENV_WITH_DDEV_PATH, cwd: options.cwd })
running.set(operationId, child)
const tail: string[] = []
+17 -5
View File
@@ -1,6 +1,6 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types'
import type { DdevProjectDetail, DdevProjectSummary, DdevSnapshot } from '../shared/types'
const execFileAsync = promisify(execFile)
@@ -27,12 +27,13 @@ interface DdevJsonEnvelope<T> {
time: string
}
async function execDdev<T>(args: string[]): Promise<DdevJsonEnvelope<T>> {
async function execDdev<T>(args: string[], cwd?: string): Promise<DdevJsonEnvelope<T>> {
let stdout: string
try {
;({ stdout } = await execFileAsync('ddev', [...args, '--json-output'], {
maxBuffer: 32 * 1024 * 1024,
env: ENV_WITH_DDEV_PATH
env: ENV_WITH_DDEV_PATH,
cwd
}))
} catch (error) {
const execError = error as { stdout?: string; stderr?: string; message: string }
@@ -57,8 +58,8 @@ async function execDdev<T>(args: string[]): Promise<DdevJsonEnvelope<T>> {
}
// Read commands (list/describe): the payload lives under `raw`.
async function runDdevRead<T>(args: string[]): Promise<T> {
const envelope = await execDdev<T>(args)
async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
const envelope = await execDdev<T>(args, cwd)
if (envelope.raw === undefined) {
throw new DdevCliError(
typeof envelope.msg === 'string' ? envelope.msg : 'ddev returned no data'
@@ -91,3 +92,14 @@ export async function listProjects(): Promise<DdevProjectSummary[]> {
export async function describeProject(name: string): Promise<DdevProjectDetail> {
return runDdevRead<DdevProjectDetail>(['describe', name])
}
// `ddev snapshot restore` has no project-name flag — it relies on cwd to
// resolve the project, so every snapshot command runs with cwd = approot
// rather than passing the project name positionally like other commands.
export async function listSnapshots(name: string, approot: string): Promise<DdevSnapshot[]> {
const raw = await runDdevRead<Record<string, DdevSnapshot[] | null>>(
['snapshot', '--list'],
approot
)
return raw[name] ?? []
}
+2
View File
@@ -4,6 +4,7 @@ import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerProjectsIpc } from './ipc/projects'
import { registerTerminalIpc } from './ipc/terminal'
import { registerDatabaseIpc } from './ipc/database'
function createWindow(): void {
// Create the browser window.
@@ -54,6 +55,7 @@ app.whenReady().then(() => {
registerProjectsIpc()
registerTerminalIpc()
registerDatabaseIpc()
createWindow()
+73
View File
@@ -0,0 +1,73 @@
import { dialog, ipcMain } from 'electron'
import { listSnapshots } from '../ddev'
import { runStreamed } from '../commandRunner'
export function registerDatabaseIpc(): void {
ipcMain.handle('database:listSnapshots', (_event, name: string, approot: string) =>
listSnapshots(name, approot)
)
ipcMain.handle(
'database:createSnapshot',
(event, operationId: string, approot: string, snapshotName?: string) =>
runStreamed(
operationId,
['snapshot', ...(snapshotName ? ['--name', snapshotName] : [])],
event.sender,
{ cwd: approot }
)
)
ipcMain.handle(
'database:restoreSnapshot',
(event, operationId: string, approot: string, snapshotName: string) =>
runStreamed(operationId, ['snapshot', 'restore', snapshotName], event.sender, {
cwd: approot
})
)
ipcMain.handle(
'database:deleteSnapshot',
(event, operationId: string, approot: string, snapshotName: string) =>
runStreamed(
operationId,
['snapshot', '--cleanup', '--name', snapshotName, '-y'],
event.sender,
{ cwd: approot }
)
)
ipcMain.handle(
'database:importFile',
(event, operationId: string, approot: string, filePath: string) =>
runStreamed(operationId, ['import-db', `--file=${filePath}`], event.sender, { cwd: approot })
)
ipcMain.handle(
'database:exportFile',
(event, operationId: string, approot: string, filePath: string) =>
runStreamed(operationId, ['export-db', `--file=${filePath}`], event.sender, { cwd: approot })
)
ipcMain.handle('database:pickImportFile', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'SQL dumps', extensions: ['sql', 'gz', 'zip', 'bz2', 'xz', 'tgz'] },
{ name: 'All files', extensions: ['*'] }
]
})
return result.canceled ? null : result.filePaths[0]
})
ipcMain.handle('database:pickExportPath', async (_event, defaultFileName: string) => {
const result = await dialog.showSaveDialog({
defaultPath: defaultFileName,
filters: [
{ name: 'SQL dumps', extensions: ['sql', 'gz'] },
{ name: 'All files', extensions: ['*'] }
]
})
return result.canceled ? null : result.filePath
})
}
+18
View File
@@ -3,6 +3,7 @@ import { electronAPI } from '@electron-toolkit/preload'
import type {
DdevProjectDetail,
DdevProjectSummary,
DdevSnapshot,
TerminalDataEvent,
TerminalExitEvent
} from '../shared/types'
@@ -33,6 +34,23 @@ const api = {
ipcRenderer.on('terminal:exit', listener)
return () => ipcRenderer.removeListener('terminal:exit', listener)
}
},
database: {
listSnapshots: (name: string, approot: string): Promise<DdevSnapshot[]> =>
ipcRenderer.invoke('database:listSnapshots', name, approot),
createSnapshot: (operationId: string, approot: string, snapshotName?: string): Promise<void> =>
ipcRenderer.invoke('database:createSnapshot', operationId, approot, snapshotName),
restoreSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
ipcRenderer.invoke('database:restoreSnapshot', operationId, approot, snapshotName),
deleteSnapshot: (operationId: string, approot: string, snapshotName: string): Promise<void> =>
ipcRenderer.invoke('database:deleteSnapshot', operationId, approot, snapshotName),
importFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
ipcRenderer.invoke('database:importFile', operationId, approot, filePath),
exportFile: (operationId: string, approot: string, filePath: string): Promise<void> =>
ipcRenderer.invoke('database:exportFile', operationId, approot, filePath),
pickImportFile: (): Promise<string | null> => ipcRenderer.invoke('database:pickImportFile'),
pickExportPath: (defaultFileName: string): Promise<string | null> =>
ipcRenderer.invoke('database:pickExportPath', defaultFileName)
}
}
@@ -0,0 +1,172 @@
import { useState } from 'react'
import { Camera, Download, RotateCcw, Trash2, Upload } from 'lucide-react'
import {
useCreateSnapshot,
useDeleteSnapshot,
useExportDatabase,
useImportDatabase,
useRestoreSnapshot,
useSnapshots
} from '../../hooks/useDatabase'
function formatDate(iso: string): string {
return new Date(iso).toLocaleString()
}
export function DatabaseSection({
name,
approot
}: {
name: string
approot: string
}): React.JSX.Element {
const { data: snapshots, isLoading } = useSnapshots(name, approot)
const createSnapshot = useCreateSnapshot(name, approot)
const restoreSnapshot = useRestoreSnapshot(name, approot)
const deleteSnapshot = useDeleteSnapshot(name, approot)
const importDatabase = useImportDatabase(name, approot)
const exportDatabase = useExportDatabase(name, approot)
// Electron doesn't support window.prompt() (it silently returns null with
// no dialog), so snapshot naming needs an inline input instead.
const [isNaming, setIsNaming] = useState(false)
const [snapshotNameDraft, setSnapshotNameDraft] = useState('')
const isBusy =
createSnapshot.isPending ||
restoreSnapshot.isPending ||
deleteSnapshot.isPending ||
importDatabase.isPending ||
exportDatabase.isPending
function submitSnapshotName(): void {
const trimmed = snapshotNameDraft.trim()
createSnapshot.mutate(trimmed || undefined)
setIsNaming(false)
setSnapshotNameDraft('')
}
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">
{isNaming ? (
<>
<input
autoFocus
type="text"
placeholder="Snapshot name (optional)"
value={snapshotNameDraft}
onChange={(e) => setSnapshotNameDraft(e.target.value)}
onKeyDown={(e) => {
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"
/>
<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"
>
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"
>
Cancel
</button>
</>
) : (
<>
<button
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"
>
<Upload size={12} /> Import
</button>
<button
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"
>
<Download size={12} /> Export
</button>
<button
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"
>
<Camera size={12} /> Snapshot
</button>
</>
)}
</div>
</div>
{isLoading ? (
<p className="text-sm text-neutral-500">Loading snapshots</p>
) : !snapshots || snapshots.length === 0 ? (
<p className="text-sm text-neutral-500">No snapshots yet.</p>
) : (
<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">Name</th>
<th className="px-3 py-2 font-medium">Created</th>
<th className="px-3 py-2 font-medium" />
</tr>
</thead>
<tbody>
{snapshots.map((snapshot) => (
<tr
key={snapshot.Name}
className="border-t border-neutral-200 dark:border-neutral-800"
>
<td className="px-3 py-2 font-medium">{snapshot.Name}</td>
<td className="px-3 py-2 text-neutral-500 dark:text-neutral-400">
{formatDate(snapshot.Created)}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-2">
<button
type="button"
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"
>
<RotateCcw size={14} />
</button>
<button
type="button"
disabled={isBusy}
onClick={() => {
if (window.confirm(`Delete snapshot "${snapshot.Name}"?`)) {
deleteSnapshot.mutate(snapshot.Name)
}
}}
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"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
@@ -6,6 +6,7 @@ import {
useStopProject
} from '../../hooks/useDdev'
import { StatusBadge } from './StatusBadge'
import { DatabaseSection } from './DatabaseSection'
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
const { data: project, isLoading, isError, error } = useProjectDetail(name)
@@ -137,6 +138,8 @@ export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
<dd>{project.dbinfo.published_port}</dd>
</dl>
</section>
<DatabaseSection name={project.name} approot={project.approot} />
</div>
)
}
+103
View File
@@ -0,0 +1,103 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult
} from '@tanstack/react-query'
import type { DdevSnapshot } from '@shared/types'
import { useTerminalStore } from '../stores/terminalStore'
import { useStatusStore } from '../stores/statusStore'
const snapshotsKey = (name: string): readonly [string, string] => ['snapshots', name] as const
export function useSnapshots(name: string, approot: string): UseQueryResult<DdevSnapshot[], Error> {
return useQuery({
queryKey: snapshotsKey(name),
queryFn: () => window.api.database.listSnapshots(name, approot)
})
}
// Same tracked-operation pattern as useProjectAction in useDdev.ts: generate
// an operationId up front so the terminal panel/status bar pick it up before
// the (potentially slow) ddev command resolves.
function beginOperation(label: string): string {
const operationId = crypto.randomUUID()
useTerminalStore.getState().startOperation(operationId, label)
useStatusStore.getState().begin(operationId, label)
return operationId
}
export function useCreateSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string | undefined> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName?: string) => {
const operationId = beginOperation(`Create snapshot for ${name}`)
await window.api.database.createSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
export function useRestoreSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName: string) => {
const operationId = beginOperation(`Restore snapshot ${snapshotName}`)
await window.api.database.restoreSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
export function useDeleteSnapshot(
name: string,
approot: string
): UseMutationResult<void, Error, string> {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (snapshotName: string) => {
const operationId = beginOperation(`Delete snapshot ${snapshotName}`)
await window.api.database.deleteSnapshot(operationId, approot, snapshotName)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: snapshotsKey(name) })
})
}
// Resolves to false if the user cancels the file picker, true if the import ran.
export function useImportDatabase(
name: string,
approot: string
): UseMutationResult<boolean, Error, void> {
return useMutation({
mutationFn: async () => {
const filePath = await window.api.database.pickImportFile()
if (!filePath) return false
const operationId = beginOperation(`Import database into ${name}`)
await window.api.database.importFile(operationId, approot, filePath)
return true
}
})
}
// Resolves to false if the user cancels the save dialog, true if the export ran.
export function useExportDatabase(
name: string,
approot: string
): UseMutationResult<boolean, Error, void> {
return useMutation({
mutationFn: async () => {
const filePath = await window.api.database.pickExportPath(`${name}.sql.gz`)
if (!filePath) return false
const operationId = beginOperation(`Export database from ${name}`)
await window.api.database.exportFile(operationId, approot, filePath)
return true
}
})
}
+5
View File
@@ -67,6 +67,11 @@ export interface DdevProjectDetail extends DdevProjectSummary {
xdebug_enabled: boolean
}
export interface DdevSnapshot {
Name: string
Created: string
}
export interface TerminalDataEvent {
operationId: string
stream: 'stdout' | 'stderr'