дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
e5c250aad8
коміт
becb9a09c0
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
Заблокувати користувача