fix(database): bound large cell previews and use dialog editing (#1040)

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
This commit is contained in:
l0ng-ai authored and GitHub committed 2026-11-03 21:49:26 +08:00
1 parent dcd8ecc5ae
commit e2e699dca9
7 files changed
+165 -6

No files matched your search

+15
View File
@@ -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;
}
@@ -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(
<ExportDialog
data={{ columns: [{ name: "payload", type: "jsonb" }], rows, table: "big", driver: "postgres" }}
source="public.big"
initialFormat="json"
/>,
);
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();
});
});
+10 -1
View File
@@ -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);
+58
View File
@@ -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(<Grid columns={columns} rowCount={1} value={() => 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(<Grid columns={columns} rowCount={1} value={() => 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(<Grid columns={columns} rowCount={1} value={() => "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(<Grid columns={columns} rowCount={1} value={() => null} />);
expect(screen.getByRole("gridcell").textContent).toBe("<null>");
});
});
+34 -5
View File
@@ -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<number[]>([]);
const [numeric, setNumeric] = useState<boolean[]>([]);
@@ -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<HTMLDivElement>) => {
@@ -302,6 +304,29 @@ export function Grid(props: GridProps) {
onPaste={onPaste}
onContextMenu={onContextMenu}
>
{editing?.dialog && (
<Dialog
open
title={tx`Edit ${columns[editing.col]!.name}`}
width={720}
onClose={() => setEditing(null)}
buttons={[
{ label: "Cancel", role: "cancel", onClick: () => setEditing(null) },
{ label: "Apply", role: "default", onClick: commit },
]}
>
<textarea
className={styles.largeCellEditor}
aria-label={`Edit ${columns[editing.col]!.name}`}
value={editing.text}
spellCheck={false}
wrap="soft"
onContextMenu={(e) => e.stopPropagation()}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
onKeyDown={(e) => e.stopPropagation()}
/>
</Dialog>
)}
<div className={styles.gridInner} style={{ width: totalWidth, height: virtualizer.getTotalSize() + ROW_H }}>
<div className={clsx(styles.gridRow, styles.gridHeader)} style={{ gridTemplateColumns: template }} role="row">
<div className={styles.rowNumber} />
@@ -368,7 +393,7 @@ export function Grid(props: GridProps) {
{state === "inserted" ? "+" : (props.rowNumber?.(r) ?? firstRowNumber + r)}
</div>
{columns.map((_, c) => {
if (editing && editing.row === r && editing.col === c) {
if (editing && !editing.dialog && editing.row === r && editing.col === c) {
return (
<input
key={c}
@@ -407,7 +432,11 @@ export function Grid(props: GridProps) {
onDoubleClick={() => startEdit(r, c)}
title={val != null && val.length > 40 ? val.slice(0, 2000) : undefined}
>
{val === null ? <span className={styles.null}>&lt;null&gt;</span> : displayed(val, !!numeric[c])}
{val === null ? (
<span className={styles.null}>&lt;null&gt;</span>
) : (
cellPreview(displayed(val, !!numeric[c]))
)}
</div>
);
})}
+10
View File
@@ -0,0 +1,10 @@
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";
}