Compare commits

...
Author SHA1 Message Date
l0ng-ai 2d1057538b chore: refresh PR head 2026-11-06 00:08:16 +08:00
l0ng-ai df67920512 Merge main 2026-11-06 00:01:25 +08:00
l0ng-ai 2780259998 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.
2026-11-06 00:00:15 +08:00
7 changed files with 135 additions and 2 deletions

No files matched your search

+13 -2
View File
@@ -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
View File
@@ -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",
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": [],