feat(database): go to a column of the table editor with File Structure's shortcut

⌘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.
Цей коміт міститься в:
l0ng-ai committed 2026-11-06 00:42:45 +08:00
1 джерело e5c250aad8
коміт becb9a09c0
15 files changed
+201 -3

No files matched your search

+16
Переглянути файл
@@ -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]);
});
});
+92
Переглянути файл
@@ -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 (
<Popup
open
anchor={props.anchor}
placement="bottom"
onClose={props.onClose}
aria-label={tx`Columns of ${props.table}`}
className={styles.columnsPopup}
trapFocus
>
<Input
autoFocus
aria-label={tx("Search columns")}
placeholder={tx`Columns of ${props.table}`}
value={query}
onChange={(e) => {
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);
}
}}
/>
<List
items={shown}
getKey={(col) => String(col)}
activeIndex={current}
onActiveChange={setActive}
onActivate={(_, i) => pick(i)}
focusable={false}
hoverSelects
rowHeight={22}
aria-label={tx("Columns")}
className={styles.columnsList}
empty={<span>{tx("No matching columns")}</span>}
renderItem={(col) => {
const c = props.columns[col]!;
return (
<span className={styles.columnsRow}>
<Icon name={c.primaryKey ? "db-column-key" : c.foreignKey ? "db-column-fk" : "db-column"} size={16} />
{c.name}
{c.type && c.type !== "NULL" && <span className={styles.treeType}>{c.type}</span>}
</span>
);
}}
/>
</Popup>
);
}
+2
Переглянути файл
@@ -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}
+19
Переглянути файл
@@ -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;
+25
Переглянути файл
@@ -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) => {
+26 -1
Переглянути файл
@@ -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<HTMLDivElement>(null);
const handle = useRef<TableEditorHandle>({ dirty: false, submit: () => {}, revert: () => {} }).current;
const handle = useRef<TableEditorHandle>({
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 }) {
/>
</div>
)}
{columnsAt && (
<ColumnsPopup
anchor={columnsAt}
table={ref.table}
columns={gridColumns}
onPick={(col) => {
setColumnsAt(null);
setRevealColumn({ col });
}}
onClose={() => setColumnsAt(null)}
/>
)}
</div>
);
}
+8
Переглянути файл
@@ -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",
+3 -2
Переглянути файл
@@ -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<Element, TableEditorHandle>();
+1
Переглянути файл
@@ -50,6 +50,7 @@ export const eclipseMacos: Record<string, string[]> = {
"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"],
+1
Переглянути файл
@@ -52,6 +52,7 @@ export const eclipseWindows: Record<string, string[]> = {
"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"],
+1
Переглянути файл
@@ -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"],
+1
Переглянути файл
@@ -353,6 +353,7 @@ export const intellijMacos: Record<string, string[]> = {
"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": [],
+1
Переглянути файл
@@ -351,6 +351,7 @@ export const intellijWindows: Record<string, string[]> = {
"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": [],
+1
Переглянути файл
@@ -22,6 +22,7 @@ export const vscodeMacos: Record<string, string[]> = {
"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"],
+4
Переглянути файл
@@ -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": "值面板",