0.8.0: MariaDB + PlanetScale, TimescaleDB, multi-result sets, streaming export, schema diff #27
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] = [
|
||||
@@ -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 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";
|
||||
|
||||
Reference in New Issue
Block a user