feat(database): submit and revert table edits from the keyboard (#1589)

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 <[email protected]>
Цей коміт міститься в:
l0ng-aiтаClaude Opus 5.5 authored and GitHub committed 2026-11-06 00:08:37 +08:00
1 джерело 269f3b22fd
коміт 425801be34
7 files changed
+135 -2

No files matched your search

+13 -2
Переглянути файл
@@ -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<HTMLDivElement>(null);
const handle = useRef<TableEditorHandle>({ 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 (
<div className={styles.view}>
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<IconButton icon="refresh" title="Reload Page" onClick={reload} disabled={loading} />
<div className={styles.pager}>
+18
Переглянути файл
@@ -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",
+38
Переглянути файл
@@ -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(<TableEditor input={tableInput("source", "public", "users")} />);
});
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();
}
});
});
+58
Переглянути файл
@@ -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<Element, TableEditorHandle>();
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();
};
}
+2
Переглянути файл
@@ -123,6 +123,8 @@ export const eclipseMacos: Record<string, string[]> = {
"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": [],
+3
Переглянути файл
@@ -352,6 +352,9 @@ export const intellijMacos: Record<string, string[]> = {
"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": [],
+3
Переглянути файл
@@ -350,6 +350,9 @@ export const intellijWindows: Record<string, string[]> = {
"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": [],