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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-03 21:49:26 +08:00
1 джерело dcd8ecc5ae
коміт e2e699dca9
7 files changed
+165 -6

No files matched your search

+15
Переглянути файл
@@ -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;
}
+37
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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";
}