feat: full-result export dialog with progress/cancel + client CSV injection parity (Task 11)

This commit is contained in:
2026-09-09 20:50:42 +08:00
parent f7c77119ea
commit fc31d5614e
7 changed files with 311 additions and 8 deletions
@@ -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<number | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [destructiveQuery, setDestructiveQuery] = useState<string | null>(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,
),
})
}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -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,
),
})
}
/>
)}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -1572,6 +1599,13 @@ const onQueriesPanelResizeStart = useCallback(
{capabilities.objects && (
<ObjectSearchPalette connectionId={connectionId} />
)}
{streamExport && (
<StreamExportDialog
connectionId={connectionId}
sql={streamExport.sql}
onClose={() => setStreamExport(null)}
/>
)}
</div>
</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;
/** "table" = full table toolbar; "query" = export/refresh/columns + timing */
variant?: "table" | "query";
/** Open the full-result streamed export dialog. */
onExportToFile?: () => void;
}
export function TableControls({
@@ -488,6 +490,7 @@ export function TableControls({
defaultRefreshRate = 0,
isMatview = false,
variant = "table",
onExportToFile,
}: TableControlsProps) {
const isQuery = variant === "query";
const notify = useNotificationStore((s) => s.notify);
@@ -636,6 +639,21 @@ export function TableControls({
{fmt.label}
</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>
</div>
);
+9
View File
@@ -295,6 +295,15 @@ export async function executeQueryMulti(
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(
connectionId: string | null,
limit: number,
+23 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { exportData } from "./exportData";
import { exportData, csvCell, qualifiedTableSql } from "./exportData";
import type { ColumnInfo } from "./types";
const columns: ColumnInfo[] = [
@@ -48,4 +48,26 @@ describe("exportData", () => {
globalThis.URL.revokeObjectURL = origRevokeObjectURL;
}
});
});
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`");
});
});
+23 -7
View File
@@ -1,6 +1,27 @@
import { buildXlsx } from "./xlsx";
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(
rows: unknown[][],
columns: ColumnInfo[],
@@ -34,14 +55,9 @@ export function exportData(
break;
}
case "csv": {
const csvRows = [headers.map((h) => `"${h.replace(/"/g, '""')}"`).join(",")];
const csvRows = [headers.map((h) => csvCell(h)).join(",")];
for (const row of rows) {
csvRows.push(
row.map((cell) => {
const s = cell === null || cell === undefined ? "" : String(cell);
return `"${s.replace(/"/g, '""')}"`;
}).join(","),
);
csvRows.push(row.map((cell) => csvCell(cell)).join(","));
}
content = csvRows.join("\n");
mime = "text/csv";