diff --git a/docs/features/audit-log.mdx b/docs/features/audit-log.mdx
index f7e2e88..dbc4f39 100644
--- a/docs/features/audit-log.mdx
+++ b/docs/features/audit-log.mdx
@@ -145,7 +145,7 @@ All audit events are JSON objects with a common structure:
| `database` | Database name |
| `sql` | SQL query that produced the exported data |
| `row_count` | Number of rows exported |
-| `format` | Export format (`csv`) |
+| `format` | Export format (`csv`, `tsv`, `json`, or `markdown`) |
| `source` | Export origin: currently `"web"` |
## Capturing Logs
diff --git a/docs/features/database-access-control.mdx b/docs/features/database-access-control.mdx
index 4cbd677..1b9ec84 100644
--- a/docs/features/database-access-control.mdx
+++ b/docs/features/database-access-control.mdx
@@ -45,7 +45,7 @@ Independent permissions control what users can do:
| `admin` | Role/database management, `pg_terminate_backend` | Terminate sessions, cancel other users' queries |
| `explain` | `EXPLAIN` queries | Explain button in editor toolbar and context menu |
| `execute` | `CALL` stored procedures | Procedure execution |
-| `export` | Export from results | Export button |
+| `export` | Export from results | Export menu |
A single SQL statement can require multiple permissions. For example, `SELECT pg_terminate_backend(123)` requires both `read` (for the SELECT) and `admin` (for the function call).
diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx
index bad2a3b..7aac3e7 100644
--- a/docs/features/sql-editor.mdx
+++ b/docs/features/sql-editor.mdx
@@ -70,7 +70,7 @@ When changes are pending, a floating bar lets you **Preview** the generated SQL
### Data Export
-- **Export to CSV** — downloads visible rows as a CSV file (requires `export` permission)
+- **Export results** — downloads visible rows as CSV, TSV, JSON, or Markdown (requires `export` permission)
- **Copy as Markdown** — copies results as a markdown table to the clipboard
## Schema Inspection
@@ -83,4 +83,4 @@ Schema tabs provide detailed inspection of database objects.
The Processes button in the toolbar opens a modal showing active database connections. The list auto-refreshes periodically. With `admin` permission, a **Terminate** button appears on each row with a confirmation countdown.
-
\ No newline at end of file
+
diff --git a/src/components/sql-editor/QueryResults.tsx b/src/components/sql-editor/QueryResults.tsx
index 19bc586..5925cbb 100644
--- a/src/components/sql-editor/QueryResults.tsx
+++ b/src/components/sql-editor/QueryResults.tsx
@@ -7,7 +7,8 @@ import { SearchInput } from '../ui/search-input'
import { Button } from '../ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip'
import type { QueryResult, ResultTab } from './hooks/useEditorTabs'
-import { exportToCsv } from '@/lib/export-csv'
+import { EXPORT_FORMATS, exportRows, type ExportFormat } from '@/lib/export-csv'
+import { Menu, MenuTrigger, MenuPopup, MenuItem } from '../ui/menu'
import { queryClient } from '@/lib/connect-client'
import { RowDetailPanel } from './RowDetailPanel'
import { JsonExpandModal } from './JsonExpandModal'
@@ -1385,21 +1386,23 @@ export function QueryResults({
await navigator.clipboard.writeText(lines.join('\n'))
}, [activeResult, displayRows])
- const handleExport = useCallback(() => {
+ const handleExport = useCallback((format: ExportFormat) => {
if (!activeResult) return
const now = new Date()
const timestamp = now.toISOString().slice(0, 19).replace(/[-:T]/g, (m) => (m === 'T' ? '-' : ''))
- const filename = `export-${timestamp}.csv`
+ const exportFormat = EXPORT_FORMATS.find((item) => item.value === format)
+ if (!exportFormat) return
+ const filename = `export-${timestamp}.${exportFormat.extension}`
const rows = displayRows.filter(r => r.status === 'normal').map(r => r.data)
- exportToCsv(activeResult.result.columns, rows, filename)
+ exportRows(activeResult.result.columns, rows, filename, format)
queryClient.auditExport({
connectionId,
sql: activeResult.sql || '',
rowCount: rows.length,
- format: 'csv',
+ format,
}).catch(() => {})
}, [activeResult, displayRows, connectionId])
@@ -1509,15 +1512,27 @@ export function QueryResults({
{hasExport && (
-
+
)}
diff --git a/src/lib/export-csv.ts b/src/lib/export-csv.ts
index 2d02070..b9963c5 100644
--- a/src/lib/export-csv.ts
+++ b/src/lib/export-csv.ts
@@ -1,6 +1,20 @@
import Papa from 'papaparse'
import type { ColumnMetadata } from '@/components/sql-editor/hooks/useEditorTabs'
+export type ExportFormat = 'csv' | 'tsv' | 'json' | 'markdown'
+
+export const EXPORT_FORMATS: Array<{
+ value: ExportFormat
+ label: string
+ extension: string
+ mimeType: string
+}> = [
+ { value: 'csv', label: 'CSV', extension: 'csv', mimeType: 'text/csv;charset=utf-8;' },
+ { value: 'tsv', label: 'TSV', extension: 'tsv', mimeType: 'text/tab-separated-values;charset=utf-8;' },
+ { value: 'json', label: 'JSON', extension: 'json', mimeType: 'application/json;charset=utf-8;' },
+ { value: 'markdown', label: 'Markdown', extension: 'md', mimeType: 'text/markdown;charset=utf-8;' },
+]
+
function sanitizeCsvString(value: string): string {
return /^[\s]*[=+\-@\t\r]/.test(value) ? `'${value}` : value
}
@@ -9,18 +23,83 @@ function sanitizeCsvCell(value: unknown): unknown {
return typeof value === 'string' ? sanitizeCsvString(value) : value
}
-export function exportToCsv(
+function normalizeJsonValue(value: unknown): unknown {
+ if (value === undefined) return null
+ if (typeof value === 'bigint') return value.toString()
+ if (value instanceof Date) return value.toISOString()
+ return value
+}
+
+function serializeDelimited(
columns: Array>,
rows: Record[],
- filename: string
-) {
+ delimiter: ',' | '\t'
+): string {
const columnNames = columns.map((col) => col.name)
- const csv = Papa.unparse({
+ return Papa.unparse({
fields: columnNames.map((name) => sanitizeCsvString(name)),
data: rows.map((row) => columnNames.map((name) => sanitizeCsvCell(row[name]))),
+ }, {
+ delimiter,
})
+}
+
+function serializeJson(
+ columns: Array>,
+ rows: Record[]
+): string {
+ const columnNames = columns.map((col) => col.name)
+ const projectedRows = rows.map((row) => Object.fromEntries(
+ columnNames.map((name) => [name, normalizeJsonValue(row[name])])
+ ))
+ return `${JSON.stringify(projectedRows, null, 2)}\n`
+}
+
+function formatMarkdownCell(value: unknown): string {
+ if (value === null) return 'null'
+ if (value === undefined) return ''
+ return String(value)
+ .replace(/\|/g, '\\|')
+ .replace(/\r?\n/g, ' ')
+}
+
+function serializeMarkdown(
+ columns: Array>,
+ rows: Record[]
+): string {
+ const columnNames = columns.map((col) => col.name)
+ const lines = [
+ `| ${columnNames.map(formatMarkdownCell).join(' | ')} |`,
+ `| ${columnNames.map(() => '---').join(' | ')} |`,
+ ]
+
+ for (const row of rows) {
+ lines.push(`| ${columnNames.map((name) => formatMarkdownCell(row[name])).join(' | ')} |`)
+ }
+
+ return `${lines.join('\n')}\n`
+}
+
+export function serializeExport(
+ columns: Array>,
+ rows: Record[],
+ format: ExportFormat
+): string {
+ switch (format) {
+ case 'csv':
+ return serializeDelimited(columns, rows, ',')
+ case 'tsv':
+ return serializeDelimited(columns, rows, '\t')
+ case 'json':
+ return serializeJson(columns, rows)
+ case 'markdown':
+ return serializeMarkdown(columns, rows)
+ }
+}
+
+function downloadText(content: string, filename: string, mimeType: string) {
+ const blob = new Blob([content], { type: mimeType })
- const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@@ -28,3 +107,23 @@ export function exportToCsv(
link.click()
URL.revokeObjectURL(url)
}
+
+export function exportRows(
+ columns: Array>,
+ rows: Record[],
+ filename: string,
+ format: ExportFormat
+) {
+ const exportFormat = EXPORT_FORMATS.find((item) => item.value === format)
+ if (!exportFormat) return
+
+ downloadText(serializeExport(columns, rows, format), filename, exportFormat.mimeType)
+}
+
+export function exportToCsv(
+ columns: Array>,
+ rows: Record[],
+ filename: string
+) {
+ exportRows(columns, rows, filename, 'csv')
+}
diff --git a/tests/export.test.ts b/tests/export.test.ts
new file mode 100644
index 0000000..9d4c9ef
--- /dev/null
+++ b/tests/export.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { serializeExport } from '../src/lib/export-csv'
+
+const columns = [
+ { name: 'id' },
+ { name: 'name' },
+ { name: 'note' },
+]
+
+const rows = [
+ { id: 1, name: 'alpha', note: '=cmd' },
+ { id: 2, name: 'pipe|name', note: 'line\nbreak' },
+]
+
+describe('serializeExport', () => {
+ it('serializes CSV and sanitizes formula-like cells', () => {
+ expect(serializeExport(columns, rows, 'csv')).toBe('id,name,note\r\n1,alpha,\'=cmd\r\n2,pipe|name,"line\nbreak"')
+ })
+
+ it('serializes TSV', () => {
+ expect(serializeExport(columns, rows, 'tsv')).toBe('id\tname\tnote\r\n1\talpha\t\'=cmd\r\n2\tpipe|name\t"line\nbreak"')
+ })
+
+ it('serializes JSON with the selected column order', () => {
+ expect(serializeExport(columns, [{ id: 1, name: undefined, note: 3n }], 'json')).toBe(`[
+ {
+ "id": 1,
+ "name": null,
+ "note": "3"
+ }
+]\n`)
+ })
+
+ it('serializes Markdown and escapes table pipes', () => {
+ expect(serializeExport(columns, rows, 'markdown')).toBe([
+ '| id | name | note |',
+ '| --- | --- | --- |',
+ '| 1 | alpha | =cmd |',
+ '| 2 | pipe\\|name | line break |',
+ '',
+ ].join('\n'))
+ })
+})