дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
e5c250aad8
коміт
7a715758a6
15 files changed
+201
-3
No files matched your search
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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": "值面板",
|
||||
|
||||
Reference in new issue
Заблокувати користувача