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
2026-11-03 21:49:26 +08:00
committed by GitHub
parent dcd8ecc5ae
commit e2e699dca9
7 changed files with 165 additions and 6 deletions
+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";
}
+1
View File
@@ -1,4 +1,5 @@
{
"Edit {name}": "编辑 {name}",
"The language server JDK ({path}) must be a full JDK with jdk.compiler, jdk.jdi and jdk.unsupported for Java language support and debugging. jdtls needs JDK {version} or newer. Download JDK {version}, choose a full JDK in {setting}, or set it to Auto.": "语言服务器 JDK({path})必须是包含 jdk.compiler、jdk.jdi 和 jdk.unsupported 的完整 JDK,才能支持 Java 语言功能和调试。jdtls 需要 JDK {version} 或更高版本。请下载 JDK {version},在 {setting} 中选择完整 JDK,或设为自动。",
"Apple's clangd does not run clang-tidy, so checks from .clang-tidy are not shown. Install clangd (LLVM) to get them.": "Apple 的 clangd 不运行 clang-tidy,因此不会显示 .clang-tidy 中配置的检查。请安装 clangd(LLVM)以启用这些检查。",
"Newer dependency version available": "有更新的依赖版本可用",