feat(editor): add table structure peek and dblclick insert
This commit is contained in:
1 parent
da95bd435f
commit
12ed7a49b3
19 files changed
+1021
-8
No files matched your search
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
STRUCTURE_PEEK_PANEL_SELECTOR,
|
||||
clearRememberedFocusedQueryEditorView,
|
||||
focusedQueryEditorView,
|
||||
queryEditorInsertContext,
|
||||
registerQueryEditorInsertContext,
|
||||
rememberFocusedQueryEditorView,
|
||||
shouldPreserveEditorFocusOnPeekPointerDown,
|
||||
unregisterQueryEditorInsertContext,
|
||||
} from "@/lib/editor/focusedQueryEditorView";
|
||||
|
||||
function mountEditor(): { root: HTMLElement; view: EditorView } {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-query-editor-root", "");
|
||||
document.body.appendChild(root);
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({ doc: "select 1" }),
|
||||
parent: root,
|
||||
});
|
||||
return { root, view };
|
||||
}
|
||||
|
||||
describe("shouldPreserveEditorFocusOnPeekPointerDown", () => {
|
||||
it("preserves focus for table / chrome clicks", () => {
|
||||
const td = document.createElement("td");
|
||||
expect(shouldPreserveEditorFocusOnPeekPointerDown(td)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows native focus for editable controls", () => {
|
||||
const input = document.createElement("input");
|
||||
expect(shouldPreserveEditorFocusOnPeekPointerDown(input)).toBe(false);
|
||||
|
||||
const button = document.createElement("button");
|
||||
expect(shouldPreserveEditorFocusOnPeekPointerDown(button)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("focusedQueryEditorView", () => {
|
||||
afterEach(() => {
|
||||
clearRememberedFocusedQueryEditorView();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("returns the focused query editor under activeElement", () => {
|
||||
const { root, view } = mountEditor();
|
||||
view.focus();
|
||||
expect(focusedQueryEditorView(view.contentDOM)).toBe(view);
|
||||
expect(focusedQueryEditorView(root)).toBe(view);
|
||||
});
|
||||
|
||||
it("falls back to the last focused editor when activeElement is inside a peek panel", () => {
|
||||
const { view } = mountEditor();
|
||||
rememberFocusedQueryEditorView(view);
|
||||
|
||||
const peek = document.createElement("div");
|
||||
peek.setAttribute("data-structure-peek-panel", "");
|
||||
const input = document.createElement("input");
|
||||
peek.appendChild(input);
|
||||
document.body.appendChild(peek);
|
||||
|
||||
expect(input.closest(STRUCTURE_PEEK_PANEL_SELECTOR)).toBe(peek);
|
||||
expect(focusedQueryEditorView(input)).toBe(view);
|
||||
});
|
||||
|
||||
it("returns null when focus is outside editors and peek panels", () => {
|
||||
const { view } = mountEditor();
|
||||
rememberFocusedQueryEditorView(view);
|
||||
|
||||
const sidebar = document.createElement("button");
|
||||
document.body.appendChild(sidebar);
|
||||
expect(focusedQueryEditorView(sidebar)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a remembered editor that was disconnected", () => {
|
||||
const { root, view } = mountEditor();
|
||||
rememberFocusedQueryEditorView(view);
|
||||
root.remove();
|
||||
|
||||
const peek = document.createElement("div");
|
||||
peek.setAttribute("data-structure-peek-panel", "");
|
||||
document.body.appendChild(peek);
|
||||
expect(focusedQueryEditorView(peek)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryEditorInsertContext", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("stores and clears per-view insert context", () => {
|
||||
const { view } = mountEditor();
|
||||
registerQueryEditorInsertContext(view, { connectionId: "c1", database: "db", databaseType: "postgres" });
|
||||
expect(queryEditorInsertContext(view)).toEqual({ connectionId: "c1", database: "db", databaseType: "postgres" });
|
||||
unregisterQueryEditorInsertContext(view);
|
||||
expect(queryEditorInsertContext(view)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { STRUCTURE_PEEK_MIN_HEIGHT, STRUCTURE_PEEK_MIN_WIDTH, clampStructurePeekRect, resizeStructurePeekRect, structurePeekPanelId } from "@/lib/editor/structurePeekPanel";
|
||||
|
||||
describe("structurePeekPanel", () => {
|
||||
it("builds a stable case-insensitive identity including catalog", () => {
|
||||
expect(structurePeekPanelId("Conn", "Db", "Public", "Orders", "Cat")).toBe(structurePeekPanelId("conn", "db", "public", "orders", "cat"));
|
||||
expect(structurePeekPanelId("c", "db", "a", "t")).not.toBe(structurePeekPanelId("c", "db", "b", "t"));
|
||||
expect(structurePeekPanelId("c", "db", "s", "t", "cat-a")).not.toBe(structurePeekPanelId("c", "db", "s", "t", "cat-b"));
|
||||
});
|
||||
|
||||
it("clamps rects inside the viewport with a margin", () => {
|
||||
expect(clampStructurePeekRect({ left: -40, top: -20, width: 500, height: 400 }, 800, 600)).toEqual({
|
||||
left: 8,
|
||||
top: 8,
|
||||
width: 500,
|
||||
height: 400,
|
||||
});
|
||||
expect(clampStructurePeekRect({ left: 700, top: 500, width: 500, height: 400 }, 800, 600)).toEqual({
|
||||
left: 800 - 8 - 500,
|
||||
top: 600 - 8 - 400,
|
||||
width: 500,
|
||||
height: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces minimum size while resizing from the west/north edges", () => {
|
||||
const origin = { left: 100, top: 100, width: 400, height: 300 };
|
||||
const shrunkWest = resizeStructurePeekRect(origin, "w", 200, 0, 1200, 800);
|
||||
expect(shrunkWest.width).toBe(STRUCTURE_PEEK_MIN_WIDTH);
|
||||
expect(shrunkWest.left).toBe(100 + 400 - STRUCTURE_PEEK_MIN_WIDTH);
|
||||
|
||||
const shrunkNorth = resizeStructurePeekRect(origin, "n", 0, 200, 1200, 800);
|
||||
expect(shrunkNorth.height).toBe(STRUCTURE_PEEK_MIN_HEIGHT);
|
||||
expect(shrunkNorth.top).toBe(100 + 300 - STRUCTURE_PEEK_MIN_HEIGHT);
|
||||
});
|
||||
|
||||
it("resizes northeast corners without treating n as containing e", () => {
|
||||
const origin = { left: 100, top: 100, width: 400, height: 300 };
|
||||
const grown = resizeStructurePeekRect(origin, "ne", 50, -40, 1200, 800);
|
||||
expect(grown.width).toBe(450);
|
||||
expect(grown.height).toBe(340);
|
||||
expect(grown.left).toBe(100);
|
||||
expect(grown.top).toBe(60);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
|
||||
export const QUERY_EDITOR_ROOT_SELECTOR = "[data-query-editor-root]";
|
||||
export const STRUCTURE_PEEK_PANEL_SELECTOR = "[data-structure-peek-panel]";
|
||||
|
||||
/** Dialect / connection metadata for inserting into a specific query editor view. */
|
||||
export interface QueryEditorInsertContext {
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
databaseType?: DatabaseType;
|
||||
}
|
||||
|
||||
let lastFocusedQueryEditorView: EditorView | null = null;
|
||||
const insertContextByView = new WeakMap<EditorView, QueryEditorInsertContext>();
|
||||
|
||||
export function rememberFocusedQueryEditorView(view: EditorView | null | undefined): void {
|
||||
if (view) lastFocusedQueryEditorView = view;
|
||||
}
|
||||
|
||||
/** Clear the remembered view when it is destroyed (or clear unconditionally). */
|
||||
export function clearRememberedFocusedQueryEditorView(view?: EditorView | null): void {
|
||||
if (view == null || lastFocusedQueryEditorView === view) {
|
||||
lastFocusedQueryEditorView = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerQueryEditorInsertContext(view: EditorView, context: QueryEditorInsertContext): void {
|
||||
insertContextByView.set(view, context);
|
||||
}
|
||||
|
||||
export function unregisterQueryEditorInsertContext(view: EditorView): void {
|
||||
insertContextByView.delete(view);
|
||||
}
|
||||
|
||||
export function queryEditorInsertContext(view: EditorView): QueryEditorInsertContext | undefined {
|
||||
return insertContextByView.get(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CodeMirror view that should receive peek inserts.
|
||||
* - Focus inside a query editor → that view (must still report hasFocus).
|
||||
* - Focus inside a structure peek panel → most recently focused query editor.
|
||||
* - Focus elsewhere (sidebar, etc.) → null.
|
||||
*/
|
||||
export function focusedQueryEditorView(active: Element | null = typeof document !== "undefined" ? document.activeElement : null): EditorView | null {
|
||||
if (!(active instanceof Element)) return null;
|
||||
|
||||
const root = active.closest(QUERY_EDITOR_ROOT_SELECTOR);
|
||||
if (root instanceof HTMLElement) {
|
||||
const view = EditorView.findFromDOM(root);
|
||||
if (!view?.hasFocus) return null;
|
||||
rememberFocusedQueryEditorView(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
if (active.closest(STRUCTURE_PEEK_PANEL_SELECTOR)) {
|
||||
const remembered = lastFocusedQueryEditorView;
|
||||
if (remembered?.dom.isConnected) return remembered;
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Keep editor focus when interacting with non-editable peek chrome (palette-style). */
|
||||
export function shouldPreserveEditorFocusOnPeekPointerDown(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return !target.closest("input, textarea, button, select, a, [contenteditable='true']");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Identity + geometry helpers for in-app table-structure peek panels. */
|
||||
|
||||
export function structurePeekPanelId(connectionId: string, database: string, schema: string | undefined, tableName: string, catalog?: string): string {
|
||||
return [connectionId, catalog ?? "", database, schema ?? "", tableName].map((part) => part.toLocaleLowerCase()).join("\u0000");
|
||||
}
|
||||
|
||||
export const STRUCTURE_PEEK_MARGIN = 8;
|
||||
export const STRUCTURE_PEEK_MIN_WIDTH = 360;
|
||||
export const STRUCTURE_PEEK_MIN_HEIGHT = 240;
|
||||
export const STRUCTURE_PEEK_CASCADE = 24;
|
||||
|
||||
export function defaultStructurePeekSize(viewportWidth: number, viewportHeight: number): { width: number; height: number } {
|
||||
const maxWidth = Math.max(STRUCTURE_PEEK_MIN_WIDTH, viewportWidth - STRUCTURE_PEEK_MARGIN * 2);
|
||||
const maxHeight = Math.max(STRUCTURE_PEEK_MIN_HEIGHT, viewportHeight - STRUCTURE_PEEK_MARGIN * 2);
|
||||
// Default large enough to scan columns comfortably without covering most of the editor.
|
||||
return {
|
||||
width: Math.min(720, Math.floor(viewportWidth * 0.62), maxWidth),
|
||||
height: Math.min(520, Math.floor(viewportHeight * 0.62), maxHeight),
|
||||
};
|
||||
}
|
||||
|
||||
export function centeredStructurePeekRect(width: number, height: number, viewportWidth: number, viewportHeight: number, cascadeIndex = 0): StructurePeekRect {
|
||||
const stagger = cascadeIndex * STRUCTURE_PEEK_CASCADE;
|
||||
return clampStructurePeekRect(
|
||||
{
|
||||
left: Math.round((viewportWidth - width) / 2) + stagger,
|
||||
top: Math.round((viewportHeight - height) / 2) + stagger,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
export interface StructurePeekRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Keep the panel fully inside the viewport (with margin), respecting min size. */
|
||||
export function clampStructurePeekRect(rect: StructurePeekRect, viewportWidth: number, viewportHeight: number, margin = STRUCTURE_PEEK_MARGIN, minWidth = STRUCTURE_PEEK_MIN_WIDTH, minHeight = STRUCTURE_PEEK_MIN_HEIGHT): StructurePeekRect {
|
||||
const maxWidth = Math.max(minWidth, viewportWidth - margin * 2);
|
||||
const maxHeight = Math.max(minHeight, viewportHeight - margin * 2);
|
||||
const width = Math.min(Math.max(rect.width, minWidth), maxWidth);
|
||||
const height = Math.min(Math.max(rect.height, minHeight), maxHeight);
|
||||
const maxLeft = Math.max(margin, viewportWidth - margin - width);
|
||||
const maxTop = Math.max(margin, viewportHeight - margin - height);
|
||||
const left = Math.min(Math.max(rect.left, margin), maxLeft);
|
||||
const top = Math.min(Math.max(rect.top, margin), maxTop);
|
||||
return { left, top, width, height };
|
||||
}
|
||||
|
||||
export type StructurePeekResizeEdge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
|
||||
|
||||
const RESIZE_EAST: ReadonlySet<StructurePeekResizeEdge> = new Set(["e", "ne", "se"]);
|
||||
const RESIZE_WEST: ReadonlySet<StructurePeekResizeEdge> = new Set(["w", "nw", "sw"]);
|
||||
const RESIZE_NORTH: ReadonlySet<StructurePeekResizeEdge> = new Set(["n", "ne", "nw"]);
|
||||
const RESIZE_SOUTH: ReadonlySet<StructurePeekResizeEdge> = new Set(["s", "se", "sw"]);
|
||||
|
||||
export function resizeStructurePeekRect(origin: StructurePeekRect, edge: StructurePeekResizeEdge, deltaX: number, deltaY: number, viewportWidth: number, viewportHeight: number): StructurePeekRect {
|
||||
let { left, top, width, height } = origin;
|
||||
|
||||
if (RESIZE_EAST.has(edge)) width = origin.width + deltaX;
|
||||
if (RESIZE_SOUTH.has(edge)) height = origin.height + deltaY;
|
||||
if (RESIZE_WEST.has(edge)) {
|
||||
width = origin.width - deltaX;
|
||||
left = origin.left + deltaX;
|
||||
}
|
||||
if (RESIZE_NORTH.has(edge)) {
|
||||
height = origin.height - deltaY;
|
||||
top = origin.top + deltaY;
|
||||
}
|
||||
|
||||
// When shrinking past min from the west/north edge, pin the opposite side.
|
||||
if (RESIZE_WEST.has(edge) && width < STRUCTURE_PEEK_MIN_WIDTH) {
|
||||
left = origin.left + origin.width - STRUCTURE_PEEK_MIN_WIDTH;
|
||||
width = STRUCTURE_PEEK_MIN_WIDTH;
|
||||
}
|
||||
if (RESIZE_NORTH.has(edge) && height < STRUCTURE_PEEK_MIN_HEIGHT) {
|
||||
top = origin.top + origin.height - STRUCTURE_PEEK_MIN_HEIGHT;
|
||||
height = STRUCTURE_PEEK_MIN_HEIGHT;
|
||||
}
|
||||
|
||||
return clampStructurePeekRect({ left, top, width, height }, viewportWidth, viewportHeight);
|
||||
}
|
||||
@@ -21,10 +21,11 @@ export interface QueryTableCandidateAtPositionInput {
|
||||
position: number;
|
||||
}
|
||||
|
||||
export type QueryContextObjectAction = "view-data" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
|
||||
export type QueryContextObjectAction = "view-data" | "peek-table-structure" | "edit-table-structure" | "edit-view" | "view-source" | "view-ddl";
|
||||
|
||||
export type QueryContextObjectRoute =
|
||||
| { event: "viewTableData"; payload: [target: SqlObjectNavigationTarget] }
|
||||
| { event: "peekTableStructure"; payload: [target: SqlObjectNavigationTarget] }
|
||||
| { event: "editTableStructure"; payload: [target: SqlObjectNavigationTarget] }
|
||||
| { event: "openObjectSource"; payload: [target: SqlObjectNavigationTarget, initialEditing: boolean] }
|
||||
| { event: "viewTableDdl"; payload: [target: SqlObjectNavigationTarget] };
|
||||
@@ -110,16 +111,18 @@ export function resolveQueryContextObjectTarget(candidate: QueryCursorTableCandi
|
||||
|
||||
export function queryContextObjectActions(type?: SqlObjectNavigationType): QueryContextObjectAction[] {
|
||||
if (type === "view" || type === "materialized_view") {
|
||||
return ["view-data", "edit-view", "view-source", "view-ddl"];
|
||||
return ["view-data", "peek-table-structure", "edit-view", "view-source", "view-ddl"];
|
||||
}
|
||||
// Unknown metadata preserves the historical table actions instead of disabling existing entry points.
|
||||
return ["view-data", "edit-table-structure", "view-ddl"];
|
||||
return ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"];
|
||||
}
|
||||
|
||||
export function queryContextObjectRoute(action: QueryContextObjectAction, target: SqlObjectNavigationTarget): QueryContextObjectRoute {
|
||||
switch (action) {
|
||||
case "view-data":
|
||||
return { event: "viewTableData", payload: [target] };
|
||||
case "peek-table-structure":
|
||||
return { event: "peekTableStructure", payload: [target] };
|
||||
case "edit-table-structure":
|
||||
return { event: "editTableStructure", payload: [target] };
|
||||
case "edit-view":
|
||||
|
||||
Reference in new issue
Block a user