mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(database): JSON-aware value editor for json cells (#2410)
Refs #2333 Editing json / jsonb / MySQL JSON cells showed one long plain text line, and invalid JSON only failed on Submit. The Value panel and the grid's ⇧Enter window now open the IDE's code editor for JSON cells, pretty-printed (big integers and duplicate keys preserved, large values formatted off the main thread) with highlighting, folding and find; validation as you type blocks Apply only for JSON columns; Reformat; Apply of an unchanged value creates no pending edit, and drafts survive opening the window. XML and bytea modes from #2333 remain. Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
const compact = '{"name":"Ada","tags":["math","code"],"n":9007199254740993}';
|
||||
|
||||
for (const type of ["json", "text"]) {
|
||||
test(`JSON value window preserves text and uses ${type} validation rules`, async ({ workbench: page }) => {
|
||||
await page.evaluate(
|
||||
async ({ compact, type }) => {
|
||||
const reactPath = "/node_modules/.vite/deps/react.js";
|
||||
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
|
||||
const viewPath = "/src/features/database/DataView.tsx";
|
||||
const react = await import(reactPath);
|
||||
const dom = await import(domPath);
|
||||
const { createElement, useState } = react.default ?? react;
|
||||
const { createRoot } = dom.default ?? dom;
|
||||
const { DataView } = await import(viewPath);
|
||||
const host = document.createElement("div");
|
||||
host.style.cssText = "position:fixed;inset:100px;z-index:100;display:flex;background:var(--bg-main)";
|
||||
document.body.append(host);
|
||||
function Value() {
|
||||
const [value, setValue] = useState(compact);
|
||||
return createElement(DataView, {
|
||||
columns: [{ name: "payload", type }],
|
||||
rowCount: 1,
|
||||
value: () => value,
|
||||
editable: true,
|
||||
panel: "value",
|
||||
onPanelChange: () => {},
|
||||
onEdit: (_row: number, _col: number, text: string) => setValue(text),
|
||||
});
|
||||
}
|
||||
createRoot(host).render(createElement(Value));
|
||||
},
|
||||
{ compact, type },
|
||||
);
|
||||
const cell = page.getByRole("gridcell", { name: compact, exact: true });
|
||||
await cell.click();
|
||||
const panel = page.getByRole("complementary", { name: "Value panel" });
|
||||
const editor = panel.locator(".cm-content");
|
||||
await expect(editor).toContainText("9007199254740993");
|
||||
await expect(panel.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
await cell.press("Shift+Enter");
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog.locator(".cm-content")).toBeFocused();
|
||||
await expect
|
||||
.poll(() =>
|
||||
dialog.locator(".cm-editor").evaluate((editor) => {
|
||||
const body = editor.closest("[data-dialog-body]")!;
|
||||
return Math.abs(body.clientHeight - editor.clientHeight);
|
||||
}),
|
||||
)
|
||||
.toBeLessThanOrEqual(4);
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(cell).toHaveText(compact);
|
||||
await editor.evaluate((element) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '{"name":"Grace","n":9007199254740993}' },
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
await page.keyboard.press("Meta+Alt+L");
|
||||
await expect(editor.locator(".cm-line")).toHaveCount(4);
|
||||
await page.keyboard.press("Meta+f");
|
||||
await expect(panel.locator(".cm-search")).toBeVisible();
|
||||
await panel.locator(".cm-search button[name=close]").click();
|
||||
await panel.getByRole("button", { name: "Open in Window", exact: true }).click();
|
||||
await expect(dialog.locator(".cm-content")).toContainText("Grace");
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeEnabled();
|
||||
await dialog.locator(".cm-content").evaluate((element) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view;
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "plain text" } });
|
||||
});
|
||||
const apply = dialog.getByRole("button", { name: "Apply", exact: true });
|
||||
if (type === "json") {
|
||||
await expect(apply).toBeDisabled();
|
||||
await expect(dialog.getByRole("status")).toBeVisible();
|
||||
} else {
|
||||
await expect(apply).toBeEnabled();
|
||||
await apply.click();
|
||||
await expect(page.getByRole("gridcell", { name: "plain text", exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("validates a large JSON value in a worker and applies the exact edited text", async ({ workbench: page }) => {
|
||||
await page.evaluate(async () => {
|
||||
const reactPath = "/node_modules/.vite/deps/react.js";
|
||||
const domPath = "/node_modules/.vite/deps/react-dom_client.js";
|
||||
const viewPath = "/src/features/database/JsonValueEditor.tsx";
|
||||
const react = await import(reactPath);
|
||||
const dom = await import(domPath);
|
||||
const { createElement } = react.default ?? react;
|
||||
const { createRoot } = dom.default ?? dom;
|
||||
const { JsonValueWindow } = await import(viewPath);
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const result = document.createElement("output");
|
||||
result.id = "applied-json";
|
||||
document.body.append(result);
|
||||
createRoot(host).render(
|
||||
createElement(JsonValueWindow, {
|
||||
title: "Edit payload",
|
||||
initial: '{"large":"' + "x".repeat(5_000_000) + '"}',
|
||||
validate: true,
|
||||
onClose: () => {},
|
||||
onApply: (text: string) => {
|
||||
result.textContent = text;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Edit payload" });
|
||||
const editor = dialog.locator(".cm-content");
|
||||
await expect(dialog.getByRole("button", { name: "Reformat", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Reformat", exact: true }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
editor.evaluate(
|
||||
(element) =>
|
||||
(element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view.state.doc.toString() ===
|
||||
'{\n "large": "' + "x".repeat(5_000_000) + '"\n}',
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await editor.evaluate((element) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view;
|
||||
view.dispatch({ changes: { from: view.state.doc.length - 1, to: view.state.doc.length } });
|
||||
});
|
||||
await expect(dialog.getByRole("status")).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
await editor.evaluate((element) => {
|
||||
const view = (element as HTMLElement & { cmTile: { view: EditorView } }).cmTile.view;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '{\n "large": "' + "x".repeat(5_000_000) + '"\n}' },
|
||||
});
|
||||
});
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeEnabled();
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator("#applied-json")
|
||||
.evaluate((element) => element.textContent === '{\n "large": "' + "x".repeat(5_000_000) + '"\n}'),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
@@ -317,6 +317,8 @@ export interface SetupOptions {
|
||||
wrapOverride?: boolean | null;
|
||||
/** Extra extensions owned by the slot (update listeners, focus handlers). */
|
||||
extra?: Extension;
|
||||
/** Standalone value editors supply their own extensions without file-backed contributions. */
|
||||
contributions?: Extension;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,7 +409,7 @@ export function baseExtensions(o: SetupOptions): Extension {
|
||||
wrapOverride: o.wrapOverride,
|
||||
}),
|
||||
),
|
||||
compartments.contributions.of(contributionExtensions(o.ctx)),
|
||||
compartments.contributions.of(o.contributions ?? contributionExtensions(o.ctx)),
|
||||
compartments.distractionFree.of(distractionFreeExtension(o.settings)),
|
||||
o.extra ?? [],
|
||||
];
|
||||
|
||||
@@ -26,6 +26,15 @@ import {
|
||||
} from "./resultView";
|
||||
import { openDialog } from "./store";
|
||||
import { hasLineBreak } from "./cellPreview";
|
||||
import {
|
||||
initialJsonText,
|
||||
isJsonColumn,
|
||||
isJsonValue,
|
||||
jsonErrorOf,
|
||||
JSON_INLINE_LIMIT,
|
||||
type JsonError,
|
||||
} from "./jsonValue";
|
||||
import { JsonValueEditor, JsonValueWindow, type JsonEditorApi } from "./JsonValueEditor";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -479,8 +488,9 @@ function SidePanel(props: {
|
||||
<RecordView key={range.lead.row} {...props} row={range.lead.row} />
|
||||
) : props.panel === "value" ? (
|
||||
<ValueView
|
||||
key={`${range.lead.row}:${range.lead.col}`}
|
||||
column={columns[range.lead.col]?.name ?? ""}
|
||||
// A committed value (here or in the grid) starts the editor over.
|
||||
key={`${range.lead.row}:${range.lead.col}\u0000${value(range.lead.row, range.lead.col) ?? "\u0001"}`}
|
||||
column={columns[range.lead.col] ?? { name: "", type: "" }}
|
||||
cell={value(range.lead.row, range.lead.col)}
|
||||
editable={props.editable}
|
||||
onCommit={(v) => props.onEdit(range.lead.row, range.lead.col, v)}
|
||||
@@ -558,33 +568,109 @@ function RecordField(props: { column: GridColumn; cell: Cell; editable: boolean;
|
||||
);
|
||||
}
|
||||
|
||||
function ValueView(props: { column: string; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) {
|
||||
const [text, setText] = useState(props.cell ?? "");
|
||||
const dirty = text !== (props.cell ?? "") || (props.cell === null && text !== "");
|
||||
/** The Value panel's editor: plain text, or the JSON code editor for JSON cells (#2333). */
|
||||
function ValueView(props: { column: GridColumn; cell: Cell; editable: boolean; onCommit: (v: Cell) => void }) {
|
||||
const name = props.column.name;
|
||||
const [json] = useState(() => isJsonValue(props.column.type, props.cell));
|
||||
const validate = isJsonColumn(props.column.type);
|
||||
// JSON opens pretty-printed; the panel applies what the editor shows.
|
||||
const [initial] = useState(() => (json ? initialJsonText(props.cell ?? "") : (props.cell ?? "")));
|
||||
const [text, setText] = useState(initial);
|
||||
const [error, setError] = useState<JsonError | null>(() =>
|
||||
props.cell !== null && validate && initial.length <= JSON_INLINE_LIMIT ? jsonErrorOf(initial) : null,
|
||||
);
|
||||
const [pending, setPending] = useState(json && initial.length > JSON_INLINE_LIMIT);
|
||||
const editor = useRef<JsonEditorApi | null>(null);
|
||||
const dirty = text !== initial;
|
||||
const commit = () => {
|
||||
if (props.editable && dirty && !error && !pending) props.onCommit(text);
|
||||
};
|
||||
const [windowOpen, setWindowOpen] = useState(false);
|
||||
return (
|
||||
<div className={styles.valueView}>
|
||||
<textarea
|
||||
className={styles.valueText}
|
||||
aria-label={`Value of ${props.column}`}
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && dirty) props.onCommit(text);
|
||||
}}
|
||||
/>
|
||||
{json ? (
|
||||
<>
|
||||
<JsonValueEditor
|
||||
initial={initial}
|
||||
validate={validate}
|
||||
emptyIsNull={props.cell === null}
|
||||
readOnly={!props.editable}
|
||||
ariaLabel={tx`Value of ${name}`}
|
||||
onChange={(t, e, busy) => {
|
||||
setText(t);
|
||||
setError(e);
|
||||
setPending(busy);
|
||||
}}
|
||||
onReady={(api) => (editor.current = api)}
|
||||
onSubmit={commit}
|
||||
onOpenWindow={() => setWindowOpen(true)}
|
||||
/>
|
||||
{error && (
|
||||
<div className={styles.valueError} role="status">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.filterButtons}>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={!props.editable || !!error || pending}
|
||||
onClick={() => editor.current?.reformat()}
|
||||
>
|
||||
<T>Reformat</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setWindowOpen(true)}>
|
||||
<T>Open in Window</T>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<textarea
|
||||
className={styles.valueText}
|
||||
aria-label={tx`Value of ${name}`}
|
||||
value={text}
|
||||
readOnly={!props.editable}
|
||||
placeholder={props.cell === null ? "<null>" : undefined}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && dirty) props.onCommit(text);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{props.editable && (
|
||||
<div className={styles.filterButtons}>
|
||||
<Button size="small" disabled={!dirty} onClick={() => props.onCommit(text)}>
|
||||
<Button size="small" disabled={!dirty || !!error || pending} onClick={commit}>
|
||||
<T>Apply</T>
|
||||
</Button>
|
||||
<Button size="small" onClick={() => props.onCommit(null)}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (props.cell !== null) props.onCommit(null);
|
||||
}}
|
||||
>
|
||||
<T>Set NULL</T>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{windowOpen && (
|
||||
<JsonValueWindow
|
||||
title={tx`Value of ${name}`}
|
||||
initial={text}
|
||||
baseline={initial}
|
||||
validate={validate}
|
||||
emptyIsNull={props.cell === null}
|
||||
readOnly={!props.editable}
|
||||
onClose={() => setWindowOpen(false)}
|
||||
onApply={(t) => {
|
||||
props.onCommit(t);
|
||||
setWindowOpen(false);
|
||||
}}
|
||||
onSetNull={() => {
|
||||
props.onCommit(null);
|
||||
setWindowOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -951,6 +951,37 @@
|
||||
outline: none;
|
||||
border-color: var(--focus-ring);
|
||||
}
|
||||
/* The JSON value editor's host (#2333): the CodeMirror view fills it. */
|
||||
.valueEditor {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
color: var(--fg-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.valueEditor > :global(.cm-editor) {
|
||||
height: 100%;
|
||||
font-size: var(--fs-code);
|
||||
}
|
||||
.valueError {
|
||||
color: var(--danger);
|
||||
font-size: var(--fs-code);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.valueWindowBody {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.valueWindowBody .valueEditor {
|
||||
min-height: 160px;
|
||||
}
|
||||
.recordText {
|
||||
resize: vertical;
|
||||
padding: 3px 6px;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { ExportDialog } from "./ExportDialog";
|
||||
import { cellPreview } from "./cellPreview";
|
||||
|
||||
vi.mock("./store", () => ({ closeDialog: vi.fn() }));
|
||||
vi.mock("@/workbench/contributions", () => ({ notifications: () => ({ notify: vi.fn() }) }));
|
||||
vi.mock("@/workbench/contributions", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/workbench/contributions")>()),
|
||||
notifications: () => ({ notify: vi.fn() }),
|
||||
}));
|
||||
vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/test" }));
|
||||
|
||||
describe("large value export", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { within } from "@testing-library/react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { DataView } from "./DataView";
|
||||
import { Grid } from "./Grid";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview } from "./cellPreview";
|
||||
@@ -171,19 +173,22 @@ describe("large database cells", () => {
|
||||
|
||||
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} />);
|
||||
const large = "word ".repeat(40_000); // not JSON: the plain multi-line editor
|
||||
render(
|
||||
<Grid columns={[{ name: "note", type: "text" }]} rowCount={1} value={() => large} 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" } });
|
||||
expect(editor.value).toBe(large);
|
||||
fireEvent.change(editor, { target: { value: large + "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.change(screen.getByRole("textbox"), { target: { value: large + "changed" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, full + "changed");
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, large + "changed");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -244,6 +249,208 @@ describe("multi-line database cells", () => {
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
describe("JSON value editor (#2333)", () => {
|
||||
const json = '{"name":"Ada","tags":["math","code"]}';
|
||||
const pretty = JSON.stringify(JSON.parse(json), null, 2);
|
||||
const payload = [{ name: "payload", type: "jsonb" }];
|
||||
// CodeMirror renders each line in its own element: compare text without layout whitespace.
|
||||
const shown = (text: string) => text.replace(/\s+/g, "");
|
||||
|
||||
/** The mounted CodeMirror view of the open editor. */
|
||||
const view = () => {
|
||||
const content = document.querySelector(".cm-content") as HTMLElement;
|
||||
return EditorView.findFromDOM(content)!;
|
||||
};
|
||||
const editDoc = (insert: string) => {
|
||||
const v = view();
|
||||
act(() => v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert } }));
|
||||
};
|
||||
const openWindow = () => {
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell"), { button: 0 });
|
||||
fireEvent.mouseUp(window);
|
||||
fireEvent.keyDown(screen.getByRole("grid"), { key: "Enter", shiftKey: true });
|
||||
};
|
||||
|
||||
it("opens the JSON window with Shift+Enter, pretty-printed, and applies edits to the grid", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={onEdit} />);
|
||||
openWindow();
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// The code editor, not the plain textarea: a contenteditable div, pretty-printed.
|
||||
const editor = screen.getByRole("textbox", { name: "Edit payload" });
|
||||
expect(editor.tagName).toBe("DIV");
|
||||
expect(shown(editor.textContent ?? "")).toBe(shown(pretty));
|
||||
editDoc(pretty.replace('"Ada"', '"Grace"'));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, pretty.replace('"Ada"', '"Grace"'));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens SQL NULL without an error or a redundant pending edit", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => null} editable onEdit={onEdit} />);
|
||||
openWindow();
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set NULL" }));
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an edited inline draft when maximizing and applies it unchanged", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={onEdit} />);
|
||||
fireEvent.doubleClick(screen.getByRole("gridcell"));
|
||||
const input = screen.getByRole("textbox");
|
||||
const draft = '{"name": "Grace", "n":9007199254740993}';
|
||||
fireEvent.change(input, { target: { value: draft } });
|
||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
|
||||
expect(view().state.doc.toString()).toBe(draft);
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, draft);
|
||||
});
|
||||
|
||||
it("marks invalid JSON and blocks Apply until it parses", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={onEdit} />);
|
||||
openWindow();
|
||||
const apply = () => screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement;
|
||||
expect(apply().disabled).toBe(true); // unchanged
|
||||
editDoc('{"broken": ');
|
||||
expect(apply().disabled).toBe(true);
|
||||
expect(screen.getByRole("status").textContent).toMatch(/JSON/i);
|
||||
fireEvent.click(apply());
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
editDoc('{"ok": 1}');
|
||||
expect(apply().disabled).toBe(false);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
fireEvent.click(apply());
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, '{"ok": 1}');
|
||||
});
|
||||
|
||||
it("allows plain text after JSON highlighting was inferred from text content", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<Grid columns={[{ name: "payload", type: "text" }]} rowCount={1} value={() => json} editable onEdit={onEdit} />,
|
||||
);
|
||||
openWindow();
|
||||
editDoc("plain text now");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, "plain text now");
|
||||
});
|
||||
|
||||
it("keeps the compact value unchanged after opening, applying and closing", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={onEdit} />);
|
||||
openWindow();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter", metaKey: true });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reformats the edited text in the window", () => {
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={vi.fn()} />);
|
||||
openWindow();
|
||||
editDoc('{"a":1}');
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reformat" }));
|
||||
expect(shown(screen.getByRole("textbox", { name: "Edit payload" }).textContent ?? "")).toBe(
|
||||
shown('{\n "a": 1\n}'),
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the panel draft into the window and keeps typed text on Apply", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<DataView
|
||||
columns={payload}
|
||||
rowCount={1}
|
||||
value={() => json}
|
||||
panel="value"
|
||||
onPanelChange={() => {}}
|
||||
editable
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell"), { button: 0 });
|
||||
const draft = '{"name": "Grace", "n": 9007199254740993}';
|
||||
editDoc(draft);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Window" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const content = dialog.querySelector<HTMLElement>(".cm-content")!;
|
||||
expect(EditorView.findFromDOM(content)!.state.doc.toString()).toBe(draft);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Apply" }));
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 0, draft);
|
||||
});
|
||||
|
||||
it("does not reformat or apply a read-only Value panel", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(
|
||||
<DataView
|
||||
columns={payload}
|
||||
rowCount={1}
|
||||
value={() => json}
|
||||
panel="value"
|
||||
onPanelChange={() => {}}
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByRole("gridcell"), { button: 0 });
|
||||
expect((screen.getByRole("button", { name: "Reformat" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
const v = view();
|
||||
act(() => v.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", metaKey: true, bubbles: true })));
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Window" }));
|
||||
expect(within(screen.getByRole("dialog")).queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("treats blank text as invalid for typed JSON and restores the unchanged state when the original text is restored", () => {
|
||||
const onEdit = vi.fn();
|
||||
render(<Grid columns={payload} rowCount={1} value={() => json} editable onEdit={onEdit} />);
|
||||
openWindow();
|
||||
editDoc("");
|
||||
expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
editDoc(pretty);
|
||||
expect((screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows formatted JSON in the Value panel with Reformat, Open in Window and guarded Apply", () => {
|
||||
const onEdit = vi.fn();
|
||||
const columns = [
|
||||
{ name: "id", type: "int4" },
|
||||
{ name: "payload", type: "jsonb" },
|
||||
];
|
||||
const data = [["1", json]];
|
||||
render(
|
||||
<DataView
|
||||
columns={columns}
|
||||
rowCount={1}
|
||||
value={(r, c) => data[r]![c]!}
|
||||
panel="value"
|
||||
onPanelChange={() => {}}
|
||||
editable
|
||||
onEdit={onEdit}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getAllByRole("gridcell")[1]!, { button: 0 });
|
||||
const editor = screen.getByRole("textbox", { name: "Value of payload" });
|
||||
expect(shown(editor.textContent ?? "")).toBe(shown(pretty));
|
||||
const apply = () => screen.getByRole("button", { name: "Apply" }) as HTMLButtonElement;
|
||||
expect(apply().disabled).toBe(true);
|
||||
editDoc(pretty.replace('"Ada"', '"Grace"'));
|
||||
expect(apply().disabled).toBe(false);
|
||||
fireEvent.click(apply());
|
||||
expect(onEdit).toHaveBeenCalledWith(0, 1, pretty.replace('"Ada"', '"Grace"'));
|
||||
// The panel editor restarts on the committed value.
|
||||
expect(shown(editor.textContent ?? "")).toBe(shown(pretty.replace('"Ada"', '"Grace"')));
|
||||
// The same editor in a larger, resizable window.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open in Window" }));
|
||||
expect(screen.getByRole("dialog", { name: "Value of payload" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("result grid search", () => {
|
||||
const columns = [
|
||||
{ name: "city", type: "text" },
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import clsx from "clsx";
|
||||
import { Dialog, Icon, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { parseDelimited, toCsv, toOneRow, toTsv, type Cell } from "./model";
|
||||
import { initialJsonText, isJsonColumn, isJsonValue } from "./jsonValue";
|
||||
import { JsonValueWindow } from "./JsonValueEditor";
|
||||
import styles from "./Database.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { CELL_PREVIEW_LIMIT, cellPreview, hasLineBreak } from "./cellPreview";
|
||||
@@ -145,7 +147,13 @@ export function Grid(props: GridProps) {
|
||||
window.addEventListener("mouseup", stop);
|
||||
return () => window.removeEventListener("mouseup", stop);
|
||||
}, []);
|
||||
const [editing, setEditing] = useState<{ row: number; col: number; text: string; dialog: boolean } | null>(null);
|
||||
const [editing, setEditing] = useState<{
|
||||
row: number;
|
||||
col: number;
|
||||
text: string;
|
||||
json: boolean;
|
||||
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(() => {
|
||||
@@ -257,13 +265,26 @@ export function Grid(props: GridProps) {
|
||||
scrollRef.current?.focus();
|
||||
};
|
||||
|
||||
/** The JSON value window's Apply / Set NULL: the same pending-edit pipeline as the inline editor. */
|
||||
const commitJsonWindow = (text: string | null) => {
|
||||
if (!editing) return;
|
||||
if (text !== value(editing.row, editing.col)) onEdit?.(editing.row, editing.col, text);
|
||||
setEditing(null);
|
||||
scrollRef.current?.focus();
|
||||
};
|
||||
|
||||
/** `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: maximized || text.length > CELL_PREVIEW_LIMIT || hasLineBreak(text) });
|
||||
const cell = value(row, col);
|
||||
const text = cell ?? "";
|
||||
// JSON cells (type or content) edit in the JSON value editor when the dialog opens (#2333).
|
||||
const json = isJsonValue(columns[col]?.type ?? "", cell);
|
||||
setEditing({ row, col, text, json, dialog: maximized || text.length > CELL_PREVIEW_LIMIT || hasLineBreak(text) });
|
||||
};
|
||||
|
||||
const jsonWindowChanged = editing?.dialog && editing.json && editing.text !== (value(editing.row, editing.col) ?? "");
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (editing) return;
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
@@ -408,7 +429,22 @@ export function Grid(props: GridProps) {
|
||||
onPaste={onPaste}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
{editing?.dialog && (
|
||||
{editing?.dialog && editing.json && (
|
||||
<JsonValueWindow
|
||||
title={tx`Edit ${columns[editing.col]!.name}`}
|
||||
initial={jsonWindowChanged ? editing.text : initialJsonText(editing.text)}
|
||||
baseline={jsonWindowChanged ? (value(editing.row, editing.col) ?? "") : undefined}
|
||||
validate={isJsonColumn(columns[editing.col]!.type)}
|
||||
emptyIsNull={value(editing.row, editing.col) === null}
|
||||
onClose={() => {
|
||||
setEditing(null);
|
||||
scrollRef.current?.focus();
|
||||
}}
|
||||
onApply={(text) => commitJsonWindow(text)}
|
||||
onSetNull={() => commitJsonWindow(null)}
|
||||
/>
|
||||
)}
|
||||
{editing?.dialog && !editing.json && (
|
||||
<Dialog
|
||||
open
|
||||
title={tx`Edit ${columns[editing.col]!.name}`}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EditorState, Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { foldKeymap } from "@codemirror/language";
|
||||
import { setDiagnostics } from "@codemirror/lint";
|
||||
import { openSearchPanel, searchKeymap } from "@codemirror/search";
|
||||
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
|
||||
import { baseExtensions, compartments } from "@/editor/core/cm/setup";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Dialog, T, Button, type DialogButton } from "@/ui";
|
||||
import { formatJson, jsonErrorOf, JSON_INLINE_LIMIT, type JsonError } from "./jsonValue";
|
||||
import { registerValueEditorActions } from "./valueEditorActions";
|
||||
import styles from "./Database.module.css";
|
||||
|
||||
export interface JsonEditorApi {
|
||||
reformat(): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
const jsonLanguage = findLanguageDescription("JSON", "value.json");
|
||||
|
||||
/** A standalone value using the same editor setup and JSON support as file editors. */
|
||||
export function JsonValueEditor(props: {
|
||||
initial: string;
|
||||
validate: boolean;
|
||||
emptyIsNull?: boolean;
|
||||
readOnly?: boolean;
|
||||
ariaLabel: string;
|
||||
onChange: (text: string, error: JsonError | null, pending: boolean) => void;
|
||||
onReady?: (api: JsonEditorApi | null) => void;
|
||||
onSubmit?: () => void;
|
||||
onOpenWindow?: () => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const current = useRef(props);
|
||||
current.current = props;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
let alive = true;
|
||||
let worker: Worker | null = null;
|
||||
const report = (text: string, error: JsonError | null, pending = false) => {
|
||||
current.current.onChange(text, current.current.validate ? error : null, pending && current.current.validate);
|
||||
queueMicrotask(() => {
|
||||
if (!alive || text !== view.state.doc.toString()) return;
|
||||
const from = Math.min(error?.pos ?? 0, Math.max(view.state.doc.length - 1, 0));
|
||||
view.dispatch(
|
||||
setDiagnostics(
|
||||
view.state,
|
||||
error
|
||||
? [
|
||||
{
|
||||
from,
|
||||
to: Math.min(from + 1, view.state.doc.length),
|
||||
severity: current.current.validate ? "error" : "warning",
|
||||
message: error.message,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
const analyze = (text: string, format = false) => {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
if (text === "" && current.current.emptyIsNull) {
|
||||
report(text, null);
|
||||
return;
|
||||
}
|
||||
const done = (result: { text: string; error: JsonError | null }) => {
|
||||
if (!alive || text !== view.state.doc.toString()) return;
|
||||
if (format && result.text !== text && !current.current.readOnly)
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: result.text } });
|
||||
else report(text, result.error);
|
||||
};
|
||||
if (text.length <= JSON_INLINE_LIMIT) {
|
||||
done({ text: format ? formatJson(text) : text, error: jsonErrorOf(text) });
|
||||
} else {
|
||||
report(text, null, true);
|
||||
worker = new Worker(new URL("./jsonValue.worker.ts", import.meta.url), { type: "module" });
|
||||
const task = worker;
|
||||
worker.onmessage = (event: MessageEvent<{ text: string; error: JsonError | null }>) => {
|
||||
if (worker !== task) return;
|
||||
task.terminate();
|
||||
worker = null;
|
||||
done(event.data);
|
||||
};
|
||||
worker.onerror = () => {
|
||||
if (worker !== task) return;
|
||||
task.terminate();
|
||||
worker = null;
|
||||
report(text, { message: tx("JSON validation failed"), pos: 0 });
|
||||
};
|
||||
worker.postMessage({ text, format });
|
||||
}
|
||||
};
|
||||
const reformat = () => {
|
||||
if (!current.current.readOnly) analyze(view.state.doc.toString(), true);
|
||||
return true;
|
||||
};
|
||||
const view = new EditorView({
|
||||
parent: host,
|
||||
state: EditorState.create({
|
||||
doc: props.initial,
|
||||
extensions: baseExtensions({
|
||||
ctx: {
|
||||
doc: -1,
|
||||
path: "value.json",
|
||||
language: "json",
|
||||
uiMode: "JSON",
|
||||
readOnly: !!props.readOnly,
|
||||
virtual: true,
|
||||
},
|
||||
settings: undefined,
|
||||
info: undefined,
|
||||
language: null,
|
||||
docLength: props.initial.length,
|
||||
indent: { tabSize: 2, insertSpaces: true },
|
||||
wrapOverride: false,
|
||||
contributions: [],
|
||||
extra: [
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
current.current.onSubmit?.();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
...searchKeymap,
|
||||
...foldKeymap,
|
||||
]),
|
||||
),
|
||||
EditorView.editable.of(!props.readOnly),
|
||||
Prec.highest(EditorView.contentAttributes.of({ "aria-label": props.ariaLabel, tabindex: "0" })),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) analyze(update.state.doc.toString());
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
if (jsonLanguage)
|
||||
void loadSupport(jsonLanguage).then((support) => {
|
||||
if (alive && support) view.dispatch({ effects: compartments.language.reconfigure(support) });
|
||||
});
|
||||
const unregisterActions = registerValueEditorActions(host, {
|
||||
reformat,
|
||||
find: () => {
|
||||
openSearchPanel(view);
|
||||
},
|
||||
openWindow: props.onOpenWindow ? () => current.current.onOpenWindow?.() : undefined,
|
||||
readOnly: !!props.readOnly,
|
||||
});
|
||||
analyze(props.initial);
|
||||
props.onReady?.({ reformat, focus: () => view.focus() });
|
||||
return () => {
|
||||
alive = false;
|
||||
unregisterActions();
|
||||
worker?.terminate();
|
||||
props.onReady?.(null);
|
||||
view.destroy();
|
||||
};
|
||||
// The parent re-keys the editor when its cell value changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props.initial]);
|
||||
|
||||
return <div ref={hostRef} className={styles.valueEditor} data-db-value-editor />;
|
||||
}
|
||||
|
||||
const VALUE_WINDOW_SIZE_KEY = "openidea.database.valueWindowSize";
|
||||
|
||||
/** The value window's last size; null on first run, clamped to the window by Dialog. */
|
||||
export function loadValueWindowSize(): { w: number; h: number } | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(VALUE_WINDOW_SIZE_KEY) ?? "null") as { w?: unknown; h?: unknown } | null;
|
||||
return typeof v?.w === "number" && typeof v.h === "number" ? { w: v.w, h: v.h } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveValueWindowSize(size: { w: number; h: number }) {
|
||||
try {
|
||||
localStorage.setItem(VALUE_WINDOW_SIZE_KEY, JSON.stringify(size));
|
||||
} catch {
|
||||
// Private mode etc. — the size just isn't remembered.
|
||||
}
|
||||
}
|
||||
|
||||
/** The value editor in a resizable window whose size is remembered between openings. */
|
||||
export function JsonValueWindow(props: {
|
||||
title: string;
|
||||
/** The text to open with, already pretty-printed by the caller. */
|
||||
initial: string;
|
||||
baseline?: string;
|
||||
validate: boolean;
|
||||
emptyIsNull?: boolean;
|
||||
readOnly?: boolean;
|
||||
onClose: () => void;
|
||||
/** Applies the editor's text (the caller's pending-edit pipeline); omit for read-only. */
|
||||
onApply?: (text: string) => void;
|
||||
onSetNull?: () => void;
|
||||
}) {
|
||||
const [text, setText] = useState(props.initial);
|
||||
const [error, setError] = useState<JsonError | null>(() =>
|
||||
!props.emptyIsNull && props.validate && props.initial.length <= JSON_INLINE_LIMIT
|
||||
? jsonErrorOf(props.initial)
|
||||
: null,
|
||||
);
|
||||
const [pending, setPending] = useState(props.initial.length > JSON_INLINE_LIMIT);
|
||||
const editor = useRef<JsonEditorApi | null>(null);
|
||||
const [initialSize] = useState(
|
||||
() =>
|
||||
loadValueWindowSize() ?? {
|
||||
w: Math.min(760, globalThis.innerWidth - 64),
|
||||
h: Math.min(520, globalThis.innerHeight - 64),
|
||||
},
|
||||
);
|
||||
const dirty = text !== (props.baseline ?? props.initial);
|
||||
const apply = () => {
|
||||
if (!props.readOnly && dirty && !error && !pending) props.onApply?.(text);
|
||||
};
|
||||
const buttons: DialogButton[] = props.readOnly
|
||||
? [{ label: "Close", role: "cancel", onClick: props.onClose }]
|
||||
: [
|
||||
...(props.onSetNull ? [{ label: "Set NULL", onClick: props.onSetNull }] : []),
|
||||
{ label: "Cancel", role: "cancel", onClick: props.onClose },
|
||||
{ label: "Apply", role: "default", disabled: !dirty || !!error || pending, onClick: apply },
|
||||
];
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={props.title}
|
||||
onClose={props.onClose}
|
||||
buttons={buttons}
|
||||
width={760}
|
||||
minWidth={420}
|
||||
minHeight={280}
|
||||
resizable="both"
|
||||
initialSize={initialSize}
|
||||
onResized={saveValueWindowSize}
|
||||
footerStart={
|
||||
props.readOnly ? undefined : (
|
||||
<Button size="small" disabled={!!error || pending} onClick={() => editor.current?.reformat()}>
|
||||
<T>Reformat</T>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className={styles.valueWindowBody}>
|
||||
<JsonValueEditor
|
||||
initial={props.initial}
|
||||
validate={props.validate}
|
||||
emptyIsNull={props.emptyIsNull}
|
||||
readOnly={props.readOnly}
|
||||
ariaLabel={props.title}
|
||||
onChange={(t, e, busy) => {
|
||||
setText(t);
|
||||
setError(e);
|
||||
setPending(busy);
|
||||
}}
|
||||
onReady={(api) => {
|
||||
editor.current = api;
|
||||
api?.focus();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
apply();
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<div className={styles.valueError} role="status">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
setFileDecorations,
|
||||
toolWindowService,
|
||||
} from "@/workbench/contributions";
|
||||
import { installValueEditorActions } from "./valueEditorActions";
|
||||
import { SQL_LANGUAGE } from "./sqlLanguage";
|
||||
import { TABLE_KIND, tableTitle } from "./tableInput";
|
||||
import { nodeTarget, type DbNode } from "./nodes";
|
||||
@@ -123,6 +124,7 @@ export function activate(): () => void {
|
||||
() => setFileDecorations("database", new Map()),
|
||||
watchIdleConnections(),
|
||||
installTableEditorFocus(),
|
||||
installValueEditorActions(),
|
||||
registerDatabaseTreeActions(),
|
||||
registerActions([
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatJson, initialJsonText, JSON_INLINE_LIMIT, isJsonColumn, isJsonValue, jsonErrorOf } from "./jsonValue";
|
||||
|
||||
describe("JSON column detection", () => {
|
||||
it("matches json, jsonb and MySQL JSON types", () => {
|
||||
for (const type of ["json", "jsonb", "JSON", "Json", "MYSQL_TYPE_JSON"]) expect(isJsonColumn(type)).toBe(true);
|
||||
for (const type of ["text", "_json", "jsonb[]", "notjson", "json_config"]) expect(isJsonColumn(type)).toBe(false);
|
||||
expect(isJsonColumn("integer")).toBe(false);
|
||||
expect(isJsonColumn("")).toBe(false);
|
||||
});
|
||||
|
||||
it("edits JSON columns as JSON, including null and invalid values", () => {
|
||||
expect(isJsonValue("jsonb", '{"a":1}')).toBe(true);
|
||||
expect(isJsonValue("json", null)).toBe(true);
|
||||
expect(isJsonValue("JSON", "not json at all")).toBe(true);
|
||||
});
|
||||
|
||||
it("edits text cells as JSON only when they are JSON objects or arrays", () => {
|
||||
expect(isJsonValue("text", '{"a": 1}')).toBe(true);
|
||||
expect(isJsonValue("text", " [1, 2] ")).toBe(true);
|
||||
expect(isJsonValue("text", '{"a": 1')).toBe(false);
|
||||
expect(isJsonValue("text", "123")).toBe(false);
|
||||
expect(isJsonValue("text", '"quoted"')).toBe(false);
|
||||
expect(isJsonValue("text", "hello {world")).toBe(false);
|
||||
expect(isJsonValue("text", "")).toBe(false);
|
||||
expect(isJsonValue("text", null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSON validation", () => {
|
||||
it("accepts valid JSON and rejects blank values", () => {
|
||||
expect(jsonErrorOf('{"a": 1}')).toBeNull();
|
||||
expect(jsonErrorOf("[]")).toBeNull();
|
||||
expect(jsonErrorOf("true")).toBeNull();
|
||||
expect(jsonErrorOf("")).not.toBeNull();
|
||||
expect(jsonErrorOf(" ")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports the message and offset of the first syntax error", () => {
|
||||
const error = jsonErrorOf('{"a": 1,}');
|
||||
expect(error).not.toBeNull();
|
||||
expect(error!.message).toMatch(/JSON/i);
|
||||
expect(error!.pos).toBeGreaterThan(0);
|
||||
expect(error!.pos).toBeLessThanOrEqual('{"a": 1,}'.length);
|
||||
// An error without a parse position lands at the start.
|
||||
expect(jsonErrorOf("no json here")!.pos).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatJson", () => {
|
||||
it("pretty-prints with two spaces and keeps scalars", () => {
|
||||
expect(formatJson('{"a":1,"b":[2,3]}')).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}');
|
||||
expect(formatJson("123")).toBe("123");
|
||||
expect(formatJson('"text"')).toBe('"text"');
|
||||
});
|
||||
|
||||
it("passes invalid text through unchanged", () => {
|
||||
expect(formatJson('{"a": 1')).toBe('{"a": 1');
|
||||
expect(formatJson("")).toBe("");
|
||||
});
|
||||
|
||||
it("is stable over already formatted JSON", () => {
|
||||
const pretty = '{\n "a": 1,\n "b": [\n 2\n ]\n}';
|
||||
expect(formatJson(pretty)).toBe(pretty);
|
||||
});
|
||||
});
|
||||
|
||||
it("formats without changing large numbers, duplicate keys or number spelling", () => {
|
||||
expect(formatJson('{"n":9007199254740993,"n":1e2}')).toBe('{\n "n": 9007199254740993,\n "n": 1e2\n}');
|
||||
});
|
||||
|
||||
it("bounds automatic formatting and content detection for large values", () => {
|
||||
const text = '{"large":"' + "x".repeat(5_000_000) + '"}';
|
||||
expect(text.length).toBeGreaterThan(JSON_INLINE_LIMIT);
|
||||
expect(initialJsonText(text)).toBe(text);
|
||||
expect(isJsonValue("text", text)).toBe(false);
|
||||
expect(isJsonValue("json", text)).toBe(true);
|
||||
});
|
||||
|
||||
it("reformats a 5 MB string without dropping any of its content", () => {
|
||||
const value = "x".repeat(5_000_000);
|
||||
expect(formatJson('{"large":"' + value + '"}')).toBe('{\n "large": "' + value + '"\n}');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Cell } from "./model";
|
||||
|
||||
/** Automatic formatting and content detection are bounded on the UI thread. */
|
||||
export const JSON_INLINE_LIMIT = 256_000;
|
||||
|
||||
export const isJsonColumn = (type: string) => /^(json|jsonb|MYSQL_TYPE_JSON)$/i.test(type.trim());
|
||||
|
||||
export interface JsonError {
|
||||
message: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
export function jsonErrorOf(text: string): JsonError | null {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return null;
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const at = /position (\d+)/i.exec(message);
|
||||
return { message, pos: at ? Math.min(Number(at[1]), text.length) : 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/** Content detection chooses highlighting, never the column's validation rules. */
|
||||
export function isJsonValue(columnType: string, cell: Cell): boolean {
|
||||
if (isJsonColumn(columnType)) return true;
|
||||
if (cell === null || cell.length > JSON_INLINE_LIMIT) return false;
|
||||
const text = cell.trim();
|
||||
return (text.startsWith("{") || text.startsWith("[")) && jsonErrorOf(text) === null;
|
||||
}
|
||||
|
||||
/** Change whitespace only: parsing/stringifying would round integers and discard duplicate keys. */
|
||||
export function formatJson(text: string): string {
|
||||
if (jsonErrorOf(text)) return text;
|
||||
const tokens: string[] = [];
|
||||
// Scan strings directly: WebKit can stop a repeated regular expression on a large literal.
|
||||
for (let i = 0; i < text.length;) {
|
||||
const start = i;
|
||||
const char = text[i++]!;
|
||||
if (" \t\r\n".includes(char)) continue;
|
||||
if (char === '"') {
|
||||
while (i < text.length) {
|
||||
const next = text[i++]!;
|
||||
if (next === "\\") i++;
|
||||
else if (next === '"') break;
|
||||
}
|
||||
} else if (!"{}[],:".includes(char)) {
|
||||
while (i < text.length && !" \t\r\n{}[],:".includes(text[i]!)) i++;
|
||||
}
|
||||
tokens.push(text.slice(start, i));
|
||||
}
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let length = 0;
|
||||
const line = () => "\n" + " ".repeat(Math.min(depth, 100));
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i]!;
|
||||
let part = token;
|
||||
if (token === "{" || token === "[") {
|
||||
depth++;
|
||||
if (tokens[i + 1] !== (token === "{" ? "}" : "]")) part += line();
|
||||
} else if (token === "}" || token === "]") {
|
||||
depth--;
|
||||
if (tokens[i - 1] !== (token === "}" ? "{" : "[")) part = line() + token;
|
||||
} else if (token === ",") part += line();
|
||||
else if (token === ":") part += " ";
|
||||
length += part.length;
|
||||
if (length > 16_000_000) return text;
|
||||
parts.push(part);
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function initialJsonText(text: string): string {
|
||||
return text.length <= JSON_INLINE_LIMIT ? formatJson(text) : text;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { formatJson, jsonErrorOf } from "./jsonValue";
|
||||
|
||||
self.onmessage = (event: MessageEvent<{ text: string; format: boolean }>) => {
|
||||
const { text, format } = event.data;
|
||||
self.postMessage({ text: format ? formatJson(text) : text, error: jsonErrorOf(text) });
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
|
||||
interface ValueEditorActions {
|
||||
reformat(): void;
|
||||
find(): void;
|
||||
openWindow?: () => void;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const editors = new WeakMap<Element, ValueEditorActions>();
|
||||
let active: ValueEditorActions | null = null;
|
||||
|
||||
function update() {
|
||||
setContext("dbJsonValueFocus", !!active);
|
||||
setContext("dbJsonValueEditable", !!active && !active.readOnly);
|
||||
setContext("dbJsonValueCanOpenWindow", !!active?.openWindow);
|
||||
}
|
||||
|
||||
export function registerValueEditorActions(root: Element, actions: ValueEditorActions): () => void {
|
||||
editors.set(root, actions);
|
||||
return () => {
|
||||
editors.delete(root);
|
||||
if (active === actions) active = null;
|
||||
update();
|
||||
};
|
||||
}
|
||||
|
||||
export function installValueEditorActions(): () => void {
|
||||
const onFocus = (event: FocusEvent) => {
|
||||
const root = event.target instanceof Element ? event.target.closest("[data-db-value-editor]") : null;
|
||||
active = root ? (editors.get(root) ?? null) : null;
|
||||
update();
|
||||
};
|
||||
document.addEventListener("focusin", onFocus);
|
||||
const dispose = registerActions([
|
||||
{
|
||||
id: "db.reformatValue",
|
||||
title: "Reformat",
|
||||
category: "Database",
|
||||
when: "dbJsonValueFocus && dbJsonValueEditable",
|
||||
run: () => active?.reformat(),
|
||||
},
|
||||
{
|
||||
id: "db.findInValue",
|
||||
title: "Find",
|
||||
category: "Database",
|
||||
when: "dbJsonValueFocus",
|
||||
run: () => active?.find(),
|
||||
},
|
||||
{
|
||||
id: "db.openValueWindow",
|
||||
title: "Open in Window",
|
||||
category: "Database",
|
||||
when: "dbJsonValueCanOpenWindow",
|
||||
run: () => active?.openWindow?.(),
|
||||
},
|
||||
]);
|
||||
return () => {
|
||||
dispose();
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
active = null;
|
||||
update();
|
||||
};
|
||||
}
|
||||
@@ -104,6 +104,8 @@ export const eclipseMacos: Record<string, string[]> = {
|
||||
"code.quickDocumentation": ["F2"],
|
||||
"db.quickDoc": ["F2"],
|
||||
"localHistory.recentChanges": [],
|
||||
"db.reformatValue": ["Shift+Meta+F"],
|
||||
"db.findInValue": [],
|
||||
"code.reformat": ["Shift+Meta+F"],
|
||||
"project.reformatCode": ["Shift+Meta+F"],
|
||||
"code.unwrap": [],
|
||||
|
||||
@@ -108,6 +108,8 @@ export const eclipseWindows: Record<string, string[]> = {
|
||||
"code.quickDocumentation": ["Shift+Ctrl+Space", "F2"],
|
||||
"db.quickDoc": ["Shift+Ctrl+Space", "F2"],
|
||||
"localHistory.recentChanges": [],
|
||||
"db.reformatValue": ["Ctrl+Alt+L", "Shift+Ctrl+F"],
|
||||
"db.findInValue": [],
|
||||
"code.reformat": ["Ctrl+Alt+L", "Shift+Ctrl+F"],
|
||||
"project.reformatCode": ["Ctrl+Alt+L", "Shift+Ctrl+F"],
|
||||
"code.unwrap": ["Alt+R"],
|
||||
|
||||
@@ -47,7 +47,7 @@ const shared = [
|
||||
["find.previous", "find.popup.previousResult"],
|
||||
["find.usages", "project.findUsages"],
|
||||
["code.quickDocumentation", "db.quickDoc", "debug.setValue", "vcs.changelist.edit"],
|
||||
["code.reformat", "project.reformatCode"],
|
||||
["code.reformat", "project.reformatCode", "db.reformatValue"],
|
||||
["code.optimizeImports", "project.optimizeImports"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff"],
|
||||
["code.moveStatementDown", "find.field.recentSearches", "find.popup.recentSearches"],
|
||||
|
||||
@@ -354,6 +354,9 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"http.runRequest": ["Meta+Enter"],
|
||||
"graphql.runQuery": ["Meta+Enter"],
|
||||
"http.runAll": [],
|
||||
"db.reformatValue": ["Meta+Alt+L"],
|
||||
"db.openValueWindow": [],
|
||||
"db.findInValue": ["Meta+F"],
|
||||
"db.execute": ["Meta+Enter"],
|
||||
// Table editor with pending changes (after `vcs.rollback`, so ⌥⌘Z reverts the table's edits there).
|
||||
"db.goToColumn": ["Meta+F12"],
|
||||
|
||||
@@ -352,6 +352,9 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"http.runRequest": ["Ctrl+Enter"],
|
||||
"graphql.runQuery": ["Ctrl+Enter"],
|
||||
"http.runAll": [],
|
||||
"db.reformatValue": ["Ctrl+Alt+L"],
|
||||
"db.openValueWindow": [],
|
||||
"db.findInValue": ["Ctrl+F"],
|
||||
"db.execute": ["Ctrl+Enter"],
|
||||
// Table editor with pending changes (after `vcs.rollback`, so Ctrl+Alt+Z reverts the table's edits there).
|
||||
"db.goToColumn": ["Ctrl+F12"],
|
||||
|
||||
@@ -350,6 +350,9 @@ export const vscodeMacos: Record<string, string[]> = {
|
||||
"http.runRequest": ["Meta+Enter"],
|
||||
"graphql.runQuery": ["Meta+Enter"],
|
||||
"http.runAll": [],
|
||||
"db.reformatValue": ["Alt+Shift+F"],
|
||||
"db.openValueWindow": [],
|
||||
"db.findInValue": ["Meta+F"],
|
||||
"db.execute": ["Meta+Enter"],
|
||||
"db.showToolWindow": [],
|
||||
// Database tool window (when the tree has focus).
|
||||
|
||||
@@ -786,6 +786,9 @@
|
||||
"Remote of {name}": "Remote of {name}",
|
||||
"Target branch of {name}": "Target branch of {name}",
|
||||
"Remote project files cannot be shown in {fileManager}": "Remote project files cannot be shown in {fileManager}",
|
||||
"Reformat": "Reformat",
|
||||
"Open in Window": "Open in Window",
|
||||
"JSON validation failed": "JSON validation failed",
|
||||
"Download Sources": "Download Sources",
|
||||
"Choose Sources…": "Choose Sources…",
|
||||
"Source archives": "Source archives",
|
||||
|
||||
@@ -7121,6 +7121,9 @@
|
||||
"Remote of {name}": "{name} 的远程",
|
||||
"Target branch of {name}": "{name} 的目标分支",
|
||||
"Remote project files cannot be shown in {fileManager}": "无法在{fileManager}中显示远程项目的文件",
|
||||
"Reformat": "重新格式化",
|
||||
"Open in Window": "在窗口中打开",
|
||||
"JSON validation failed": "JSON 验证失败",
|
||||
"Download Sources": "下载源代码",
|
||||
"Choose Sources…": "选择源代码…",
|
||||
"Source archives": "源代码归档",
|
||||
|
||||
Reference in New Issue
Block a user