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
})
}