From 2780259998e75092e86cbc6e92951b334fa64e4d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:00:15 +0800 Subject: [PATCH] feat(database): submit and revert table edits from the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The table editor's Submit and Revert were toolbar buttons only, with no shortcut, no Keymap entry and nothing in Find Action. They are now the db.submitChanges and db.revertChanges actions, enabled while a table editor has focus and pending edits, bound to ⌘↩ / Ctrl+Enter and ⌥⌘Z / Ctrl+Alt+Z in the IntelliJ keymaps (unbound in Eclipse, where ⌥⌘Z is Surround With). They don't take focus from a text field, so ⌘↩ still applies the edit in the Value panel and the large-value dialog, and still runs Execute in query consoles. --- src/features/database/TableEditor.tsx | 15 +++++- src/features/database/index.ts | 18 +++++++ src/features/database/table-editor.test.tsx | 38 ++++++++++++++ src/features/database/tableEditorFocus.ts | 58 +++++++++++++++++++++ src/platform/keymaps/eclipse-macos.ts | 2 + src/platform/keymaps/intellij-macos.ts | 3 ++ src/platform/keymaps/intellij-windows.ts | 3 ++ 7 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 src/features/database/tableEditorFocus.ts diff --git a/src/features/database/TableEditor.tsx b/src/features/database/TableEditor.tsx index d188963d1..adc560afd 100644 --- a/src/features/database/TableEditor.tsx +++ b/src/features/database/TableEditor.tsx @@ -3,7 +3,7 @@ * a WHERE filter, and pending cell edits / added / deleted rows submitted * together in one transaction. Tables without a primary key are read-only. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { call, commands, @@ -30,6 +30,7 @@ import { import { openDialog, sourceById, withConnection } from "./store"; import type { TableRef } from "./tableInput"; import styles from "./Database.module.css"; +import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus"; import { tx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; @@ -179,9 +180,19 @@ export function TableEditor({ input }: { input: EditorInput }) { }); const dirty = hasEdits(edits); + // Submit / Revert actions (⌘↩ / ⌥⌘Z) reach the latest state through the handle. + const viewRef = useRef(null); + const handle = useRef({ dirty: false, submit: () => {}, revert: () => {} }).current; + handle.dirty = editable && dirty; + handle.submit = () => { + if (editable && dirty) void submit(); + }; + handle.revert = () => setEdits(emptyEdits()); + useEffect(() => registerTableEditor(viewRef.current!, handle), [handle]); + useEffect(() => tableEditorChanged(handle), [handle, dirty, editable]); const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names); return ( -
+
diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 57e524572..43843071b 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -7,6 +7,7 @@ import { lazyComponent } from "@/ui"; import { activeEditor, registerEditorContribution } from "@/editor/api"; import { registerActions } from "@/platform/actions"; +import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus"; import { useProject } from "@/platform/project"; import { registerEditorKind, @@ -94,6 +95,7 @@ export function activate(): () => void { }), () => setFileDecorations("database", new Map()), watchIdleConnections(), + installTableEditorFocus(), registerDatabaseTreeActions(), registerActions([ { @@ -152,6 +154,22 @@ export function activate(): () => void { await executeInView(e.view, e.ctx.path); }, }, + { + id: "db.submitChanges", + title: "Submit", + category: "Tools", + keywords: ["table editor", "apply changes", "commit"], + when: "dbTableEditorFocus && dbTableEditorChanges", + run: () => activeTableEditor()?.submit(), + }, + { + id: "db.revertChanges", + title: "Revert", + category: "Tools", + keywords: ["table editor", "discard changes", "undo"], + when: "dbTableEditorFocus && dbTableEditorChanges", + run: () => activeTableEditor()?.revert(), + }, { id: "db.explainPlan", title: "Explain Plan", diff --git a/src/features/database/table-editor.test.tsx b/src/features/database/table-editor.test.tsx index 98c1da0c6..32687106a 100644 --- a/src/features/database/table-editor.test.tsx +++ b/src/features/database/table-editor.test.tsx @@ -1,7 +1,9 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DbDriver, DbTablePage } from "@/platform/ipc"; +import { getContext } from "@/platform/context"; import { TableEditor } from "./TableEditor"; +import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus"; import { tableInput } from "./tableInput"; const backend = vi.hoisted(() => ({ @@ -87,4 +89,40 @@ describe("table editor capabilities", () => { expect(screen.getByRole("button", { name: "Add Row" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Submit" })).toBeTruthy(); }); + + it("offers Submit and Revert to the keyboard while the grid has focus and edits are pending (#1549)", async () => { + backend.driver = "postgres"; + backend.applyEdits.mockResolvedValue({ status: "ok", data: 1 }); + const uninstall = installTableEditorFocus(); + try { + await act(async () => { + render(); + }); + const grid = screen.getByRole("grid"); + act(() => grid.focus()); + expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false }); + + fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" })); + const input = screen.getByRole("textbox", { name: "Edit name" }); + // A text field keeps ⌘↩ for its own edit. + expect(getContext().dbTableEditorFocus).toBe(false); + fireEvent.change(input, { target: { value: "Bob" } }); + fireEvent.keyDown(input, { key: "Enter" }); + act(() => grid.focus()); + expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: true }); + + act(() => activeTableEditor()!.revert()); + expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy(); + expect(getContext().dbTableEditorChanges).toBe(false); + + fireEvent.doubleClick(screen.getByRole("gridcell", { name: "Ann" })); + fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Bob" } }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "Edit name" }), { key: "Enter" }); + act(() => grid.focus()); + await act(async () => activeTableEditor()!.submit()); + expect(backend.applyEdits).toHaveBeenCalledTimes(1); + } finally { + uninstall(); + } + }); }); diff --git a/src/features/database/tableEditorFocus.ts b/src/features/database/tableEditorFocus.ts new file mode 100644 index 000000000..d6358630f --- /dev/null +++ b/src/features/database/tableEditorFocus.ts @@ -0,0 +1,58 @@ +/** + * The table editor that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z). Not while a text field has + * it: ⌘↩ there applies the field's own edit (the Value panel, the cell editor). + */ +import { setContext } from "@/platform/context"; + +export interface TableEditorHandle { + dirty: boolean; + submit(): void; + revert(): void; +} + +const handles = new WeakMap(); +let active: TableEditorHandle | null = null; + +const isTextField = (el: Element) => !!el.closest("input, textarea, select, [contenteditable='true']"); + +function update() { + setContext("dbTableEditorFocus", !!active); + setContext("dbTableEditorChanges", !!active?.dirty); +} + +function onFocusIn(event: FocusEvent) { + const target = event.target instanceof Element ? event.target : null; + const root = target?.closest("[data-db-table-editor]"); + active = (root && !isTextField(target!) && handles.get(root)) || null; + update(); +} + +/** Registers the editor rooted at `root`; returns the function that unregisters it. */ +export function registerTableEditor(root: Element, handle: TableEditorHandle): () => void { + handles.set(root, handle); + if (root.contains(document.activeElement) && !isTextField(document.activeElement!)) active = handle; + update(); + return () => { + handles.delete(root); + if (active === handle) active = null; + update(); + }; +} + +/** The handle's state changed (pending edits). */ +export function tableEditorChanged(handle: TableEditorHandle) { + if (active === handle) update(); +} + +export function activeTableEditor(): TableEditorHandle | null { + return active; +} + +export function installTableEditorFocus(): () => void { + document.addEventListener("focusin", onFocusIn); + return () => { + document.removeEventListener("focusin", onFocusIn); + active = null; + update(); + }; +} diff --git a/src/platform/keymaps/eclipse-macos.ts b/src/platform/keymaps/eclipse-macos.ts index bf54ab70f..64fde8a23 100644 --- a/src/platform/keymaps/eclipse-macos.ts +++ b/src/platform/keymaps/eclipse-macos.ts @@ -123,6 +123,8 @@ export const eclipseMacos: Record = { "debug.stepOver": ["F6"], "debug.forceStepOver": ["Meta+Ctrl+F6"], "code.surroundWith": ["Meta+Alt+Z"], + // ⌥⌘Z is Surround With here, not the table editor's Revert. + "db.revertChanges": [], "file.reloadFromDisk": [], "project.reloadFromDisk": [], "navigate.toggleBookmark": [], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 6d939fa80..fae455fef 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -352,6 +352,9 @@ export const intellijMacos: Record = { "graphql.runQuery": ["Meta+Enter"], "http.runAll": [], "db.execute": ["Meta+Enter"], + // Table editor with pending changes (after `vcs.rollback`, so ⌥⌘Z reverts the table's edits there). + "db.submitChanges": ["Meta+Enter"], + "db.revertChanges": ["Meta+Alt+Z"], "db.showToolWindow": [], "agents.newTask": ["Meta+Shift+I"], "agents.showToolWindow": [], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index 75b0a8c53..b433d6a7d 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -350,6 +350,9 @@ export const intellijWindows: Record = { "graphql.runQuery": ["Ctrl+Enter"], "http.runAll": [], "db.execute": ["Ctrl+Enter"], + // Table editor with pending changes (after `vcs.rollback`, so Ctrl+Alt+Z reverts the table's edits there). + "db.submitChanges": ["Ctrl+Enter"], + "db.revertChanges": ["Ctrl+Alt+Z"], "db.showToolWindow": [], "agents.newTask": ["Ctrl+Alt+Shift+I"], "agents.showToolWindow": [],