diff --git a/desktop/src/components/db-viewer/DbViewerScreen.tsx b/desktop/src/components/db-viewer/DbViewerScreen.tsx index 04c79aa..b6a741e 100644 --- a/desktop/src/components/db-viewer/DbViewerScreen.tsx +++ b/desktop/src/components/db-viewer/DbViewerScreen.tsx @@ -22,6 +22,8 @@ import { VirtualDataGrid } from "../grid/VirtualDataGrid"; import { ResultSetPanels } from "./ResultSetPanels"; import { RowDetailDrawer } from "../grid/RowDetailDrawer"; import { TableControls } from "./TableControls"; +import { StreamExportDialog } from "./StreamExportDialog"; +import { qualifiedTableSql } from "../../lib/exportData"; import { EditConnectionModal } from "./EditConnectionModal"; import { PasswordPromptDialog } from "./PasswordPromptDialog"; import { useDbConnection } from "../../hooks/useDbConnection"; @@ -178,6 +180,7 @@ export function DbViewerScreen({ const [rowDetailIdx, setRowDetailIdx] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); const [destructiveQuery, setDestructiveQuery] = useState(null); + const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null); const connections = useConnectionStore((s) => s.connections); const currentConnection = connections.find((c) => c.id === connectionId) ?? null; @@ -1269,6 +1272,18 @@ const onQueriesPanelResizeStart = useCallback( } variant="query" isMatview={readOnlyTable} + onExportToFile={() => + setStreamExport({ + sql: + activeTab?.tabType === "query" + ? activeTab.query ?? "" + : qualifiedTableSql( + currentConnection?.db_type ?? "postgresql", + activeSchema, + activeTable, + ), + }) + } /> )}
@@ -1380,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback( setSelectedRows(new Set()) } isMatview={readOnlyTable} + onExportToFile={() => + setStreamExport({ + sql: + activeTab?.tabType === "query" + ? activeTab.query ?? "" + : qualifiedTableSql( + currentConnection?.db_type ?? "postgresql", + activeSchema, + activeTable, + ), + }) + } /> )}
@@ -1572,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback( {capabilities.objects && ( )} + {streamExport && ( + setStreamExport(null)} + /> + )}
); diff --git a/desktop/src/components/db-viewer/StreamExportDialog.test.tsx b/desktop/src/components/db-viewer/StreamExportDialog.test.tsx new file mode 100644 index 0000000..5f28443 --- /dev/null +++ b/desktop/src/components/db-viewer/StreamExportDialog.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: vi.fn().mockResolvedValue("/tmp/export.csv"), +})); +vi.mock("../../lib/commands", () => ({ + exportQueryToFile: vi.fn().mockResolvedValue({ rows_written: 3 }), + cancelQuery: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + +import { StreamExportDialog } from "./StreamExportDialog"; +import { save } from "@tauri-apps/plugin-dialog"; +import { exportQueryToFile, cancelQuery } from "../../lib/commands"; + +describe("StreamExportDialog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("picks a path, invokes the export, and reports the row count", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /choose file/i })); + expect(save).toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: /export/i })); + await waitFor(() => expect(exportQueryToFile).toHaveBeenCalledWith("c1", "SELECT 1", "csv", "/tmp/export.csv")); + await waitFor(() => expect(screen.getByText(/3 rows written/i)).toBeTruthy()); + }); + + it("cancels via the existing query-cancel wrapper", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /export/i })); + fireEvent.click(screen.getByRole("button", { name: /cancel/i })); + await waitFor(() => expect(cancelQuery).toHaveBeenCalledWith("c1")); + }); + + it("surfaces export errors", async () => { + (exportQueryToFile as ReturnType).mockRejectedValueOnce(new Error("boom")); + render(); + fireEvent.click(screen.getByRole("button", { name: /export/i })); + await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy()); + }); +}); diff --git a/desktop/src/components/db-viewer/StreamExportDialog.tsx b/desktop/src/components/db-viewer/StreamExportDialog.tsx new file mode 100644 index 0000000..c926124 --- /dev/null +++ b/desktop/src/components/db-viewer/StreamExportDialog.tsx @@ -0,0 +1,160 @@ +import { useState, useEffect, useRef } from "react"; +import { AnimatedModal } from "../ui/AnimatedModal"; +import { Button } from "../ui/Button"; +import { Select } from "../ui/Select"; +import { save } from "@tauri-apps/plugin-dialog"; +import { listen } from "@tauri-apps/api/event"; +import { exportQueryToFile, cancelQuery } from "../../lib/commands"; + +interface StreamExportDialogProps { + connectionId: string; + sql: string; + onClose: () => void; +} + +const FORMAT_OPTIONS = [ + { value: "csv", label: "CSV" }, + { value: "jsonl", label: "JSON Lines" }, + { value: "json", label: "JSON" }, +]; + +const EXTENSIONS: Record = { + csv: "csv", + jsonl: "jsonl", + json: "json", +}; + +export function StreamExportDialog({ connectionId, sql, onClose }: StreamExportDialogProps) { + const [format, setFormat] = useState("csv"); + const [path, setPath] = useState(""); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(0); + const [done, setDone] = useState(false); + const [rowsWritten, setRowsWritten] = useState(null); + const [error, setError] = useState(null); + const jobIdRef = useRef(null); + + // Filter export-progress events to this job so concurrent exports don't + // clobber each other's progress bar. + useEffect(() => { + const unlisten = listen<{ job_id: string; percent: number }>("export-progress", (e) => { + if (e.payload.job_id === jobIdRef.current) { + setProgress(e.payload.percent); + } + }); + return () => { + unlisten.then((fn) => fn()); + }; + }, []); + + const handleChooseFile = async () => { + const ext = EXTENSIONS[format] ?? "csv"; + const picked = await save({ + defaultPath: `export.${ext}`, + filters: [{ name: format.toUpperCase(), extensions: [ext] }], + }); + if (picked) setPath(picked); + }; + + const handleExport = async () => { + setRunning(true); + setError(null); + setDone(false); + setProgress(0); + try { + let target = path; + if (!target) { + const ext = EXTENSIONS[format] ?? "csv"; + target = (await save({ + defaultPath: `export.${ext}`, + filters: [{ name: format.toUpperCase(), extensions: [ext] }], + })) ?? ""; + if (!target) { + setRunning(false); + return; + } + setPath(target); + } + jobIdRef.current = `export-${Date.now()}`; + const res = await exportQueryToFile(connectionId, sql, format, target); + setDone(true); + setProgress(100); + setRowsWritten(res.rows_written); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRunning(false); + } + }; + + const handleCancel = async () => { + try { + await cancelQuery(connectionId); + } catch { + // ignore — the export may already have finished + } + setRunning(false); + }; + + return ( + +
+

Export all rows to file

+ +
+