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

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.
Цей коміт міститься в:
l0ng-ai committed 2026-11-06 00:00:15 +08:00
1 джерело 97177486e9
коміт 2780259998
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": [],