fix(database): keep line breaks when editing multi-line cells (#1311)

The inline cell editor is a single-line input that drops a value's line breaks on the first change, and it committed on every Enter; the Record view's fields had the same problem. Multi-line values now open in the multi-line editor, Shift+Enter opens it for any cell (from the grid or the inline editor) and Cmd/Ctrl+Enter applies it; CRLF values keep CRLF; the grid marks line breaks with a muted ↵; the Record view uses a text area for multi-line values.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-04 23:26:24 +08:00
1 джерело 7cdf690b4b
коміт 834b8d4e3b
5 files changed
+141 -21

No files matched your search

+25 -11
Переглянути файл
@@ -20,6 +20,7 @@ import {
type LocalSort,
} from "./resultView";
import { openDialog } from "./store";
import { hasLineBreak } from "./cellPreview";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -346,17 +347,30 @@ function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean;
{c.name}
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
</span>
<Input
mono
value={text}
readOnly={!props.editable}
placeholder={props.cell === null ? "<null>" : undefined}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
{hasLineBreak(props.cell ?? "") ? (
// A single-line field would drop the line breaks on the first edit.
<textarea
className={styles.recordText}
rows={Math.min(8, (props.cell ?? "").split(/\r\n|\r|\n/).length)}
value={text}
readOnly={!props.editable}
spellCheck={false}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
/>
) : (
<Input
mono
value={text}
readOnly={!props.editable}
placeholder={props.cell === null ? "<null>" : undefined}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
}}
/>
)}
</label>
);
}
+17 -1
Переглянути файл
@@ -107,6 +107,9 @@
color: var(--fg-muted);
font-style: italic;
}
.lineBreak {
color: var(--fg-muted);
}
.cellEditor {
min-width: 0;
height: 24px;
@@ -959,10 +962,23 @@
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
}
.valueText:focus {
.valueText:focus,
.recordText:focus {
outline: none;
border-color: var(--focus-ring);
}
.recordText {
resize: vertical;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
color: var(--fg-strong);
font-family: var(--openidea-editor-font, var(--font-mono));
font-size: var(--fs-code);
-webkit-user-select: text;
user-select: text;
}
.aggregates {
width: 100%;
border-collapse: collapse;
+45
Переглянути файл
@@ -130,3 +130,48 @@ describe("large database cells", () => {
expect(screen.getByRole("gridcell").textContent).toBe("<null>");
});
});
describe("multi-line database cells", () => {
const columns = [{ name: "note", type: "text" }];
const multi = "first line\nsecond line\r\nthird";
it("shows line breaks as markers on one line", () => {
render(<Grid columns={columns} rowCount={1} value={() => multi} />);
expect(screen.getByRole("gridcell").textContent).toBe("first line↵second line↵third");
});
it.each([
["LF", "a\nb", "a\nb\nc"],
["CRLF, kept although the textarea uses LF", "a\r\nb", "a\r\nb\r\nc"],
])("edits a multi-line value (%s) in the multi-line editor and keeps its line breaks", (_, value, saved) => {
const onEdit = vi.fn();
render(<Grid columns={columns} rowCount={1} value={() => value} editable onEdit={onEdit} />);
fireEvent.doubleClick(screen.getByRole("gridcell"));
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(editor.tagName).toBe("TEXTAREA");
fireEvent.change(editor, { target: { value: "a\nb\nc" } });
fireEvent.keyDown(editor, { key: "Enter", metaKey: true });
expect(onEdit).toHaveBeenCalledWith(0, 0, saved);
});
it("opens the multi-line editor with Shift+Enter, from the grid and from the inline editor", () => {
const onEdit = vi.fn();
render(<Grid columns={columns} rowCount={1} value={() => "one"} editable onEdit={onEdit} />);
fireEvent.mouseDown(screen.getByRole("gridcell"), { button: 0 });
fireEvent.mouseUp(window);
fireEvent.keyDown(screen.getByRole("grid"), { key: "Enter", shiftKey: true });
expect((screen.getByRole("textbox") as HTMLTextAreaElement).tagName).toBe("TEXTAREA");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.doubleClick(screen.getByRole("gridcell"));
const inline = screen.getByRole("textbox");
expect(inline.tagName).toBe("INPUT");
fireEvent.change(inline, { target: { value: "one two" } });
fireEvent.keyDown(inline, { key: "Enter", shiftKey: true });
fireEvent.blur(inline);
const editor = screen.getByRole("textbox") as HTMLTextAreaElement;
expect(editor.tagName).toBe("TEXTAREA");
expect(editor.value).toBe("one two");
expect(onEdit).not.toHaveBeenCalled();
});
});
+51 -9
Переглянути файл
@@ -10,7 +10,7 @@ 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";
import { CELL_PREVIEW_LIMIT, cellPreview, hasLineBreak } from "./cellPreview";
export interface GridColumn {
name: string;
@@ -83,6 +83,22 @@ function initialWidth(c: GridColumn, sample: Cell[], decorations: number): numbe
/** SQLite renders whole REALs as `3108.0`; IDEA shows `3108`. */
const displayed = (v: string, numeric: boolean) => (numeric && /^-?\d+\.0$/.test(v) ? v.slice(0, -2) : v);
/** A cell's text on one line: each line break shows as a muted `↵`. */
function cellText(value: string) {
const parts = cellPreview(value).split(/\r\n|\r|\n/);
if (parts.length === 1) return parts[0];
return parts.flatMap((part, i) =>
i === 0
? [part]
: [
<span key={i} className={styles.lineBreak}>
↵
</span>,
part,
],
);
}
function norm(s: Sel) {
return {
top: Math.min(s.r0, s.r1),
@@ -105,6 +121,11 @@ export function Grid(props: GridProps) {
return () => window.removeEventListener("mouseup", stop);
}, []);
const [editing, setEditing] = useState<{ row: number; col: number; text: string; dialog: boolean } | null>(null);
/** The inline editor is being swapped for the multi-line one: its blur must not commit. */
const maximizing = useRef(false);
useEffect(() => {
if (!editing?.dialog) maximizing.current = false;
}, [editing?.dialog]);
const columnKey = columns.map((c) => c.name).join("\u0000");
const [widths, setWidths] = useState<number[]>([]);
const [numeric, setNumeric] = useState<boolean[]>([]);
@@ -163,17 +184,23 @@ export function Grid(props: GridProps) {
const commit = () => {
if (!editing) return;
const prev = value(editing.row, editing.col);
if (editing.text !== (prev ?? "") || prev === null) {
if (!(prev === null && editing.text === "")) onEdit?.(editing.row, editing.col, editing.text);
// A textarea turns CRLF into LF; a value that used CRLF throughout keeps it.
const text =
prev && prev.includes("\r\n") && !/(^|[^\r])\n/.test(prev)
? editing.text.replace(/\r?\n/g, "\r\n")
: editing.text;
if (text !== (prev ?? "") || prev === null) {
if (!(prev === null && text === "")) onEdit?.(editing.row, editing.col, text);
}
setEditing(null);
scrollRef.current?.focus();
};
const startEdit = (row: number, col: number) => {
/** `maximized` (⇧Enter): the multi-line editor, which multi-line and long values always use. */
const startEdit = (row: number, col: number, maximized = false) => {
if (!editable || props.rowState?.(row) === "deleted") return;
const text = value(row, col) ?? "";
setEditing({ row, col, text, dialog: text.length > CELL_PREVIEW_LIMIT });
setEditing({ row, col, text, dialog: maximized || text.length > CELL_PREVIEW_LIMIT || hasLineBreak(text) });
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
@@ -192,7 +219,7 @@ export function Grid(props: GridProps) {
if (!sel) return;
if ((e.key === "Enter" || e.key === "F2") && editable) {
e.preventDefault();
startEdit(sel.r1, sel.c1);
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
return;
}
const moves: Record<string, [number, number]> = {
@@ -325,7 +352,13 @@ export function Grid(props: GridProps) {
wrap="soft"
onContextMenu={(e) => e.stopPropagation()}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
onKeyDown={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commit();
}
}}
/>
</Dialog>
)}
@@ -404,9 +437,18 @@ export function Grid(props: GridProps) {
aria-label={`Edit ${columns[c]!.name}`}
value={editing.text}
onChange={(e) => setEditing({ ...editing, text: e.target.value })}
onBlur={commit}
onBlur={() => {
if (!maximizing.current) commit();
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter" && e.shiftKey) {
// A line break can't be typed in a single-line field: continue in the multi-line editor.
e.preventDefault();
maximizing.current = true;
setEditing({ ...editing, dialog: true });
return;
}
if (e.key === "Enter") commit();
if (e.key === "Escape") {
setEditing(null);
@@ -437,7 +479,7 @@ export function Grid(props: GridProps) {
{val === null ? (
<span className={styles.null}>&lt;null&gt;</span>
) : (
cellPreview(displayed(val, !!numeric[c]))
cellText(displayed(val, !!numeric[c]))
)}
</div>
);
+3
Переглянути файл
@@ -8,3 +8,6 @@ export function cellPreview(value: string): string {
if (last >= 0xd800 && last <= 0xdbff) end--;
return value.slice(0, end) + "\u2026";
}
/** Whether the value spans several lines (a single-line field would drop its line breaks). */
export const hasLineBreak = (value: string) => /[\r\n]/.test(value);