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:
Vadim Lazuk
2026-11-10 05:22:56 +08:00
committed by GitHub
co-authored by Inf1mum
parent 64dad9c520
commit 6c3e5ae469
21 changed files with 1079 additions and 32 deletions
+149
View File
@@ -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);
});
+3 -1
View File
@@ -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 ?? [],
];
+105 -19
View File
@@ -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>
);
}
+31
View File
@@ -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;
+4 -1
View File
@@ -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", () => {
+213 -6
View File
@@ -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" },
+40 -4
View File
@@ -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}`}
+282
View File
@@ -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>
);
}
+2
View File
@@ -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([
{
+83
View File
@@ -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}');
});
+76
View File
@@ -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();
};
}
+2
View File
@@ -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": [],
+2
View File
@@ -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"],
+1 -1
View File
@@ -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"],
+3
View File
@@ -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"],
+3
View File
@@ -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"],
+3
View File
@@ -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).
+3
View File
@@ -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",
+3
View File
@@ -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": "源代码归档",