дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
97177486e9
коміт
2780259998
7 files changed
+135
-2
No files matched your search
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
Reference in new issue
Заблокувати користувача