From e2e699dca99947cfefbe90fcd2c06386c4f43093 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:49:26 +0800 Subject: [PATCH] fix(database): bound large cell previews and use dialog editing (#1040) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Limit grid and export-preview cell text to 1000 UTF-16 code units plus an ellipsis. Edit large values in a wrapped dialog while preserving complete clipboard, export and database values. Reproduced STATUS_STACK_OVERFLOW (0xC00000FD) in Windows WebView2 against a real PostgreSQL table with nine rows and 8.83 MB of jsonb data. Verified the fix through display, selection, editing, full clipboard copy, JSON file export and PostgreSQL writeback. All PR CI checks passed, including 5625 frontend tests and Rust checks on Windows, macOS and Linux. Fixes #825 --- src/features/database/Database.module.css | 15 ++++++ src/features/database/ExportDialog.test.tsx | 37 +++++++++++++ src/features/database/ExportDialog.tsx | 11 +++- src/features/database/Grid.test.tsx | 58 +++++++++++++++++++++ src/features/database/Grid.tsx | 39 ++++++++++++-- src/features/database/cellPreview.ts | 10 ++++ src/platform/locales/text/zh.json | 1 + 7 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 src/features/database/ExportDialog.test.tsx create mode 100644 src/features/database/cellPreview.ts 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 }, + ]} + > +