diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index fa103b667..909df5869 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/Grid.test.tsx b/src/features/database/Grid.test.tsx index 75b5e24fa..2c7e73aa5 100644 --- a/src/features/database/Grid.test.tsx +++ b/src/features/database/Grid.test.tsx @@ -2,7 +2,8 @@ import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap"; 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, Grid } from "./Grid"; +import { exportData } from "./export"; vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: ({ count }: { count: number }) => ({ @@ -72,3 +73,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 3b05b4f67..0865878c1 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -6,7 +6,7 @@ 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"; @@ -69,6 +69,17 @@ interface Sel { c1: number; } +export const CELL_PREVIEW_LIMIT = 1000; + +/** Bound text layout without changing the underlying value or splitting a surrogate pair. */ +export function cellPreview(value: string): string { + if (value.length <= CELL_PREVIEW_LIMIT) return value; + let end = CELL_PREVIEW_LIMIT; + const last = value.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end--; + return value.slice(0, end) + "\u2026"; +} + const ROW_H = 24; const NUM_W = 48; @@ -103,7 +114,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 +182,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 +314,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 }, + ]} + > +