feat(database): One-Row extractor, and ⌘C copies in the chosen extractor's format (#1842)

The grid's context menu gets Copy as ▸ TSV / CSV / One-Row, and the
toolbar extractor drop-down gains One-Row: every selected value on one
", "-separated line — numbers bare, strings single-quoted with '
doubled, NULL for nulls — for pasting into IN (…) lists.

The toolbar extractor also decides what plain ⌘C copies, for CSV and
One-Row (JSON / SQL Inserts / Markdown stay TSV, keeping ⌘C
cell-shaped). TSV remains the default.

Fixes #1835
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-06 22:29:18 +08:00
1 джерело 58f567c954
коміт ee84185b91
9 files changed
+138 -15

No files matched your search

+8 -1
Переглянути файл
@@ -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)}
+67
Переглянути файл
@@ -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(
<Grid
columns={[
{ name: "id", type: "INTEGER" },
{ name: "city", type: "TEXT" },
]}
rowCount={3}
value={(r, c) => 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(
<Grid
columns={[
{ name: "a", type: "text" },
{ name: "b", type: "text" },
]}
rowCount={2}
value={(r, c) =>
[
["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(
+37 -8
Переглянути файл
@@ -1,13 +1,13 @@
/**
* Result / table data grid: virtualised rows, resizable columns, rectangular
* cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV, `<null>`
* rendering, sortable headers and in-place editing (table editor).
* cell selection (click, ⇧-click, arrows, ⌘A), copy as TSV / CSV / One-Row,
* `<null>` 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<HTMLDivElement>(null);
const [sel, setSel] = useState<Sel | null>(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) },
+2 -1
Переглянути файл
@@ -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}
+2 -1
Переглянути файл
@@ -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}
+8 -4
Переглянути файл
@@ -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]!);
}
}
+11
Переглянути файл
@@ -22,6 +22,17 @@ export function toTsv(header: readonly string[] | null, rows: ReadonlyArray<read
return lines.join("\n");
}
/**
* Every value on a single `, `-separated line (IDEA's One-Row extractor, for
* `IN (…)` lists): NULL for nulls, numbers unquoted, everything else
* single-quoted with `'` doubled. `numeric(c)` decides quoting per column.
*/
export function toOneRow(rows: ReadonlyArray<readonly Cell[]>, 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<readonly Cell[]>): string {
const field = (v: Cell) => {
+1
Переглянути файл
@@ -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");
});
});
+2
Переглянути файл
@@ -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": "默认复制为富文本",