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

⌘F12 / Ctrl+F12 in a focused table editor now opens Go to Column: the table's columns with types and key icons, filtered as you type; Enter selects that column's cell in the current row and scrolls to it. The grid also scrolls sideways when arrow keys or the search bar's current match move to a column outside the view.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-06 00:43:01 +08:00
1 джерело e5c250aad8
коміт 7a715758a6
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": "值面板",