From becb9a09c027c2384b645d173af0a661291ca96f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:42:45 +0800 Subject: [PATCH] feat(database): go to a column of the table editor with File Structure's shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘F12 / Ctrl+F12 in a focused table editor did nothing. It now opens Go to Column: the table's columns with their types and key icons, filtered as you type (prefix matches first); Enter selects that column's cell in the current row and scrolls the grid to it. File Structure in text editors is unchanged. The grid now also scrolls sideways when arrow keys or the search bar's current match move to a column outside the view. --- src/features/database/ColumnsPopup.test.ts | 16 ++++ src/features/database/ColumnsPopup.tsx | 92 ++++++++++++++++++++++ src/features/database/DataView.tsx | 2 + src/features/database/Database.module.css | 19 +++++ src/features/database/Grid.tsx | 25 ++++++ src/features/database/TableEditor.tsx | 27 ++++++- src/features/database/index.ts | 8 ++ src/features/database/tableEditorFocus.ts | 5 +- src/platform/keymaps/eclipse-macos.ts | 1 + src/platform/keymaps/eclipse-windows.ts | 1 + src/platform/keymaps/eclipse.test.ts | 1 + src/platform/keymaps/intellij-macos.ts | 1 + src/platform/keymaps/intellij-windows.ts | 1 + src/platform/keymaps/vscode-macos.ts | 1 + src/platform/locales/text/zh.json | 4 + 15 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 src/features/database/ColumnsPopup.test.ts create mode 100644 src/features/database/ColumnsPopup.tsx diff --git a/src/features/database/ColumnsPopup.test.ts b/src/features/database/ColumnsPopup.test.ts new file mode 100644 index 000000000..42f9f8c6d --- /dev/null +++ b/src/features/database/ColumnsPopup.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { filterColumns } from "./ColumnsPopup"; + +const columns = ["id", "user_id", "name", "created_at", "username"].map((name) => ({ name, type: "TEXT" })); + +describe("filterColumns", () => { + it("lists every column for an empty query", () => { + expect(filterColumns(columns, " ")).toEqual([0, 1, 2, 3, 4]); + }); + + it("puts prefix matches first, then other matches, ignoring case", () => { + expect(filterColumns(columns, "US")).toEqual([1, 4]); + expect(filterColumns(columns, "name")).toEqual([2, 4]); + expect(filterColumns(columns, "_id")).toEqual([1]); + }); +}); diff --git a/src/features/database/ColumnsPopup.tsx b/src/features/database/ColumnsPopup.tsx new file mode 100644 index 000000000..ad19b7785 --- /dev/null +++ b/src/features/database/ColumnsPopup.tsx @@ -0,0 +1,92 @@ +/** + * Go to Column in a table editor (⌘F12 / Ctrl+F12): the grid's columns, filtered as you type; + * Enter selects that column's cell in the current row. + */ +import { useState } from "react"; +import { Icon, Input, List, Popup, listNavigate } from "@/ui"; +import { tx } from "@/platform/i18n"; +import type { GridColumn } from "./Grid"; +import styles from "./Database.module.css"; + +export function filterColumns(columns: readonly GridColumn[], query: string): number[] { + const q = query.trim().toLowerCase(); + const all = columns.map((_, i) => i); + if (!q) return all; + const starts = all.filter((i) => columns[i]!.name.toLowerCase().startsWith(q)); + const contains = all.filter((i) => !starts.includes(i) && columns[i]!.name.toLowerCase().includes(q)); + return [...starts, ...contains]; +} + +export function ColumnsPopup(props: { + anchor: { x: number; y: number }; + table: string; + columns: readonly GridColumn[]; + onPick: (col: number) => void; + onClose: () => void; +}) { + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + const shown = filterColumns(props.columns, query); + const current = Math.min(active, shown.length - 1); + const pick = (i: number) => { + const col = shown[i]; + if (col !== undefined) props.onPick(col); + }; + return ( + + { + setQuery(e.target.value); + setActive(0); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + pick(current); + return; + } + const next = listNavigate(e.key, { items: shown, active: current }); + if (next !== null) { + e.preventDefault(); + setActive(next); + } + }} + /> + String(col)} + activeIndex={current} + onActiveChange={setActive} + onActivate={(_, i) => pick(i)} + focusable={false} + hoverSelects + rowHeight={22} + aria-label={tx("Columns")} + className={styles.columnsList} + empty={{tx("No matching columns")}} + renderItem={(col) => { + const c = props.columns[col]!; + return ( + + + {c.name} + {c.type && c.type !== "NULL" && {c.type}} + + ); + }} + /> + + ); +} diff --git a/src/features/database/DataView.tsx b/src/features/database/DataView.tsx index 530637f1b..ecad794d2 100644 --- a/src/features/database/DataView.tsx +++ b/src/features/database/DataView.tsx @@ -90,6 +90,7 @@ export interface DataViewProps extends Pick< /** The search bar is shown (the toolbar's search button, ⌘F in the grid). */ searchOpen?: boolean; onSearchOpenChange?: (open: boolean) => void; + revealColumn?: GridProps["revealColumn"]; "aria-label"?: string; } @@ -200,6 +201,7 @@ export function DataView(props: DataViewProps) { isMatch={test ? (r, c) => matchSet.has(`${r}:${c}`) : undefined} currentMatch={currentMatch} onFind={onSearchOpenChange && openSearch} + revealColumn={props.revealColumn} aria-label={props["aria-label"]} columns={columns} rowCount={shown} diff --git a/src/features/database/Database.module.css b/src/features/database/Database.module.css index 599d77da6..4d59b7dcd 100644 --- a/src/features/database/Database.module.css +++ b/src/features/database/Database.module.css @@ -823,6 +823,25 @@ font: inherit; cursor: pointer; } +/* Go to Column (ColumnsPopup.tsx). */ +.columnsPopup { + display: flex; + flex-direction: column; + gap: 6px; + width: 320px; + padding: 8px; +} +.columnsList { + height: min(320px, 50vh); +} +.columnsRow { + display: flex; + flex: 1; + align-items: center; + gap: 6px; + min-width: 0; + white-space: nowrap; +} .filterPopup { display: flex; flex-direction: column; diff --git a/src/features/database/Grid.tsx b/src/features/database/Grid.tsx index 981020541..ee3800e04 100644 --- a/src/features/database/Grid.tsx +++ b/src/features/database/Grid.tsx @@ -57,6 +57,8 @@ export interface GridProps { currentMatch?: { row: number; col: number } | null; /** ⌘F / Ctrl+F in the grid (opens the search bar). */ onFind?: () => void; + /** Go to Column: a new object selects that column's cell in the current row, scrolls to it and focuses the grid. */ + revealColumn?: { col: number } | null; "aria-label"?: string; } @@ -160,15 +162,37 @@ export function Grid(props: GridProps) { estimateSize: () => ROW_H, overscan: 12, }); + /** Scrolls sideways so column `c` is visible (the first one with the row numbers). */ + const scrollColumnIntoView = (c: number) => { + const el = scrollRef.current; + if (!el || c < 0 || c >= widths.length) return; + const left = c ? NUM_W + widths.slice(0, c).reduce((a, b) => a + b, 0) : 0; + const right = NUM_W + widths.slice(0, c + 1).reduce((a, b) => a + b, 0); + if (left < el.scrollLeft) el.scrollLeft = left; + else if (right > el.scrollLeft + el.clientWidth) el.scrollLeft = Math.min(left, right - el.clientWidth); + }; const { currentMatch } = props; useEffect(() => { if (!currentMatch) return; const { row, col } = currentMatch; setSel({ r0: row, c0: col, r1: row, c1: col }); virtualizer.scrollToIndex(row, { align: "auto" }); + scrollColumnIntoView(col); // Follows the match only, not the virtualizer's identity. // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentMatch?.row, currentMatch?.col]); + const { revealColumn } = props; + useEffect(() => { + if (!revealColumn || !rowCount) return; + const { col } = revealColumn; + const row = sel ? Math.min(sel.r1, rowCount - 1) : 0; + setSel({ r0: row, c0: col, r1: row, c1: col }); + virtualizer.scrollToIndex(row, { align: "auto" }); + scrollColumnIntoView(col); + scrollRef.current?.focus({ preventScroll: true }); + // Runs once per request. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revealColumn]); const totalWidth = useMemo(() => NUM_W + widths.reduce((a, b) => a + b, 0), [widths]); const template = `${NUM_W}px ${widths.map((w) => `${w}px`).join(" ")}`; @@ -255,6 +279,7 @@ export function Grid(props: GridProps) { const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])); setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c }); virtualizer.scrollToIndex(r, { align: "auto" }); + scrollColumnIntoView(c); }; const onCellMouseDown = (e: MouseEvent, r: number, c: number) => { diff --git a/src/features/database/TableEditor.tsx b/src/features/database/TableEditor.tsx index adc560afd..c1a2773ce 100644 --- a/src/features/database/TableEditor.tsx +++ b/src/features/database/TableEditor.tsx @@ -16,6 +16,7 @@ import { import { Button, EmptyState, Icon, IconButton, Select, Spinner, T, showContextMenu } from "@/ui"; import { notifications, type EditorInput } from "@/workbench/contributions"; import { SqlFilterInput } from "./SqlFilterInput"; +import { ColumnsPopup } from "./ColumnsPopup"; import { DataView, ExtractorSelect, PanelToggle, exportMenu, type DataPanel } from "./DataView"; import type { ExportFormat } from "./export"; import { @@ -182,12 +183,23 @@ export function TableEditor({ input }: { input: EditorInput }) { const dirty = hasEdits(edits); // Submit / Revert actions (⌘↩ / ⌥⌘Z) reach the latest state through the handle. const viewRef = useRef(null); - const handle = useRef({ dirty: false, submit: () => {}, revert: () => {} }).current; + const handle = useRef({ + dirty: false, + submit: () => {}, + revert: () => {}, + showColumns: () => {}, + }).current; handle.dirty = editable && dirty; handle.submit = () => { if (editable && dirty) void submit(); }; handle.revert = () => setEdits(emptyEdits()); + const [columnsAt, setColumnsAt] = useState<{ x: number; y: number } | null>(null); + const [revealColumn, setRevealColumn] = useState<{ col: number } | null>(null); + handle.showColumns = () => { + const r = viewRef.current?.getBoundingClientRect(); + if (page && r) setColumnsAt({ x: r.left + r.width / 2, y: r.top + 48 }); + }; useEffect(() => registerTableEditor(viewRef.current!, handle), [handle]); useEffect(() => tableEditorChanged(handle), [handle, dirty, editable]); const orderInvalid = orderDraft !== null && !parseOrderBy(orderDraft, names); @@ -339,6 +351,7 @@ export function TableEditor({ input }: { input: EditorInput }) { columns={gridColumns} rowCount={rowCount} fixedRows={loaded} + revealColumn={revealColumn} value={value} panel={panel} onPanelChange={setPanel} @@ -366,6 +379,18 @@ export function TableEditor({ input }: { input: EditorInput }) { /> )} + {columnsAt && ( + { + setColumnsAt(null); + setRevealColumn({ col }); + }} + onClose={() => setColumnsAt(null)} + /> + )} ); } diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 43843071b..9e6980cff 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -154,6 +154,14 @@ export function activate(): () => void { await executeInView(e.view, e.ctx.path); }, }, + { + id: "db.goToColumn", + title: "Go to Column", + category: "Tools", + keywords: ["table editor", "file structure", "columns"], + when: "dbTableEditorFocus", + run: () => activeTableEditor()?.showColumns(), + }, { id: "db.submitChanges", title: "Submit", diff --git a/src/features/database/tableEditorFocus.ts b/src/features/database/tableEditorFocus.ts index d6358630f..22bdb5bf5 100644 --- a/src/features/database/tableEditorFocus.ts +++ b/src/features/database/tableEditorFocus.ts @@ -1,6 +1,6 @@ /** - * 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). + * 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). */ import { setContext } from "@/platform/context"; @@ -8,6 +8,7 @@ export interface TableEditorHandle { dirty: boolean; submit(): void; revert(): void; + showColumns(): void; } const handles = new WeakMap(); diff --git a/src/platform/keymaps/eclipse-macos.ts b/src/platform/keymaps/eclipse-macos.ts index 64fde8a23..bc89ab0b7 100644 --- a/src/platform/keymaps/eclipse-macos.ts +++ b/src/platform/keymaps/eclipse-macos.ts @@ -50,6 +50,7 @@ export const eclipseMacos: Record = { "code.expandToLevel5": [], "refactor.extractMethod": ["Meta+Alt+M"], "navigate.fileStructure": ["Meta+O"], + "db.goToColumn": ["Meta+O"], "find.inFile": [], "find.inPath": ["Ctrl+H"], "find.next": ["Meta+K"], diff --git a/src/platform/keymaps/eclipse-windows.ts b/src/platform/keymaps/eclipse-windows.ts index b346e0dd8..abc425fe3 100644 --- a/src/platform/keymaps/eclipse-windows.ts +++ b/src/platform/keymaps/eclipse-windows.ts @@ -52,6 +52,7 @@ export const eclipseWindows: Record = { "code.expandToLevel5": [], "refactor.extractMethod": ["Shift+Alt+M"], "navigate.fileStructure": ["Ctrl+F3", "Ctrl+O"], + "db.goToColumn": ["Ctrl+F3", "Ctrl+O"], "find.inFile": [], "find.inPath": ["Ctrl+H"], "find.next": ["Ctrl+K"], diff --git a/src/platform/keymaps/eclipse.test.ts b/src/platform/keymaps/eclipse.test.ts index e5bbd7b91..59aa3517f 100644 --- a/src/platform/keymaps/eclipse.test.ts +++ b/src/platform/keymaps/eclipse.test.ts @@ -41,6 +41,7 @@ const shared = [ "find.popup.openResult", ], ["navigate.typeHierarchy", "db.editData"], + ["navigate.fileStructure", "db.goToColumn"], ["find.replaceInFile", "agents.findInConversation", "vcs.log.goToRef"], ["find.next", "find.popup.nextResult"], ["find.previous", "find.popup.previousResult"], diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index fae455fef..6a3db68ea 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -353,6 +353,7 @@ export const intellijMacos: Record = { "http.runAll": [], "db.execute": ["Meta+Enter"], // Table editor with pending changes (after `vcs.rollback`, so ⌥⌘Z reverts the table's edits there). + "db.goToColumn": ["Meta+F12"], "db.submitChanges": ["Meta+Enter"], "db.revertChanges": ["Meta+Alt+Z"], "db.showToolWindow": [], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index b433d6a7d..bc4b04347 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -351,6 +351,7 @@ export const intellijWindows: Record = { "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.goToColumn": ["Ctrl+F12"], "db.submitChanges": ["Ctrl+Enter"], "db.revertChanges": ["Ctrl+Alt+Z"], "db.showToolWindow": [], diff --git a/src/platform/keymaps/vscode-macos.ts b/src/platform/keymaps/vscode-macos.ts index 883cc29fd..5bb0e0998 100644 --- a/src/platform/keymaps/vscode-macos.ts +++ b/src/platform/keymaps/vscode-macos.ts @@ -22,6 +22,7 @@ export const vscodeMacos: Record = { "navigate.implementation": ["Meta+F12"], "navigate.typeDeclaration": [], "navigate.fileStructure": ["Meta+Shift+O"], + "db.goToColumn": ["Meta+Shift+O"], "navigate.relatedSymbol": [], "navigate.nextError": ["F8"], "navigate.prevError": ["Shift+F8"], diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a7a8d7011..bb312d53c 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -3744,6 +3744,10 @@ "Clear local filter": "清除本地筛选", "Search values": "搜索值", "No matching values": "没有匹配的值", + "Go to Column": "转到列", + "Columns of {table}": "{table} 的列", + "Search columns": "搜索列", + "No matching columns": "没有匹配的列", "Clear Filter": "清除筛选", "Clear Local Filter For All Columns": "清除所有列的本地筛选", "Value panel": "值面板",