diff --git a/src/features/database/DataView.tsx b/src/features/database/DataView.tsx index ecad794d2..d1a7ee8f1 100644 --- a/src/features/database/DataView.tsx +++ b/src/features/database/DataView.tsx @@ -8,7 +8,7 @@ import { useMemo, useRef, useState, type KeyboardEvent, type RefObject } from "r import clsx from "clsx"; import { Button, Checkbox, Icon, IconButton, Input, Popup, SearchField, Select, Tabs, T, type MenuItem } from "@/ui"; import { EXPORT_FORMATS, exportData, type ExportData, type ExportFormat } from "./export"; -import { Grid, type GridColumn, type GridProps, type GridRange } from "./Grid"; +import { Grid, type CopyFormat, type GridColumn, type GridProps, type GridRange } from "./Grid"; import type { Cell } from "./model"; import { aggregates, @@ -55,6 +55,10 @@ export function ExtractorSelect({ value, onChange }: { value: ExportFormat; onCh ); } +/** The copy format plain ⌘C uses under a toolbar extractor (cell copies stay cell-shaped). */ +export const copyFormatOf = (extractor: ExportFormat): CopyFormat => + extractor === "csv" ? "csv" : extractor === "onerow" ? "onerow" : "tsv"; + /** The Record / Value / Aggregates panel's toggle (IDEA's "Show Value Editor"). */ export function PanelToggle({ panel, onChange }: { panel: DataPanel | null; onChange: (p: DataPanel | null) => void }) { return ( @@ -87,6 +91,8 @@ export interface DataViewProps extends Pick< /** The side panel shown (`null`: hidden). */ panel: DataPanel | null; onPanelChange: (panel: DataPanel | null) => void; + /** Plain ⌘C's copy format (the toolbar's extractor; TSV by default). */ + copyFormat?: CopyFormat; /** The search bar is shown (the toolbar's search button, ⌘F in the grid). */ searchOpen?: boolean; onSearchOpenChange?: (open: boolean) => void; @@ -214,6 +220,7 @@ export function DataView(props: DataViewProps) { onSort={onSort} rowMenu={props.rowMenu && ((rows) => props.rowMenu!(rows.map(at)))} rowNumber={(r) => (props.firstRowNumber ?? 1) + at(r)} + copyFormat={props.copyFormat} onSelectionChange={setRange} onFilter={(col, anchor) => setFilterAt({ col, anchor })} filtered={(col) => filter.has(col)} diff --git a/src/features/database/Grid.test.tsx b/src/features/database/Grid.test.tsx index 78e1742bf..7c8e736a6 100644 --- a/src/features/database/Grid.test.tsx +++ b/src/features/database/Grid.test.tsx @@ -53,6 +53,73 @@ describe("database grid", () => { expect(writeText).toHaveBeenCalledWith("one\ttwo\nthree\tfour"); expect(globalCopy).not.toHaveBeenCalled(); }); + it("copies the selection in the extractor's format for plain ⌘C", () => { + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + const data = [ + ["1", "Shanghai"], + ["2", "Bei'jing"], + ["3", null], + ]; + render( + data[r]![c]!} + copyFormat="onerow" + />, + ); + const cells = screen.getAllByRole("gridcell"); + // A column of three cells: the values on one line. + fireEvent.mouseDown(cells[0]!, { button: 0 }); + fireEvent.mouseEnter(cells[4]!); + fireEvent.mouseUp(window); + fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith("1, 2, 3"); + // Text values are single-quoted with `'` doubled; nulls are NULL. + writeText.mockClear(); + fireEvent.mouseDown(cells[1]!, { button: 0 }); + fireEvent.mouseEnter(cells[5]!); + fireEvent.mouseUp(window); + fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith("'Shanghai', 'Bei''jing', NULL"); + // Several columns join in reading order, all on one line. + writeText.mockClear(); + fireEvent.mouseDown(cells[0]!, { button: 0 }); + fireEvent.mouseEnter(cells[3]!); + fireEvent.mouseUp(window); + fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith("1, 'Shanghai', 2, 'Bei''jing'"); + }); + it("copies a selection as CSV with ⌘C when the extractor is csv", () => { + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render( + + [ + ["one", "two"], + ["three", "four"], + ][r]![c]! + } + copyFormat="csv" + />, + ); + const cells = screen.getAllByRole("gridcell"); + fireEvent.mouseDown(cells[0]!, { button: 0 }); + fireEvent.mouseEnter(cells[3]!); + fireEvent.mouseUp(window); + fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith("one,two\r\nthree,four"); + }); it("pastes a rectangle as pending edits and skips deleted rows", () => { const onEdit = vi.fn(); render( diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index ee3800e04..7bcc9c711 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -1,13 +1,13 @@ /** * Result / table data grid: virtualised rows, resizable columns, rectangular - * cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV, `` - * rendering, sortable headers and in-place editing (table editor). + * cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV / One-Row, + * `` rendering, sortable headers and in-place editing (table editor). */ import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import clsx from "clsx"; import { Dialog, Icon, showContextMenu, type MenuItem } from "@/ui"; -import { parseDelimited, toCsv, toTsv, type Cell } from "./model"; +import { parseDelimited, toCsv, toOneRow, toTsv, type Cell } from "./model"; import styles from "./Database.module.css"; import { tx } from "@/platform/i18n"; import { CELL_PREVIEW_LIMIT, cellPreview, hasLineBreak } from "./cellPreview"; @@ -29,11 +29,16 @@ export function isNumericColumn(type: string, sample: readonly Cell[]): boolean return values.length > 0 && values.every((v) => NUMBER.test(v)); } +/** The grid's own copy formats (the extractor drop-down chooses one for plain ⌘C). */ +export type CopyFormat = "tsv" | "csv" | "onerow"; + export interface GridProps { columns: readonly GridColumn[]; rowCount: number; /** Displayed value (pending edits applied). */ value: (row: number, col: number) => Cell; + /** The format plain ⌘C copies in (the toolbar's extractor; TSV by default). */ + copyFormat?: CopyFormat; rowState?: (row: number) => "deleted" | "inserted" | null; isModified?: (row: number, col: number) => boolean; editable?: boolean; @@ -117,7 +122,18 @@ function norm(s: Sel) { } export function Grid(props: GridProps) { - const { columns, rowCount, value, editable, onEdit, sort, onSort, rowMenu, firstRowNumber = 1 } = props; + const { + columns, + rowCount, + value, + editable, + onEdit, + sort, + onSort, + rowMenu, + firstRowNumber = 1, + copyFormat = "tsv", + } = props; const scrollRef = useRef(null); const [sel, setSel] = useState(null); const dragging = useRef(false); @@ -208,7 +224,7 @@ export function Grid(props: GridProps) { return Array.from({ length: n.bottom - n.top + 1 }, (_, i) => n.top + i); }; - const copy = (format: "tsv" | "csv", header: boolean, wholeRows = false) => { + const copy = (format: CopyFormat, header: boolean, wholeRows = false) => { if (!sel) return; const n = norm(sel); const cols = wholeRows @@ -216,7 +232,12 @@ export function Grid(props: GridProps) { : Array.from({ length: n.right - n.left + 1 }, (_, i) => n.left + i); const rows = selectedRows().map((r) => cols.map((c) => value(r, c))); const head = header ? cols.map((c) => columns[c]!.name) : null; - const text = format === "tsv" ? toTsv(head, rows) : toCsv(head, rows); + const text = + format === "onerow" + ? toOneRow(rows, (c) => !!numeric[cols[c]!]) + : format === "tsv" + ? toTsv(head, rows) + : toCsv(head, rows); void navigator.clipboard?.writeText(text); }; @@ -247,7 +268,7 @@ export function Grid(props: GridProps) { const mod = e.metaKey || e.ctrlKey; if (mod && e.key.toLowerCase() === "c") { e.preventDefault(); - copy("tsv", false); + copy(copyFormat, false); return; } if (mod && e.key.toLowerCase() === "f" && props.onFind) { @@ -326,7 +347,15 @@ export function Grid(props: GridProps) { e.preventDefault(); if (!sel) return; const items: MenuItem[] = [ - { label: "Copy", shortcut: "⌘C", run: () => copy("tsv", false) }, + { label: "Copy", shortcut: "⌘C", run: () => copy(copyFormat, false) }, + { + label: "Copy as", + submenu: [ + { label: "TSV", verbatim: true, run: () => copy("tsv", false) }, + { label: "CSV", verbatim: true, run: () => copy("csv", false) }, + { label: "One-Row", run: () => copy("onerow", false) }, + ], + }, { label: "Copy with Header", run: () => copy("tsv", true) }, { label: "Copy Rows as TSV", run: () => copy("tsv", true, true) }, { label: "Copy Rows as CSV", run: () => copy("csv", true, true) }, diff --git a/src/features/database/ResultsView.tsx b/src/features/database/ResultsView.tsx index ee3d34ddc..b3a1505f9 100644 --- a/src/features/database/ResultsView.tsx +++ b/src/features/database/ResultsView.tsx @@ -10,7 +10,7 @@ import type { DbPlanNode } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui"; import { ChartView } from "./ChartView"; -import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView"; +import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView"; import type { ExportData, ExportFormat } from "./export"; import { pageWindow } from "./resultView"; import { analyze } from "./sql/analyze"; @@ -215,6 +215,7 @@ function Result({ tab }: { tab: ResultTab }) { value={value} panel={panel} onPanelChange={setPanel} + copyFormat={copyFormatOf(extractor)} searchOpen={searchOpen} onSearchOpenChange={setSearchOpen} firstRowNumber={(tab.page ?? 0) * (tab.pageSize ?? 0) + 1} diff --git a/src/features/database/TableEditor.tsx b/src/features/database/TableEditor.tsx index c1a2773ce..03bc6cd6a 100644 --- a/src/features/database/TableEditor.tsx +++ b/src/features/database/TableEditor.tsx @@ -17,7 +17,7 @@ import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMe import { notifications, type EditorInput } from "@/workbench/contributions"; import { SqlFilterInput } from "./SqlFilterInput"; import { ColumnsPopup } from "./ColumnsPopup"; -import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView"; +import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView"; import type { ExportFormat } from "./export"; import { buildRowEdits, @@ -355,6 +355,7 @@ export function TableEditor({ input }: { input: EditorInput }) { value={value} panel={panel} onPanelChange={setPanel} + copyFormat={copyFormatOf(extractor)} searchOpen={searchOpen} onSearchOpenChange={setSearchOpen} firstRowNumber={offset + 1} diff --git a/src/features/database/export.ts b/src/features/database/export.ts index 0aea5bb60..91d7505cf 100644 --- a/src/features/database/export.ts +++ b/src/features/database/export.ts @@ -1,14 +1,15 @@ /** * Result-set export formats (IDEA's data extractors): CSV, TSV, JSON, SQL - * INSERT statements and a Markdown table. Values arrive as text; numeric - * columns are written unquoted where the format distinguishes them. + * INSERT statements, a Markdown table and One-Row (all values on one `, ` list). + * Values arrive as text; numeric columns are written unquoted where the format + * distinguishes them. */ import type { DbDriver } from "@/platform/ipc"; import { isNumericColumn } from "./Grid"; -import { toCsv, toTsv, type Cell } from "./model"; +import { toCsv, toOneRow, toTsv, type Cell } from "./model"; import { quoteIdent } from "./sql/complete"; -export type ExportFormat = "csv" | "tsv" | "json" | "sql" | "markdown"; +export type ExportFormat = "csv" | "tsv" | "json" | "sql" | "markdown" | "onerow"; export const EXPORT_FORMATS: ReadonlyArray<{ id: ExportFormat; label: string; extension: string }> = [ { id: "csv", label: "CSV", extension: "csv" }, @@ -16,6 +17,7 @@ export const EXPORT_FORMATS: ReadonlyArray<{ id: ExportFormat; label: string; ex { id: "json", label: "JSON", extension: "json" }, { id: "sql", label: "SQL Inserts", extension: "sql" }, { id: "markdown", label: "Markdown", extension: "md" }, + { id: "onerow", label: "One-Row", extension: "txt" }, ]; export interface ExportData { @@ -99,5 +101,7 @@ export function exportData( : [...(options.rowHeader ? ["--:"] : []), ...numeric.map((n) => (n ? "--:" : "---"))]; return [head.map(cell), align, ...t.rows.map((r) => r.map(cell))].map((r) => `| ${r.join(" | ")} |`).join("\n"); } + case "onerow": + return toOneRow(d.rows, (c) => numeric[c]!); } } diff --git a/src/features/database/model.ts b/src/features/database/model.ts index 222b85acd..98b2470df 100644 --- a/src/features/database/model.ts +++ b/src/features/database/model.ts @@ -22,6 +22,17 @@ export function toTsv(header: readonly string[] | null, rows: ReadonlyArray, numeric: (col: number) => boolean): string { + const literal = (v: Cell, c: number) => + v === null ? "NULL" : numeric(c) && Number.isFinite(Number(v)) ? v : `'${v.replace(/'/g, "''")}'`; + return rows.flatMap((r) => r.map(literal)).join(", "); +} + /** RFC 4180 CSV; NULL is an empty field. */ export function toCsv(header: readonly string[] | null, rows: ReadonlyArray): string { const field = (v: Cell) => { diff --git a/src/features/database/resultView.test.ts b/src/features/database/resultView.test.ts index 8ad3dc5d2..08b6ba837 100644 --- a/src/features/database/resultView.test.ts +++ b/src/features/database/resultView.test.ts @@ -122,6 +122,7 @@ describe("export options", () => { "id\t1\t2\ncity\tShanghai\t", ); expect(exportData("csv", data)).toBe("id,city\r\n1,Shanghai\r\n2,"); + expect(exportData("onerow", data)).toBe("1, 'Shanghai', 2, NULL"); }); }); diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index c4017151c..a19c1bd83 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -609,6 +609,8 @@ "Copy Revision Numbers": "复制修订号", "Copy Rows as CSV": "将行复制为 CSV", "Copy Rows as TSV": "将行复制为 TSV", + "Copy as": "复制为", + "One-Row": "单行", "Copy Stack": "复制堆栈", "Copy Value": "复制值", "Copy as rich text by default": "默认复制为富文本",