дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
1 джерело
58f567c954
коміт
ee84185b91
9 files changed
+138
-15
No files matched your search
@@ -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)}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]!);
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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": "默认复制为富文本",
|
||||
|
||||
Reference in new issue
Заблокувати користувача