diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index fa103b66..909df586 100644 --- a/src/features/database/Database.module.css +++ b/src/features/database/Database.module.css @@ -1259,3 +1259,18 @@ flex: none; width: 2px; } + +/* Wrap large values instead of laying megabytes out as one line. */ +.largeCellEditor { + display: block; + box-sizing: border-box; + width: 100%; + height: min(60vh, 480px); + padding: 8px; + font: inherit; + color: inherit; + background: inherit; + white-space: pre-wrap; + overflow-wrap: anywhere; + resize: vertical; +} diff --git a/src/features/database/ExportDialog.test.tsx b/src/features/database/ExportDialog.test.tsx new file mode 100644 index 00000000..3b06fa8c --- /dev/null +++ b/src/features/database/ExportDialog.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { commands } from "@/platform/ipc"; +import { ExportDialog } from "./ExportDialog"; +import { cellPreview } from "./cellPreview"; + +vi.mock("./store", () => ({ closeDialog: vi.fn() })); +vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: vi.fn() }) })); +vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/test" })); + +describe("large value export", () => { + it("bounds preview values while exporting every row in full", async () => { + const full = JSON.stringify({ text: "\u4e2d text ".repeat(200_000), tail: "complete" }); + const dbExportFile = vi.spyOn(commands, "dbExportFile").mockResolvedValue({ status: "ok", data: null }); + const rows = [[full], ["short"], [null]]; + render( + , + ); + const preview = screen.getByLabelText("Export preview"); + expect(JSON.parse(preview.textContent!)).toEqual([ + { payload: cellPreview(full) }, + { payload: "short" }, + { payload: null }, + ]); + await waitFor(() => expect(screen.getByLabelText("Output file")).toHaveValue("/home/test/big.json")); + fireEvent.click(screen.getByRole("button", { name: "Export to File" })); + await waitFor(() => expect(dbExportFile).toHaveBeenCalled()); + const [path, contents] = dbExportFile.mock.calls[0]!; + expect(path).toBe("/home/test/big.json"); + expect(JSON.parse(contents)).toEqual(rows.map(([payload]) => ({ payload }))); + dbExportFile.mockRestore(); + }); +}); diff --git a/src/features/database/ExportDialog.tsx b/src/features/database/ExportDialog.tsx index 26994635..b8d9b014 100644 --- a/src/features/database/ExportDialog.tsx +++ b/src/features/database/ExportDialog.tsx @@ -17,6 +17,7 @@ import { import { CodePreview } from "./CodePreview"; import { closeDialog } from "./store"; import styles from "./Database.module.css"; +import { cellPreview } from "./cellPreview"; const PREVIEW_ROWS = 100; /** Formats with a tabular layout (the options apply). */ @@ -59,7 +60,15 @@ export function ExportDialog({ const tabular = TABULAR.has(format); const effective = tabular ? options : DEFAULT_EXPORT_OPTIONS; const preview = useMemo( - () => exportData(format, { ...data, rows: data.rows.slice(0, PREVIEW_ROWS) }, effective), + () => + exportData( + format, + { + ...data, + rows: data.rows.slice(0, PREVIEW_ROWS).map((row) => row.map((v) => (v === null ? null : cellPreview(v)))), + }, + effective, + ), [format, data, effective], ); const text = () => exportData(format, data, effective); diff --git a/src/features/database/Grid.test.tsx b/src/features/database/Grid.test.tsx index 75b5e24f..200defe8 100644 --- a/src/features/database/Grid.test.tsx +++ b/src/features/database/Grid.test.tsx @@ -3,6 +3,8 @@ import { registerActions } from "@/platform/actions"; import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { Grid } from "./Grid"; +import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview"; +import { exportData } from "./export"; vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: ({ count }: { count: number }) => ({ @@ -72,3 +74,59 @@ describe("database grid", () => { ]); }); }); + +describe("large database cells", () => { + const full = JSON.stringify({ text: "\u8bf4\u660e text \u6df7\u5408 ".repeat(200_000), tail: "complete" }); + const columns = [{ name: "payload", type: "jsonb" }]; + + it("preserves short values and truncates only beyond the preview limit", () => { + for (const value of ["", "hello", "\u4e2d".repeat(CELL_PREVIEW_LIMIT)]) expect(cellPreview(value)).toBe(value); + expect(cellPreview(full)).toBe(full.slice(0, CELL_PREVIEW_LIMIT) + "\u2026"); + expect(cellPreview("a".repeat(999) + "\ud83d\ude00tail")).toBe("a".repeat(999) + "\u2026"); + }); + + it("bounds rendered text while copying and exporting the complete value", () => { + const writeText = vi.fn(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render( full} />); + const cell = screen.getByRole("gridcell"); + expect(cell.textContent).toBe(cellPreview(full)); + expect(cell.title).toBe(full.slice(0, 2000)); + fireEvent.mouseDown(cell, { button: 0 }); + fireEvent.keyDown(screen.getByRole("grid"), { key: "c", ctrlKey: true }); + expect(writeText).toHaveBeenCalledWith(full); + expect(JSON.parse(exportData("json", { columns, rows: [[full]], table: "big", driver: "postgres" }))).toEqual([ + { payload: full }, + ]); + }); + + it("edits the full large value in a dialog and supports cancel and apply", () => { + const onEdit = vi.fn(); + render( full} editable onEdit={onEdit} />); + fireEvent.doubleClick(screen.getByRole("gridcell")); + expect(screen.getByRole("dialog")).toBeTruthy(); + const editor = screen.getByRole("textbox") as HTMLTextAreaElement; + expect(editor.tagName).toBe("TEXTAREA"); + expect(editor.value).toBe(full); + fireEvent.change(editor, { target: { value: full + "changed" } }); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(onEdit).not.toHaveBeenCalled(); + fireEvent.doubleClick(screen.getByRole("gridcell")); + fireEvent.change(screen.getByRole("textbox"), { target: { value: full + "changed" } }); + fireEvent.click(screen.getByRole("button", { name: "Apply" })); + expect(onEdit).toHaveBeenCalledWith(0, 0, full + "changed"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("keeps short values in the inline editor and preserves null display", () => { + const onEdit = vi.fn(); + const { rerender } = render( "short"} editable onEdit={onEdit} />); + fireEvent.doubleClick(screen.getByRole("gridcell")); + expect(screen.getByRole("textbox").tagName).toBe("INPUT"); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "updated" } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + expect(onEdit).toHaveBeenCalledWith(0, 0, "updated"); + rerender( null} />); + expect(screen.getByRole("gridcell").textContent).toBe(""); + }); +}); diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index 3b05b4f6..f0d93202 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -6,10 +6,11 @@ import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent, type MouseEvent } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import clsx from "clsx"; -import { Icon, showContextMenu, type MenuItem } from "@/ui"; +import { Dialog, Icon, showContextMenu, type MenuItem } from "@/ui"; import { parseDelimited, toCsv, toTsv, type Cell } from "./model"; import styles from "./Database.module.css"; import { tx } from "@/platform/i18n"; +import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview"; export interface GridColumn { name: string; @@ -103,7 +104,7 @@ export function Grid(props: GridProps) { window.addEventListener("mouseup", stop); return () => window.removeEventListener("mouseup", stop); }, []); - const [editing, setEditing] = useState<{ row: number; col: number; text: string } | null>(null); + const [editing, setEditing] = useState<{ row: number; col: number; text: string; dialog: boolean } | null>(null); const columnKey = columns.map((c) => c.name).join("\u0000"); const [widths, setWidths] = useState([]); const [numeric, setNumeric] = useState([]); @@ -171,7 +172,8 @@ export function Grid(props: GridProps) { const startEdit = (row: number, col: number) => { if (!editable || props.rowState?.(row) === "deleted") return; - setEditing({ row, col, text: value(row, col) ?? "" }); + const text = value(row, col) ?? ""; + setEditing({ row, col, text, dialog: text.length > CELL_PREVIEW_LIMIT }); }; const onKeyDown = (e: KeyboardEvent) => { @@ -302,6 +304,29 @@ export function Grid(props: GridProps) { onPaste={onPaste} onContextMenu={onContextMenu} > + {editing?.dialog && ( + setEditing(null)} + buttons={[ + { label: "Cancel", role: "cancel", onClick: () => setEditing(null) }, + { label: "Apply", role: "default", onClick: commit }, + ]} + > +