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