feat(database): submit and revert pending console result edits from the keyboard (#2370)

- The console result grid reuses the table editor's Submit / Revert actions and shortcuts, active only while the grid has focus and pending edits
- ⌘↩ while editing a cell confirms that cell before submitting (the table editor does the same)
- Submit / Revert button tooltips show their shortcuts; the result grid supports Go to Column
- ⌘↩ still executes the statement in the SQL editor

Closes #2332

Co-authored-by: LU XIANGRUI <[email protected]>
Цей коміт міститься в:
LU XIANGRUIтаLU XIANGRUI authored and GitHub committed 2026-11-09 22:39:29 +08:00
1 джерело b50219b6fa
коміт 68dd88993b
6 files changed
+141 -14

No files matched your search

+1
Переглянути файл
@@ -507,6 +507,7 @@ export function Grid(props: GridProps) {
<input
key={c}
className={styles.cellEditor}
data-cell-editor=""
autoFocus
aria-label={`Edit ${columns[c]!.name}`}
value={editing.text}
+75 -5
Переглянути файл
@@ -4,13 +4,26 @@
* a toolbar (re-run, export / copy as CSV, JSON, SQL…), the grid and a floating row count.
*/
import { copySelectionOnKeyDown } from "@/ui/selectableText";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import { call, commands, type DbPlanNode, type DbQueryResult } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { Button, EmptyState, Icon, IconButton, Select, Spinner, Tabs, Tree, showContextMenu, T } from "@/ui";
import {
Button,
EmptyState,
Icon,
IconButton,
Select,
Spinner,
Tabs,
Tree,
showContextMenu,
T,
tooltipAttrs,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ChartView } from "./ChartView";
import { ColumnsPopup } from "./ColumnsPopup";
import { DataView, ExtractorSelect, PanelToggle, copyFormatOf, exportMenu, type DataPanel } from "./DataView";
import type { ExportData, ExportFormat } from "./export";
import { buildRowEdits, emptyEdits, hasEdits, splitTarget, type Cell, type PendingEdits } from "./model";
@@ -45,6 +58,7 @@ import {
type ResultTab,
} from "./store";
import styles from "./Database.module.css";
import { registerTableEditor, tableEditorChanged, type TableEditorHandle } from "./tableEditorFocus";
import { tx } from "@/platform/i18n";
function time(t: number): string {
@@ -327,6 +341,38 @@ function Result({ tab }: { tab: ResultTab }) {
}
};
// Submit / Revert / Go to Column (⌘↩ / ⌥⌘Z / ⌘F12) reach the latest state through the handle, as in the table editor.
const canSubmit = editable && dirty && !busy;
const viewRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<TableEditorHandle>({
dirty: false,
submit: () => {},
revert: () => {},
showColumns: () => {},
});
const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null);
const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null);
useLayoutEffect(() => {
const handle = handleRef.current;
handle.dirty = canSubmit;
handle.submit = () => {
if (canSubmit) void submit();
};
handle.revert = () => {
if (!busy) setEdits(() => emptyEdits());
};
handle.showColumns = () => {
const rect = viewRef.current?.getBoundingClientRect();
if (view === "table" && rect) setColumnsAt({ x: rect.left + rect.width / 2, y: rect.top + 48 });
};
tableEditorChanged(handle);
});
const hasResult = !!r;
useEffect(
() => (hasResult && viewRef.current ? registerTableEditor(viewRef.current, handleRef.current) : undefined),
[hasResult],
);
if (!r) return null;
const data = (): ExportData => ({
columns,
@@ -349,7 +395,7 @@ function Result({ tab }: { tab: ResultTab }) {
// else: the table's details are still loading.
}
return (
<div className={styles.view}>
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<IconButton icon="db-table" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
@@ -372,10 +418,21 @@ function Result({ tab }: { tab: ResultTab }) {
disabled={busy}
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
/>
<Button size="small" variant="primary" disabled={!dirty || busy} onClick={() => void submit()}>
<Button
size="small"
variant="primary"
disabled={!dirty || busy}
onClick={() => void submit()}
{...tooltipAttrs("Submit", shortcutLabel("db.submitChanges"))}
>
<T>Submit</T>
</Button>
<Button size="small" disabled={!dirty || busy} onClick={() => setEdits(() => emptyEdits())}>
<Button
size="small"
disabled={!dirty || busy}
onClick={() => setEdits(() => emptyEdits())}
{...tooltipAttrs("Revert", shortcutLabel("db.revertChanges"))}
>
<T>Revert</T>
</Button>
</>
@@ -421,6 +478,7 @@ function Result({ tab }: { tab: ResultTab }) {
isModified={(row, col) => !!edits.updates.get(row)?.has(resultNames[col]!)}
editable={editable && !busy}
onEdit={onEdit}
revealColumn={revealColumn}
rowMenu={(rows) =>
editable && !busy
? [
@@ -441,6 +499,18 @@ function Result({ tab }: { tab: ResultTab }) {
</div>
</>
)}
{columnsAt && (
<ColumnsPopup
anchor={columnsAt}
table={tableLabel || tab.title}
columns={columns}
onPick={(col) => {
setColumnsAt(null);
setRevealColumn({ col });
}}
onClose={() => setColumnsAt(null)}
/>
)}
</div>
);
}
+14 -3
Переглянути файл
@@ -13,7 +13,7 @@ import {
type DbTablePage,
type RequestId,
} from "@/platform/ipc";
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui";
import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu, tooltipAttrs } from "@/ui";
import { notifications, type EditorInput } from "@/workbench/contributions";
import { SqlFilterInput } from "./SqlFilterInput";
import { ColumnsPopup } from "./ColumnsPopup";
@@ -241,10 +241,21 @@ export function TableEditor({ input }: { input: EditorInput }) {
title="Add Row"
onClick={() => setEdits((e) => ({ ...e, inserts: [...e.inserts, new Map()] }))}
/>
<Button size="small" variant="primary" disabled={!dirty} onClick={() => void submit()}>
<Button
size="small"
variant="primary"
disabled={!dirty}
onClick={() => void submit()}
{...tooltipAttrs("Submit", shortcutLabel("db.submitChanges"))}
>
<T>Submit</T>
</Button>
<Button size="small" disabled={!dirty} onClick={() => setEdits(emptyEdits())}>
<Button
size="small"
disabled={!dirty}
onClick={() => setEdits(emptyEdits())}
{...tooltipAttrs("Revert", shortcutLabel("db.revertChanges"))}
>
<T>Revert</T>
</Button>
</>
+35
Переглянути файл
@@ -1,8 +1,10 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
import { getContext } from "@/platform/context";
import { ResultsView } from "./ResultsView";
import { executeConsole, useDatabase, type ResultTab } from "./store";
import { activeTableEditor, installTableEditorFocus } from "./tableEditorFocus";
const backend = vi.hoisted(() => ({
driver: "postgres" as DbDriver,
@@ -326,3 +328,36 @@ describe("read-only results with a reason", () => {
);
});
});
describe("result grid keyboard Submit / Revert (#2332)", () => {
it("confirms the cell being edited, then submits; reverts pending edits", async () => {
const uninstall = installTableEditorFocus();
try {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
await act(async () => render(<ResultsView />));
const submit = await screen.findByRole("button", { name: "Submit" });
expect(submit.getAttribute("data-tooltip-shortcut")).toBeTruthy();
const grid = screen.getByRole("grid");
act(() => grid.focus());
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: false });
fireEvent.doubleClick(screen.getByRole("gridcell", { name: "Ann" }));
fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Bob" } });
// Still the grid while a cell is being edited: ⌘↩ confirms the cell, then submits.
expect(getContext()).toMatchObject({ dbTableEditorFocus: true, dbTableEditorChanges: true });
await act(async () => activeTableEditor()!.submit());
expect(backend.applyEdits).toHaveBeenCalledWith("pg", "public", "users", [
{ kind: "update", key: [{ column: "id", value: "1" }], values: [{ column: "name", value: "Bob" }] },
]);
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
fireEvent.change(screen.getByRole("textbox", { name: "Edit name" }), { target: { value: "Cara" } });
act(() => activeTableEditor()!.revert());
expect(screen.queryByRole("textbox", { name: "Edit name" })).toBeNull();
expect(screen.getByRole("gridcell", { name: "Ann" })).toBeTruthy();
expect(getContext().dbTableEditorChanges).toBe(false);
} finally {
uninstall();
}
});
});
+2 -2
Переглянути файл
@@ -104,8 +104,8 @@ describe("table editor capabilities", () => {
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);
// The inline cell editor still counts as the grid: ⌘↩ confirms the cell, then submits (#2332).
expect(getContext().dbTableEditorFocus).toBe(true);
fireEvent.change(input, { target: { value: "Bob" } });
fireEvent.keyDown(input, { key: "Enter" });
act(() => grid.focus());
+14 -4
Переглянути файл
@@ -1,7 +1,9 @@
/**
* The table editor that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to Column (⌘F12). Not
* while a text field has it: ⌘↩ there applies the field's own edit (the Value panel, the cell editor).
* The table editor or editable result grid that has focus, for Submit / Revert (⌘↩ / ⌥⌘Z) and Go to
* Column (⌘F12). Not while a text field has it: ⌘↩ there applies the field's own edit (the Value
* panel, the multi-line editor). The grid's inline cell editor counts as the grid: the cell is confirmed first.
*/
import { flushSync } from "react-dom";
import { setContext } from "@/platform/context";
export interface TableEditorHandle {
@@ -13,12 +15,15 @@ export interface TableEditorHandle {
const handles = new WeakMap<Element, TableEditorHandle>();
let active: TableEditorHandle | null = null;
let editingCell = false;
const isTextField = (el: Element) => !!el.closest("input, textarea, select, [contenteditable='true']");
const isCellEditor = (el: Element) => el.matches("[data-cell-editor]");
const isTextField = (el: Element) =>
!isCellEditor(el) && !!el.closest("input, textarea, select, [contenteditable='true']");
function update() {
setContext("dbTableEditorFocus", !!active);
setContext("dbTableEditorChanges", !!active?.dirty);
setContext("dbTableEditorChanges", !!active && (active.dirty || editingCell));
}
function onFocusIn(event: FocusEvent) {
@@ -29,6 +34,7 @@ function onFocusIn(event: FocusEvent) {
target?.closest("[data-db-table-editor]") ??
(target?.hasAttribute("data-editor-focus") ? target.querySelector("[data-db-table-editor]") : null);
active = (root && !isTextField(target!) && handles.get(root)) || null;
editingCell = !!active && isCellEditor(target!);
update();
}
@@ -49,7 +55,10 @@ export function tableEditorChanged(handle: TableEditorHandle) {
if (active === handle) update();
}
/** The active editor, after confirming the cell being edited so its value counts. */
export function activeTableEditor(): TableEditorHandle | null {
const el = document.activeElement;
if (active && el instanceof HTMLElement && isCellEditor(el)) flushSync(() => el.blur());
return active;
}
@@ -58,6 +67,7 @@ export function installTableEditorFocus(): () => void {
return () => {
document.removeEventListener("focusin", onFocusIn);
active = null;
editingCell = false;
update();
};
}