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": "值面板",