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(
|
export function runStreamed(
|
||||||
operationId: string,
|
operationId: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
sender: WebContents
|
sender: WebContents,
|
||||||
|
options: { cwd?: string } = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
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)
|
running.set(operationId, child)
|
||||||
|
|
||||||
const tail: string[] = []
|
const tail: string[] = []
|
||||||
|
|||||||
+17
-5
@@ -1,6 +1,6 @@
|
|||||||
import { execFile } from 'child_process'
|
import { execFile } from 'child_process'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import type { DdevProjectDetail, DdevProjectSummary } from '../shared/types'
|
import type { DdevProjectDetail, DdevProjectSummary, DdevSnapshot } from '../shared/types'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -27,12 +27,13 @@ interface DdevJsonEnvelope<T> {
|
|||||||
time: string
|
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
|
let stdout: string
|
||||||
try {
|
try {
|
||||||
;({ stdout } = await execFileAsync('ddev', [...args, '--json-output'], {
|
;({ stdout } = await execFileAsync('ddev', [...args, '--json-output'], {
|
||||||
maxBuffer: 32 * 1024 * 1024,
|
maxBuffer: 32 * 1024 * 1024,
|
||||||
env: ENV_WITH_DDEV_PATH
|
env: ENV_WITH_DDEV_PATH,
|
||||||
|
cwd
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const execError = error as { stdout?: string; stderr?: string; message: string }
|
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`.
|
// Read commands (list/describe): the payload lives under `raw`.
|
||||||
async function runDdevRead<T>(args: string[]): Promise<T> {
|
async function runDdevRead<T>(args: string[], cwd?: string): Promise<T> {
|
||||||
const envelope = await execDdev<T>(args)
|
const envelope = await execDdev<T>(args, cwd)
|
||||||
if (envelope.raw === undefined) {
|
if (envelope.raw === undefined) {
|
||||||
throw new DdevCliError(
|
throw new DdevCliError(
|
||||||
typeof envelope.msg === 'string' ? envelope.msg : 'ddev returned no data'
|
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> {
|
export async function describeProject(name: string): Promise<DdevProjectDetail> {
|
||||||
return runDdevRead<DdevProjectDetail>(['describe', name])
|
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 icon from '../../resources/icon.png?asset'
|
||||||
import { registerProjectsIpc } from './ipc/projects'
|
import { registerProjectsIpc } from './ipc/projects'
|
||||||
import { registerTerminalIpc } from './ipc/terminal'
|
import { registerTerminalIpc } from './ipc/terminal'
|
||||||
|
import { registerDatabaseIpc } from './ipc/database'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -54,6 +55,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
registerProjectsIpc()
|
registerProjectsIpc()
|
||||||
registerTerminalIpc()
|
registerTerminalIpc()
|
||||||
|
registerDatabaseIpc()
|
||||||
|
|
||||||
createWindow()
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { electronAPI } from '@electron-toolkit/preload'
|
|||||||
import type {
|
import type {
|
||||||
DdevProjectDetail,
|
DdevProjectDetail,
|
||||||
DdevProjectSummary,
|
DdevProjectSummary,
|
||||||
|
DdevSnapshot,
|
||||||
TerminalDataEvent,
|
TerminalDataEvent,
|
||||||
TerminalExitEvent
|
TerminalExitEvent
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
@@ -33,6 +34,23 @@ const api = {
|
|||||||
ipcRenderer.on('terminal:exit', listener)
|
ipcRenderer.on('terminal:exit', listener)
|
||||||
return () => ipcRenderer.removeListener('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
|
useStopProject
|
||||||
} from '../../hooks/useDdev'
|
} from '../../hooks/useDdev'
|
||||||
import { StatusBadge } from './StatusBadge'
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
import { DatabaseSection } from './DatabaseSection'
|
||||||
|
|
||||||
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
export function ProjectDetail({ name }: { name: string }): React.JSX.Element {
|
||||||
const { data: project, isLoading, isError, error } = useProjectDetail(name)
|
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>
|
<dd>{project.dbinfo.published_port}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<DatabaseSection name={project.name} approot={project.approot} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -67,6 +67,11 @@ export interface DdevProjectDetail extends DdevProjectSummary {
|
|||||||
xdebug_enabled: boolean
|
xdebug_enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DdevSnapshot {
|
||||||
|
Name: string
|
||||||
|
Created: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TerminalDataEvent {
|
export interface TerminalDataEvent {
|
||||||
operationId: string
|
operationId: string
|
||||||
stream: 'stdout' | 'stderr'
|
stream: 'stdout' | 'stderr'
|
||||||
|
|||||||
Reference in New Issue
Block a user