Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 66 additions & 3 deletions src/pages/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { reconstructTableQuery } from "../utils/editor";
import { shouldShowStatementSuccess } from "../utils/resultPresentation";
import { formatRowsForCopy, copyTextToClipboard } from "../utils/clipboard";
import { formatResultForExport } from "../utils/resultExport";
import { serializePkKey, buildPkMap } from "../utils/dataGrid";
import {
buildKeylessUpdatePlan,
Expand Down Expand Up @@ -70,6 +71,7 @@ import {
} from "lucide-react";
import { invoke } from "@tauri-apps/api/core";
import { listen, emit } from "@tauri-apps/api/event";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { TableToolbar } from "../components/ui/TableToolbar";
import { DataGrid } from "../components/ui/DataGrid";
import { MultiResultPanel } from "../components/ui/MultiResultPanel";
Expand Down Expand Up @@ -98,6 +100,7 @@ import {
removeOtherEntries,
removeEntriesToRight,
removeEntriesToLeft,
findActiveEntry,
} from "../utils/multiResult";
import {
extractQueryParams,
Expand Down Expand Up @@ -468,6 +471,16 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
const isMultiDb = usesMultiDatabaseLayout(activeCapabilities, selectedDatabases);
const isEditorOpen =
!isTableTab && (activeTab?.isEditorOpen ?? activeTab?.type !== "table");
const activeResultEntry = useMemo(
() =>
activeTab?.results
? findActiveEntry(activeTab.results, activeTab.activeResultId)
: undefined,
[activeTab?.activeResultId, activeTab?.results],
);
const activeExportResult = activeResultEntry?.result ?? activeTab?.result;
const canExportActiveResult =
!!activeExportResult && activeExportResult.rows.length > 0;

const handleCloseTab = useCallback(
(tabId: string) => {
Expand Down Expand Up @@ -1346,7 +1359,15 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
applied.add(idx);
applyStatement(idx, item);
});
updateTab(targetTabId, { isLoading: false });
const firstResultEntry = batchResults.findIndex(
(item) => (item.result?.rows.length ?? 0) > 0,
);
updateTab(targetTabId, {
isLoading: false,
...(firstResultEntry >= 0
? { activeResultId: entries[firstResultEntry].id }
: {}),
});
},
[
activeConnectionId,
Expand Down Expand Up @@ -3273,6 +3294,49 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
const handleExportCommon = async (format: "csv" | "json" | "markdown") => {
if (!activeTab || !activeConnectionId) return;

const extension = format === "markdown" ? "md" : format;
const multiResult = activeResultEntry?.result;
if (multiResult?.rows.length) {
try {
const filePath = await save({
filters: [
{
name: format === "markdown" ? "Markdown" : format.toUpperCase(),
extensions: [extension],
},
],
defaultPath: `result_${Date.now()}.${extension}`,
});

if (!filePath) return;

setExportState({
isOpen: true,
status: "exporting",
rowsProcessed: multiResult.rows.length,
fileName: filePath.split(/[/\\]/).pop() || filePath,
});
setExportMenuOpen(false);

await writeTextFile(
filePath,
formatResultForExport(multiResult, format, csvDelimiter),
);

setExportState((prev) => ({
...prev,
status: "completed",
}));
} catch (e) {
setExportState((prev) => ({
...prev,
status: "error",
errorMessage: String(e),
}));
}
return;
}

const effectiveSchema =
activeCapabilities?.schemas === true ? activeTab.schema : undefined;
const tabForQuery = { ...activeTab, schema: effectiveSchema };
Expand All @@ -3284,7 +3348,6 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
if (!query || !query.trim()) return;

try {
const extension = format === "markdown" ? "md" : format;
const filePath = await save({
filters: [
{
Expand Down Expand Up @@ -3813,7 +3876,7 @@ export const Editor = ({ commandScopeId }: EditorProps) => {
<div ref={exportMenuRef} className="relative ml-auto shrink-0">
<button
onClick={() => setExportMenuOpen(!exportMenuOpen)}
disabled={!activeTab.result || activeTab.result.rows.length === 0}
disabled={!canExportActiveResult}
aria-haspopup="menu"
aria-expanded={exportMenuOpen}
title={t("editor.export")}
Expand Down
52 changes: 52 additions & 0 deletions src/utils/resultExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { QueryResult } from "../types/editor";
import { rowsToMarkdown } from "./clipboard";

export type ResultExportFormat = "csv" | "json" | "markdown";

function csvValue(value: unknown, delimiter: string): string {
const text =
value === null || value === undefined
? ""
: typeof value === "object"
? JSON.stringify(value)
: String(value);

if (text.includes(delimiter) || text.includes('"') || /\r?\n/.test(text)) {
return `"${text.replace(/"/g, '""')}"`;
}

return text;
}

function resultToCsv(result: QueryResult, delimiter: string): string {
const header = result.columns
.map((column) => csvValue(column, delimiter))
.join(delimiter);
const rows = result.rows.map((row) =>
row.map((value) => csvValue(value, delimiter)).join(delimiter),
);
return [header, ...rows].join("\n");
}

export function formatResultForExport(
result: QueryResult,
format: ResultExportFormat,
csvDelimiter = ",",
): string {
if (format === "json") {
const rows = result.rows.map((row) => {
const obj: Record<string, unknown> = {};
result.columns.forEach((column, index) => {
obj[column] = row[index] ?? null;
});
return obj;
});
return JSON.stringify(rows, null, 2);
}

if (format === "markdown") {
return rowsToMarkdown(result.rows, result.columns);
}

return resultToCsv(result, csvDelimiter);
}
57 changes: 57 additions & 0 deletions tests/utils/resultExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { QueryResult } from "../../src/types/editor";
import { formatResultForExport } from "../../src/utils/resultExport";

const result: QueryResult = {
columns: ["id", "name"],
rows: [
[1, "John"],
[2, null],
],
affected_rows: 0,
};

describe("resultExport", () => {
it("formats loaded result rows as CSV with headers", () => {
expect(formatResultForExport(result, "csv")).toBe(
"id,name\n1,John\n2,",
);
});

it("uses the configured CSV delimiter", () => {
expect(formatResultForExport(result, "csv", ";")).toBe(
"id;name\n1;John\n2;",
);
});

it("escapes CSV fields that contain delimiters, quotes, or newlines", () => {
const specialResult: QueryResult = {
columns: ["id", "note"],
rows: [[1, 'hello, "world"\nagain']],
affected_rows: 0,
};

expect(formatResultForExport(specialResult, "csv")).toBe(
'id,note\n1,"hello, ""world""\nagain"',
);
});

it("formats loaded result rows as pretty JSON", () => {
expect(formatResultForExport(result, "json")).toBe(
JSON.stringify(
[
{ id: 1, name: "John" },
{ id: 2, name: null },
],
null,
2,
),
);
});

it("formats loaded result rows as Markdown", () => {
expect(formatResultForExport(result, "markdown")).toBe(
"| id | name |\n| --- | --- |\n| 1 | John |\n| 2 | null |",
);
});
});