From 9f67b13a479a819ab96872f0bfe7ba0b609e1c88 Mon Sep 17 00:00:00 2001 From: R3ap3R Date: Sun, 2 Aug 2026 23:51:09 -0500 Subject: [PATCH] Add database tools: snapshots and import/export (step 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/main/commandRunner.ts | 5 +- src/main/ddev.ts | 22 ++- src/main/index.ts | 2 + src/main/ipc/database.ts | 73 ++++++++ src/preload/index.ts | 18 ++ .../components/projects/DatabaseSection.tsx | 172 ++++++++++++++++++ .../src/components/projects/ProjectDetail.tsx | 3 + src/renderer/src/hooks/useDatabase.ts | 103 +++++++++++ src/shared/types.ts | 5 + 9 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 src/main/ipc/database.ts create mode 100644 src/renderer/src/components/projects/DatabaseSection.tsx create mode 100644 src/renderer/src/hooks/useDatabase.ts diff --git a/src/main/commandRunner.ts b/src/main/commandRunner.ts index 1868855..2cdade7 100644 --- a/src/main/commandRunner.ts +++ b/src/main/commandRunner.ts @@ -33,10 +33,11 @@ export class CommandCancelledError extends Error { export function runStreamed( operationId: string, args: string[], - sender: WebContents + sender: WebContents, + options: { cwd?: string } = {} ): Promise { 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[] = [] diff --git a/src/main/ddev.ts b/src/main/ddev.ts index b4aec62..3da18ba 100644 --- a/src/main/ddev.ts +++ b/src/main/ddev.ts @@ -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 { time: string } -async function execDdev(args: string[]): Promise> { +async function execDdev(args: string[], cwd?: string): Promise> { 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(args: string[]): Promise> { } // Read commands (list/describe): the payload lives under `raw`. -async function runDdevRead(args: string[]): Promise { - const envelope = await execDdev(args) +async function runDdevRead(args: string[], cwd?: string): Promise { + const envelope = await execDdev(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 { export async function describeProject(name: string): Promise { return runDdevRead(['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 { + const raw = await runDdevRead>( + ['snapshot', '--list'], + approot + ) + return raw[name] ?? [] +} diff --git a/src/main/index.ts b/src/main/index.ts index a8e7514..27f3867 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/database.ts b/src/main/ipc/database.ts new file mode 100644 index 0000000..7478088 --- /dev/null +++ b/src/main/ipc/database.ts @@ -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 + }) +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 4fcdf13..9c7bdd3 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => + ipcRenderer.invoke('database:listSnapshots', name, approot), + createSnapshot: (operationId: string, approot: string, snapshotName?: string): Promise => + ipcRenderer.invoke('database:createSnapshot', operationId, approot, snapshotName), + restoreSnapshot: (operationId: string, approot: string, snapshotName: string): Promise => + ipcRenderer.invoke('database:restoreSnapshot', operationId, approot, snapshotName), + deleteSnapshot: (operationId: string, approot: string, snapshotName: string): Promise => + ipcRenderer.invoke('database:deleteSnapshot', operationId, approot, snapshotName), + importFile: (operationId: string, approot: string, filePath: string): Promise => + ipcRenderer.invoke('database:importFile', operationId, approot, filePath), + exportFile: (operationId: string, approot: string, filePath: string): Promise => + ipcRenderer.invoke('database:exportFile', operationId, approot, filePath), + pickImportFile: (): Promise => ipcRenderer.invoke('database:pickImportFile'), + pickExportPath: (defaultFileName: string): Promise => + ipcRenderer.invoke('database:pickExportPath', defaultFileName) } } diff --git a/src/renderer/src/components/projects/DatabaseSection.tsx b/src/renderer/src/components/projects/DatabaseSection.tsx new file mode 100644 index 0000000..e4a0c4a --- /dev/null +++ b/src/renderer/src/components/projects/DatabaseSection.tsx @@ -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 ( +
+
+

Database

+
+ {isNaming ? ( + <> + 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" + /> + + + + ) : ( + <> + + + + + )} +
+
+ + {isLoading ? ( +

Loading snapshots…

+ ) : !snapshots || snapshots.length === 0 ? ( +

No snapshots yet.

+ ) : ( +
+ + + + + + + + + {snapshots.map((snapshot) => ( + + + + + + ))} + +
NameCreated +
{snapshot.Name} + {formatDate(snapshot.Created)} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/projects/ProjectDetail.tsx b/src/renderer/src/components/projects/ProjectDetail.tsx index 7fd05ee..2be2fbc 100644 --- a/src/renderer/src/components/projects/ProjectDetail.tsx +++ b/src/renderer/src/components/projects/ProjectDetail.tsx @@ -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 {
{project.dbinfo.published_port}
+ + ) } diff --git a/src/renderer/src/hooks/useDatabase.ts b/src/renderer/src/hooks/useDatabase.ts new file mode 100644 index 0000000..a1f3690 --- /dev/null +++ b/src/renderer/src/hooks/useDatabase.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } + }) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 32aaa72..2a6a5fa 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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'