From 425801be34b3f7ce95712fd1056c446b460a0ac3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:08:37 +0800 Subject: [PATCH] feat(database): submit and revert table edits from the keyboard (#1589) 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. 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). A text field keeps ⌘↩ for its own edit (Value panel, large-value dialog), and query consoles keep Execute. Closes #1549 Co-Authored-By: Claude Opus 5.5 --- 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": [],