diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index c49c9966f9..88a694fa15 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -15,7 +15,7 @@ let lastHandledCompressRequestId = 0; + + + + diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index d601e5eb0b..1899190612 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -1526,6 +1526,13 @@ export default withEnglishFallback({ confirm: "Təsdiqlə", selectFirst: "Siyahı mətnini seçin", }, + structurePeek: { + title: "Struktur: {name}", + hint: "Dəyəri redaktora daxil etmək üçün xanaya iki dəfə klikləyin", + doubleClickInsert: "Daxil etmək üçün iki dəfə klikləyin", + drag: "Yerini dəyişmək üçün sürükləyin", + empty: "Sütun yoxdur", + }, gotoLine: { placeholder: "Sətir nömrəsi", total: "{total} sətir", @@ -3342,6 +3349,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "Verilənlər bazalarına bax", viewData: "Məlumatlara bax", openInNewDataTab: "Yeni vərəqdə bax", + peekStructure: "Struktura bax", editStructure: "Strukturu redaktə et", editColumn: "Sütunu redaktə et", editIndex: "İndeksi redaktə et", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 83fec09002..150392dd25 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1741,6 +1741,13 @@ export default { confirm: "Confirm", selectFirst: "Please select list text", }, + structurePeek: { + title: "Structure: {name}", + hint: "Double-click a cell to insert its value into the editor", + doubleClickInsert: "Double-click to insert", + drag: "Drag to move", + empty: "No columns", + }, gotoLine: { placeholder: "Line number", total: "{total} lines", @@ -3576,6 +3583,7 @@ export default { openDatabaseBrowser: "Browse Databases", viewData: "View Data", openInNewDataTab: "View in New Tab", + peekStructure: "View Structure", editStructure: "Edit Structure", editColumn: "Edit Column", editIndex: "Edit Index", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d0a502b175..0bfe0991b4 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1740,6 +1740,13 @@ export default withEnglishFallback({ confirm: "Confirmar", selectFirst: "Seleccione el texto de la lista", }, + structurePeek: { + title: "Estructura: {name}", + hint: "Haz doble clic en una celda para insertar su valor en el editor", + doubleClickInsert: "Doble clic para insertar", + drag: "Arrastrar para mover", + empty: "Sin columnas", + }, connectionOnlyTarget: "Objetivo a nivel de conexión", previewChanges: "Previsualizar cambios", previewChangesComment: "Previsualización de cambios (simulación): sentencia {operation}", @@ -3470,6 +3477,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "Explorar bases de datos", viewData: "Ver datos", openInNewDataTab: "Ver en nueva pestaña", + peekStructure: "Ver estructura", editStructure: "Editar estructura", editColumn: "Editar columna", editIndex: "Editar índice", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 1821dfe5aa..f3cf5954f5 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1737,6 +1737,13 @@ export default withEnglishFallback({ confirm: "Conferma", selectFirst: "Seleziona il testo della lista", }, + structurePeek: { + title: "Struttura: {name}", + hint: "Fai doppio clic su una cella per inserirne il valore nell'editor", + doubleClickInsert: "Doppio clic per inserire", + drag: "Trascina per spostare", + empty: "Nessuna colonna", + }, connectionOnlyTarget: "Target a livello di connessione", previewChanges: "Anteprima modifiche", previewChangesComment: "Anteprima modifiche (prova a secco): istruzione {operation}", @@ -3467,6 +3474,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "Esplora database", viewData: "Visualizza Dati", openInNewDataTab: "Visualizza in nuova scheda", + peekStructure: "Visualizza struttura", editStructure: "Modifica Struttura", editColumn: "Modifica Colonna", editIndex: "Modifica Indice", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index ef71ed3916..c3a5a5e1ef 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1758,6 +1758,13 @@ export default withEnglishFallback({ confirm: "確定", selectFirst: "リストテキストを選択してください", }, + structurePeek: { + title: "構造: {name}", + hint: "セルをダブルクリックするとエディターに挿入されます", + doubleClickInsert: "ダブルクリックで挿入", + drag: "ドラッグして移動", + empty: "列がありません", + }, connectionOnlyTarget: "接続レベル目標", previewChanges: "変更をプレビュー", previewChangesComment: "変更プレビュー(ドライラン):{operation} ステートメント", @@ -3490,6 +3497,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "データベースを参照", viewData: "データを表示", openInNewDataTab: "新しいタブで表示", + peekStructure: "構造を表示", editStructure: "構造を編集", editColumn: "列を編集", editIndex: "インデックスを編集", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 52f7c841f5..e3b43a171c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -1622,6 +1622,13 @@ export default withEnglishFallback({ confirm: "확인", selectFirst: "목록 텍스트를 먼저 선택하세요", }, + structurePeek: { + title: "구조: {name}", + hint: "셀을 더블클릭하면 에디터에 삽입됩니다", + doubleClickInsert: "더블클릭하여 삽입", + drag: "드래그하여 이동", + empty: "열 없음", + }, gotoLine: { placeholder: "줄 번호", total: "총 {total}행", @@ -3369,6 +3376,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "데이터베이스 탐색", viewData: "데이터 보기", openInNewDataTab: "새 탭에서 보기", + peekStructure: "구조 보기", editStructure: "구조 편집", editColumn: "컬럼 편집", editIndex: "인덱스 편집", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index fe256e186c..afa81474bc 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1738,6 +1738,13 @@ export default withEnglishFallback({ confirm: "Confirmar", selectFirst: "Selecione o texto da lista primeiro", }, + structurePeek: { + title: "Estrutura: {name}", + hint: "Clique duas vezes em uma célula para inserir o valor no editor", + doubleClickInsert: "Clique duas vezes para inserir", + drag: "Arraste para mover", + empty: "Sem colunas", + }, connectionOnlyTarget: "Alvo em nível de conexão", previewChanges: "Pré-visualizar alterações", previewChangesComment: "Pré-visualização de alterações (simulação): instrução {operation}", @@ -3469,6 +3476,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "Explorar bancos de dados", viewData: "Ver Dados", openInNewDataTab: "Ver em nova aba", + peekStructure: "Ver estrutura", editStructure: "Editar Estrutura", editColumn: "Editar Coluna", editIndex: "Editar Índice", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 403dae0c82..51db87722e 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -1527,6 +1527,13 @@ export default withEnglishFallback({ confirm: "Onayla", selectFirst: "Lütfen liste metnini seçin", }, + structurePeek: { + title: "Yapı: {name}", + hint: "Bir hücreye çift tıklayarak değerini düzenleyiciye ekleyin", + doubleClickInsert: "Eklemek için çift tıklayın", + drag: "Taşımak için sürükleyin", + empty: "Sütun yok", + }, gotoLine: { placeholder: "Satır numarası", total: "{total} satır", @@ -3315,6 +3322,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "Veritabanlarına Gözat", viewData: "Verileri Görüntüle", openInNewDataTab: "Yeni Sekmede Görüntüle", + peekStructure: "Yapıyı Görüntüle", editStructure: "Yapıyı Düzenle", editColumn: "Sütunu Düzenle", editIndex: "Dizini Düzenle", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index d68f8fa0da..143a6a1345 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1655,6 +1655,13 @@ export default withEnglishFallback({ confirm: "确定", selectFirst: "请选择列表文本", }, + structurePeek: { + title: "表结构:{name}", + hint: "双击单元格可将内容插入到编辑器光标处", + doubleClickInsert: "双击插入到编辑器", + drag: "拖动面板", + empty: "暂无字段", + }, gotoLine: { placeholder: "行号", total: "共 {total} 行", @@ -3488,6 +3495,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "查看资源对象", viewData: "查看数据", openInNewDataTab: "新标签页查看", + peekStructure: "查看表结构", editStructure: "编辑表结构", editColumn: "编辑字段", editIndex: "编辑索引", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1bfdea40b0..e07fc7e1a4 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1739,6 +1739,13 @@ export default withEnglishFallback({ confirm: "確定", selectFirst: "請選擇清單文字", }, + structurePeek: { + title: "資料表結構:{name}", + hint: "連按兩下儲存格可將內容插入到編輯器游標處", + doubleClickInsert: "連按兩下插入到編輯器", + drag: "拖曳面板", + empty: "暫無欄位", + }, connectionOnlyTarget: "連線級目標", previewChanges: "預覽變更", previewChangesComment: "變更預覽(乾跑):{operation} 語句", @@ -3466,6 +3473,7 @@ export default withEnglishFallback({ openDatabaseBrowser: "檢視資源物件", viewData: "檢視資料", openInNewDataTab: "新分頁檢視", + peekStructure: "檢視資料表結構", editStructure: "編輯資料表結構", editColumn: "編輯欄位", editIndex: "編輯索引", diff --git a/apps/desktop/src/lib/__tests__/editor/focusedQueryEditorView.spec.ts b/apps/desktop/src/lib/__tests__/editor/focusedQueryEditorView.spec.ts new file mode 100644 index 0000000000..a073043f89 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/focusedQueryEditorView.spec.ts @@ -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(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/editor/structurePeekPanel.spec.ts b/apps/desktop/src/lib/__tests__/editor/structurePeekPanel.spec.ts new file mode 100644 index 0000000000..e6273e93b0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/editor/structurePeekPanel.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/lib/editor/focusedQueryEditorView.ts b/apps/desktop/src/lib/editor/focusedQueryEditorView.ts new file mode 100644 index 0000000000..cc66909f76 --- /dev/null +++ b/apps/desktop/src/lib/editor/focusedQueryEditorView.ts @@ -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(); + +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']"); +} diff --git a/apps/desktop/src/lib/editor/structurePeekPanel.ts b/apps/desktop/src/lib/editor/structurePeekPanel.ts new file mode 100644 index 0000000000..d349d6e3da --- /dev/null +++ b/apps/desktop/src/lib/editor/structurePeekPanel.ts @@ -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 = new Set(["e", "ne", "se"]); +const RESIZE_WEST: ReadonlySet = new Set(["w", "nw", "sw"]); +const RESIZE_NORTH: ReadonlySet = new Set(["n", "ne", "nw"]); +const RESIZE_SOUTH: ReadonlySet = 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); +} diff --git a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts index 3008956862..2a8ad849ba 100644 --- a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts +++ b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts @@ -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": diff --git a/packages/app-tests/queryCursorTableTarget.test.ts b/packages/app-tests/queryCursorTableTarget.test.ts index 9f10455d70..3f0cc96ee6 100644 --- a/packages/app-tests/queryCursorTableTarget.test.ts +++ b/packages/app-tests/queryCursorTableTarget.test.ts @@ -255,15 +255,15 @@ test("preserves table actions when context-menu metadata is unavailable", () => database: "app", schema: "public", }); - assert.deepEqual(queryContextObjectActions(undefined), ["view-data", "edit-table-structure", "view-ddl"]); + assert.deepEqual(queryContextObjectActions(undefined), ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"]); }); test("uses source actions for views and materialized views", () => { - const expected = ["view-data", "edit-view", "view-source", "view-ddl"]; + const expected = ["view-data", "peek-table-structure", "edit-view", "view-source", "view-ddl"]; assert.deepEqual(queryContextObjectActions("view"), expected); assert.deepEqual(queryContextObjectActions("materialized_view"), expected); - assert.deepEqual(queryContextObjectActions("table"), ["view-data", "edit-table-structure", "view-ddl"]); + assert.deepEqual(queryContextObjectActions("table"), ["view-data", "peek-table-structure", "edit-table-structure", "view-ddl"]); }); test("falls back to the candidate database and schema when no table is loaded", () => { diff --git a/packages/app-tests/queryEditorContextObjectRouting.test.ts b/packages/app-tests/queryEditorContextObjectRouting.test.ts index 500837b4f0..76e89a5827 100644 --- a/packages/app-tests/queryEditorContextObjectRouting.test.ts +++ b/packages/app-tests/queryEditorContextObjectRouting.test.ts @@ -9,6 +9,7 @@ const materializedViewTarget: SqlObjectNavigationTarget = { ...tableTarget, type test("routes table context actions with the resolved target payload", () => { assert.deepEqual(queryContextObjectRoute("view-data", tableTarget), { event: "viewTableData", payload: [tableTarget] }); + assert.deepEqual(queryContextObjectRoute("peek-table-structure", tableTarget), { event: "peekTableStructure", payload: [tableTarget] }); assert.deepEqual(queryContextObjectRoute("edit-table-structure", tableTarget), { event: "editTableStructure", payload: [tableTarget] }); assert.deepEqual(queryContextObjectRoute("view-ddl", tableTarget), { event: "viewTableDdl", payload: [tableTarget] }); }); @@ -20,6 +21,7 @@ test("routes view source actions with editing intent and type fidelity", () => { test("preserves materialized view type in every routed payload", () => { assert.deepEqual(queryContextObjectRoute("view-data", materializedViewTarget), { event: "viewTableData", payload: [materializedViewTarget] }); + assert.deepEqual(queryContextObjectRoute("peek-table-structure", materializedViewTarget), { event: "peekTableStructure", payload: [materializedViewTarget] }); assert.deepEqual(queryContextObjectRoute("edit-view", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, true] }); assert.deepEqual(queryContextObjectRoute("view-source", materializedViewTarget), { event: "openObjectSource", payload: [materializedViewTarget, false] }); assert.deepEqual(queryContextObjectRoute("view-ddl", materializedViewTarget), { event: "viewTableDdl", payload: [materializedViewTarget] });