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:
@@ -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
@@ -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] ?? []
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user