feat: full-result export dialog with progress/cancel + client CSV injection parity (Task 11)
This commit is contained in:
@@ -22,6 +22,8 @@ import { VirtualDataGrid } from "../grid/VirtualDataGrid";
|
|||||||
import { ResultSetPanels } from "./ResultSetPanels";
|
import { ResultSetPanels } from "./ResultSetPanels";
|
||||||
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
import { RowDetailDrawer } from "../grid/RowDetailDrawer";
|
||||||
import { TableControls } from "./TableControls";
|
import { TableControls } from "./TableControls";
|
||||||
|
import { StreamExportDialog } from "./StreamExportDialog";
|
||||||
|
import { qualifiedTableSql } from "../../lib/exportData";
|
||||||
import { EditConnectionModal } from "./EditConnectionModal";
|
import { EditConnectionModal } from "./EditConnectionModal";
|
||||||
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
import { PasswordPromptDialog } from "./PasswordPromptDialog";
|
||||||
import { useDbConnection } from "../../hooks/useDbConnection";
|
import { useDbConnection } from "../../hooks/useDbConnection";
|
||||||
@@ -178,6 +180,7 @@ export function DbViewerScreen({
|
|||||||
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
const [rowDetailIdx, setRowDetailIdx] = useState<number | null>(null);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(null);
|
||||||
|
const [streamExport, setStreamExport] = useState<{ sql: string } | null>(null);
|
||||||
const connections = useConnectionStore((s) => s.connections);
|
const connections = useConnectionStore((s) => s.connections);
|
||||||
const currentConnection =
|
const currentConnection =
|
||||||
connections.find((c) => c.id === connectionId) ?? null;
|
connections.find((c) => c.id === connectionId) ?? null;
|
||||||
@@ -1269,6 +1272,18 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
}
|
}
|
||||||
variant="query"
|
variant="query"
|
||||||
isMatview={readOnlyTable}
|
isMatview={readOnlyTable}
|
||||||
|
onExportToFile={() =>
|
||||||
|
setStreamExport({
|
||||||
|
sql:
|
||||||
|
activeTab?.tabType === "query"
|
||||||
|
? activeTab.query ?? ""
|
||||||
|
: qualifiedTableSql(
|
||||||
|
currentConnection?.db_type ?? "postgresql",
|
||||||
|
activeSchema,
|
||||||
|
activeTable,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
@@ -1380,6 +1395,18 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
setSelectedRows(new Set())
|
setSelectedRows(new Set())
|
||||||
}
|
}
|
||||||
isMatview={readOnlyTable}
|
isMatview={readOnlyTable}
|
||||||
|
onExportToFile={() =>
|
||||||
|
setStreamExport({
|
||||||
|
sql:
|
||||||
|
activeTab?.tabType === "query"
|
||||||
|
? activeTab.query ?? ""
|
||||||
|
: qualifiedTableSql(
|
||||||
|
currentConnection?.db_type ?? "postgresql",
|
||||||
|
activeSchema,
|
||||||
|
activeTable,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
@@ -1572,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
|
|||||||
{capabilities.objects && (
|
{capabilities.objects && (
|
||||||
<ObjectSearchPalette connectionId={connectionId} />
|
<ObjectSearchPalette connectionId={connectionId} />
|
||||||
)}
|
)}
|
||||||
|
{streamExport && (
|
||||||
|
<StreamExportDialog
|
||||||
|
connectionId={connectionId}
|
||||||
|
sql={streamExport.sql}
|
||||||
|
onClose={() => setStreamExport(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
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(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
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<typeof vi.fn>).mockRejectedValueOnce(new Error("boom"));
|
||||||
|
render(<StreamExportDialog connectionId="c1" sql="SELECT 1" onClose={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /export/i }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/boom/i)).toBeTruthy());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const jobIdRef = useRef<string | null>(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 (
|
||||||
|
<AnimatedModal open onClose={onClose}>
|
||||||
|
<div className="w-[420px] flex flex-col gap-4">
|
||||||
|
<h2 className="text-base font-semibold text-text">Export all rows to file</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select
|
||||||
|
label="Format"
|
||||||
|
value={format}
|
||||||
|
onChange={setFormat}
|
||||||
|
options={FORMAT_OPTIONS}
|
||||||
|
disabled={running}
|
||||||
|
/>
|
||||||
|
<Button variant="secondary" onClick={handleChooseFile} disabled={running}>
|
||||||
|
Choose file…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{path && (
|
||||||
|
<p className="text-xs text-text-muted break-all" data-testid="export-path">
|
||||||
|
{path}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{running && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-surface-raised overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent transition-all"
|
||||||
|
style={{ width: `${Math.max(progress, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-text-muted">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{done && rowsWritten !== null && (
|
||||||
|
<p className="text-xs text-emerald-400" data-testid="export-done">
|
||||||
|
{rowsWritten} rows written
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-400" data-testid="export-error">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{running && (
|
||||||
|
<Button variant="secondary" onClick={handleCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleExport} disabled={running}>
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AnimatedModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -467,6 +467,8 @@ interface TableControlsProps {
|
|||||||
isMatview?: boolean;
|
isMatview?: boolean;
|
||||||
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
|
||||||
variant?: "table" | "query";
|
variant?: "table" | "query";
|
||||||
|
/** Open the full-result streamed export dialog. */
|
||||||
|
onExportToFile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableControls({
|
export function TableControls({
|
||||||
@@ -488,6 +490,7 @@ export function TableControls({
|
|||||||
defaultRefreshRate = 0,
|
defaultRefreshRate = 0,
|
||||||
isMatview = false,
|
isMatview = false,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
|
onExportToFile,
|
||||||
}: TableControlsProps) {
|
}: TableControlsProps) {
|
||||||
const isQuery = variant === "query";
|
const isQuery = variant === "query";
|
||||||
const notify = useNotificationStore((s) => s.notify);
|
const notify = useNotificationStore((s) => s.notify);
|
||||||
@@ -636,6 +639,21 @@ export function TableControls({
|
|||||||
{fmt.label}
|
{fmt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{onExportToFile && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 h-px bg-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExportOpen(false);
|
||||||
|
onExportToFile();
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-1.5 text-xs text-left text-text hover:bg-surface-raised transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Export all rows to file…
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -295,6 +295,15 @@ export async function executeQueryMulti(
|
|||||||
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
return invoke<MultiQueryResult>("execute_query_multi", { connectionId, query, page, pageSize });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function exportQueryToFile(
|
||||||
|
connectionId: string,
|
||||||
|
sql: string,
|
||||||
|
format: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<{ rows_written: number; path: string }> {
|
||||||
|
return invoke("export_query_to_file", { connectionId, sql, format, path });
|
||||||
|
}
|
||||||
|
|
||||||
export async function getQueryHistory(
|
export async function getQueryHistory(
|
||||||
connectionId: string | null,
|
connectionId: string | null,
|
||||||
limit: number,
|
limit: number,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { exportData } from "./exportData";
|
import { exportData, csvCell, qualifiedTableSql } from "./exportData";
|
||||||
import type { ColumnInfo } from "./types";
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
const columns: ColumnInfo[] = [
|
const columns: ColumnInfo[] = [
|
||||||
@@ -49,3 +49,25 @@ describe("exportData", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("csvCell (client CSV parity with streamed export)", () => {
|
||||||
|
it("quotes and guards like the Rust writer", () => {
|
||||||
|
expect(csvCell("plain")).toBe("plain");
|
||||||
|
expect(csvCell("a,b")).toBe('"a,b"');
|
||||||
|
expect(csvCell('a"b')).toBe('"a""b"');
|
||||||
|
expect(csvCell("=SUM(A1)")).toBe("'=SUM(A1)");
|
||||||
|
expect(csvCell("+x")).toBe("'+x");
|
||||||
|
expect(csvCell("-1")).toBe("'-1");
|
||||||
|
expect(csvCell("@cmd")).toBe("'@cmd");
|
||||||
|
expect(csvCell(null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("qualifiedTableSql", () => {
|
||||||
|
it("double-quotes for PG/SQLite and backticks for MySQL/MariaDB", () => {
|
||||||
|
expect(qualifiedTableSql("postgresql", "public", "users")).toBe('SELECT * FROM "public"."users"');
|
||||||
|
expect(qualifiedTableSql("sqlite", "main", "users")).toBe('SELECT * FROM "main"."users"');
|
||||||
|
expect(qualifiedTableSql("mysql", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||||
|
expect(qualifiedTableSql("mariadb", "app", "users")).toBe("SELECT * FROM `app`.`users`");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,27 @@
|
|||||||
import { buildXlsx } from "./xlsx";
|
import { buildXlsx } from "./xlsx";
|
||||||
import type { ColumnInfo } from "./types";
|
import type { ColumnInfo } from "./types";
|
||||||
|
|
||||||
|
/** Quote one CSV cell with formula-injection protection — mirrors the Rust
|
||||||
|
* `csv_cell` from the streaming export (Task 10) byte-for-byte. */
|
||||||
|
export function csvCell(value: unknown): string {
|
||||||
|
const s = value === null || value === undefined ? "" : String(value);
|
||||||
|
const guarded = /^[=+\-@\t\r]/.test(s) ? `'${s}` : s;
|
||||||
|
if (/[,"\n\r]/.test(guarded)) {
|
||||||
|
return `"${guarded.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return guarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SQL selecting every row of a table, dialect-quoted. */
|
||||||
|
export function qualifiedTableSql(dbType: string, schema: string, table: string): string {
|
||||||
|
if (dbType === "mysql" || dbType === "mariadb") {
|
||||||
|
const q = (s: string) => s.replace(/`/g, "``");
|
||||||
|
return `SELECT * FROM \`${q(schema)}\`.\`${q(table)}\``;
|
||||||
|
}
|
||||||
|
const q = (s: string) => s.replace(/"/g, '""');
|
||||||
|
return `SELECT * FROM "${q(schema)}"."${q(table)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
export function exportData(
|
export function exportData(
|
||||||
rows: unknown[][],
|
rows: unknown[][],
|
||||||
columns: ColumnInfo[],
|
columns: ColumnInfo[],
|
||||||
@@ -34,14 +55,9 @@ export function exportData(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "csv": {
|
case "csv": {
|
||||||
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
|
const csvRows = [headers.map((h) => csvCell(h)).join(",")];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
csvRows.push(
|
csvRows.push(row.map((cell) => csvCell(cell)).join(","));
|
||||||
row.map((cell) => {
|
|
||||||
const s = cell === null || cell === undefined ? "" : String(cell);
|
|
||||||
return `"${s.replace(/"/g, '""')}"`;
|
|
||||||
}).join(","),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
content = csvRows.join("\n");
|
content = csvRows.join("\n");
|
||||||
mime = "text/csv";
|
mime = "text/csv";
|
||||||
|
|||||||
Reference in New Issue
Block a user