diff --git a/e2e/databaseJsonValue.spec.ts b/e2e/databaseJsonValue.spec.ts new file mode 100644 index 000000000..1c75a9f21 --- /dev/null +++ b/e2e/databaseJsonValue.spec.ts @@ -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); +}); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 1c01eac6b..a6ee0d556 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -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 ?? [], ]; diff --git a/src/features/database/DataView.tsx b/src/features/database/DataView.tsx index 8c4a41e4f..9a8ab407c 100644 --- a/src/features/database/DataView.tsx +++ b/src/features/database/DataView.tsx @@ -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: { ) : props.panel === "value" ? ( 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(() => + 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(null); + const dirty = text !== initial; + const commit = () => { + if (props.editable && dirty && !error && !pending) props.onCommit(text); + }; + const [windowOpen, setWindowOpen] = useState(false); return ( - " : undefined} - spellCheck={false} - onChange={(e) => setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && dirty) props.onCommit(text); - }} - /> + {json ? ( + <> + { + setText(t); + setError(e); + setPending(busy); + }} + onReady={(api) => (editor.current = api)} + onSubmit={commit} + onOpenWindow={() => setWindowOpen(true)} + /> + {error && ( + + {error.message} + + )} + + editor.current?.reformat()} + > + Reformat + + setWindowOpen(true)}> + Open in Window + + + > + ) : ( + " : 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 && ( - props.onCommit(text)}> + Apply - props.onCommit(null)}> + { + if (props.cell !== null) props.onCommit(null); + }} + > Set NULL )} + {windowOpen && ( + setWindowOpen(false)} + onApply={(t) => { + props.onCommit(t); + setWindowOpen(false); + }} + onSetNull={() => { + props.onCommit(null); + setWindowOpen(false); + }} + /> + )} ); } diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index 4aa6642c9..37ba7524d 100644 --- a/src/features/database/Database.module.css +++ b/src/features/database/Database.module.css @@ -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; diff --git a/src/features/database/ExportDialog.test.tsx b/src/features/database/ExportDialog.test.tsx index 3b06fa8c7..ef779bbc6 100644 --- a/src/features/database/ExportDialog.test.tsx +++ b/src/features/database/ExportDialog.test.tsx @@ -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()), + notifications: () => ({ notify: vi.fn() }), +})); vi.mock("@tauri-apps/api/path", () => ({ homeDir: async () => "/home/test" })); describe("large value export", () => { diff --git a/src/features/database/Grid.test.tsx b/src/features/database/Grid.test.tsx index 7c8e736a6..fdf5002f0 100644 --- a/src/features/database/Grid.test.tsx +++ b/src/features/database/Grid.test.tsx @@ -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( full} editable onEdit={onEdit} />); + const large = "word ".repeat(40_000); // not JSON: the plain multi-line editor + render( + 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( 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( 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( 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( 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( + 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( 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( 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( + 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(".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( + 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( 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( + 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" }, diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index a1645500f..c46b7b68a 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -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) => { 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 && ( + { + setEditing(null); + scrollRef.current?.focus(); + }} + onApply={(text) => commitJsonWindow(text)} + onSetNull={() => commitJsonWindow(null)} + /> + )} + {editing?.dialog && !editing.json && ( void; + onReady?: (api: JsonEditorApi | null) => void; + onSubmit?: () => void; + onOpenWindow?: () => void; +}) { + const hostRef = useRef(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 ; +} + +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(() => + !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(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 ( + editor.current?.reformat()}> + Reformat + + ) + } + > + + { + setText(t); + setError(e); + setPending(busy); + }} + onReady={(api) => { + editor.current = api; + api?.focus(); + }} + onSubmit={() => { + apply(); + }} + /> + {error && ( + + {error.message} + + )} + + + ); +} diff --git a/src/features/database/index.ts b/src/features/database/index.ts index e9783c53c..65bb28691 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -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([ { diff --git a/src/features/database/jsonValue.test.ts b/src/features/database/jsonValue.test.ts new file mode 100644 index 000000000..27239de4f --- /dev/null +++ b/src/features/database/jsonValue.test.ts @@ -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}'); +}); diff --git a/src/features/database/jsonValue.ts b/src/features/database/jsonValue.ts new file mode 100644 index 000000000..df0dcf084 --- /dev/null +++ b/src/features/database/jsonValue.ts @@ -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; +} diff --git a/src/features/database/jsonValue.worker.ts b/src/features/database/jsonValue.worker.ts new file mode 100644 index 000000000..5e8d3f686 --- /dev/null +++ b/src/features/database/jsonValue.worker.ts @@ -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) }); +}; diff --git a/src/features/database/valueEditorActions.ts b/src/features/database/valueEditorActions.ts new file mode 100644 index 000000000..f46abdbb9 --- /dev/null +++ b/src/features/database/valueEditorActions.ts @@ -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(); +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(); + }; +} diff --git a/src/platform/keymaps/eclipse-macos.ts b/src/platform/keymaps/eclipse-macos.ts index 2671f243d..51ba46f94 100644 --- a/src/platform/keymaps/eclipse-macos.ts +++ b/src/platform/keymaps/eclipse-macos.ts @@ -104,6 +104,8 @@ export const eclipseMacos: Record = { "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": [], diff --git a/src/platform/keymaps/eclipse-windows.ts b/src/platform/keymaps/eclipse-windows.ts index abc425fe3..ff64a4da8 100644 --- a/src/platform/keymaps/eclipse-windows.ts +++ b/src/platform/keymaps/eclipse-windows.ts @@ -108,6 +108,8 @@ export const eclipseWindows: Record = { "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"], diff --git a/src/platform/keymaps/eclipse.test.ts b/src/platform/keymaps/eclipse.test.ts index 59aa3517f..eefe5f609 100644 --- a/src/platform/keymaps/eclipse.test.ts +++ b/src/platform/keymaps/eclipse.test.ts @@ -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"], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 045ba6626..5b909aec9 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -354,6 +354,9 @@ export const intellijMacos: Record = { "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"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index a049427c4..ee01144a6 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -352,6 +352,9 @@ export const intellijWindows: Record = { "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"], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 5b6dd9b35..cfd6101f1 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -350,6 +350,9 @@ export const vscodeMacos: Record = { "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). diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 156f35b0d..2c6862143 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index baabab728..05b6b64a2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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": "源代码归档",