From e324f35f280e1d0a849e6a0b694d08b64f0d39c8 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 30 May 2026 09:31:07 +0800 Subject: [PATCH] feat(editor): support dragging tables into queries --- .../src/components/editor/QueryEditor.vue | 44 ++++++++++- .../src/components/layout/ContentArea.vue | 1 + .../src/components/objects/ObjectBrowser.vue | 2 + .../src/components/sidebar/TreeItem.vue | 31 ++++++++ apps/desktop/src/lib/queryEditorTableDrop.ts | 74 ++++++++++++++++++ .../app-tests/queryEditorTableDrop.test.ts | 78 +++++++++++++++++++ 6 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/queryEditorTableDrop.ts create mode 100644 packages/app-tests/queryEditorTableDrop.test.ts diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 7ab8b7ea2a..3a6751a0f7 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -24,6 +24,11 @@ import { } from "@/lib/sqlCompletion"; import { extractIdentifierAt, isSqlKeyword, matchTable } from "@/lib/sqlNavigation"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sqlDiagnostics"; +import { + DBX_TABLE_REFERENCE_MIME, + parseTableReferencePayload, + tableReferenceInsertText, +} from "@/lib/queryEditorTableDrop"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, @@ -47,13 +52,14 @@ import { type SqlSemanticDiagnostic, } from "@/lib/sqlSemanticDiagnostics"; import type { SqlCompletionColumn } from "@/lib/sqlCompletion"; -import type { SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; +import type { DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; import { vscodeSelectionLayer } from "@/lib/codemirrorVscodeSelectionLayer"; const props = defineProps<{ modelValue: string; connectionId?: string; database?: string; + databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver"; formatDialect?: SqlFormatDialect; formatRequestId?: number; @@ -715,6 +721,33 @@ async function formatCurrentSql() { } } +function droppedTableReference(event: DragEvent) { + return parseTableReferencePayload(event.dataTransfer?.getData(DBX_TABLE_REFERENCE_MIME)); +} + +function insertDroppedTableReference(currentView: EditorViewType, event: DragEvent): boolean { + if (props.readOnly) return false; + const payload = droppedTableReference(event); + if (!payload) return false; + + event.preventDefault(); + event.stopPropagation(); + + const insertText = tableReferenceInsertText(payload, props.databaseType); + const dropPos = currentView.posAtCoords({ x: event.clientX, y: event.clientY }); + const selection = currentView.state.selection.main; + const from = dropPos ?? selection.from; + const to = dropPos == null && !selection.empty ? selection.to : from; + currentView.dispatch({ + changes: { from, to, insert: insertText }, + selection: { anchor: from + insertText.length }, + scrollIntoView: true, + userEvent: "input.drop", + }); + currentView.focus(); + return true; +} + let completionEpoch = 0; let completionDebounceTimer: ReturnType | null = null; @@ -1249,6 +1282,15 @@ onMounted(async () => { }), ), EditorView.domEventHandlers({ + dragover(event) { + if (props.readOnly || !droppedTableReference(event)) return false; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + return true; + }, + drop(event, currentView) { + return insertDroppedTableReference(currentView, event); + }, wheel(event) { if (!event.metaKey && !event.ctrlKey) return false; event.preventDefault(); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index b2427ee19b..8075f1d5ca 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -293,6 +293,7 @@ defineExpose({ focusSearch, refreshData, handleModRTarget }); :model-value="activeTab.sql" :connection-id="activeTab.connectionId" :database="activeTab.database" + :database-type="activeConnection?.db_type" :dialect="editorDialect" :format-dialect="activeSqlFormatDialect" :format-request-id="formatSqlRequestId" diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 4cd43e537c..def8628786 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -1391,6 +1391,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { class="min-h-0 flex-1" :connection-id="props.connection.id" :database="props.database" + :database-type="props.connection.db_type" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" force-word-wrap @@ -1407,6 +1408,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] { class="min-h-0 flex-1" :connection-id="props.connection.id" :database="props.database" + :database-type="props.connection.db_type" :dialect="sourceDialect" :format-dialect="sourceFormatDialect" force-word-wrap diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 96be5e9736..9fb9fe888b 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -58,6 +58,12 @@ import { uuid } from "@/lib/utils"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; import { canTreeNodeShowExpander, treeItemPaddingLeft } from "@/lib/sidebarTreeItemLayout"; import { buildTableSelectSql } from "@/lib/tableSelectSql"; +import { + DBX_TABLE_REFERENCE_MIME, + createTableReferencePayload, + serializeTableReferencePayload, + tableReferenceInsertText, +} from "@/lib/queryEditorTableDrop"; import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing"; import { supportsDatabaseCreation, @@ -1792,6 +1798,29 @@ const showDropInside = computed( () => dragState.active && dragState.targetId === props.node.id && dragState.dropPosition === "inside", ); const isDragging = computed(() => dragState.active && dragState.draggedId === props.node.id); +const canDragTableReference = computed( + () => + !props.dragDisabled && + (props.node.type === "table" || props.node.type === "view") && + !!props.node.connectionId && + !!props.node.database, +); + +function onTableReferenceDragStart(event: DragEvent) { + if (!canDragTableReference.value) return; + const payload = createTableReferencePayload({ + connectionId: props.node.connectionId, + database: props.node.database, + schema: props.node.schema, + tableName: props.node.label, + databaseType: currentDatabaseType(), + }); + if (!payload || !event.dataTransfer) return; + const insertText = tableReferenceInsertText(payload, currentDatabaseType()); + event.dataTransfer.effectAllowed = "copy"; + event.dataTransfer.setData(DBX_TABLE_REFERENCE_MIME, serializeTableReferencePayload(payload)); + event.dataTransfer.setData("text/plain", insertText); +} // ---- CustomContextMenu ---- @@ -2110,12 +2139,14 @@ function treeItemMenuItems(): ContextMenuItem[] { }" :tabindex="isSelected ? 0 : -1" :style="rowStyle" + :draggable="canDragTableReference" @click="onClick" @dblclick="onDoubleClick" @keydown="onKeydown" @mousedown="isDraggable ? startDrag($event, node.id, node.type) : undefined" @mousemove="isDropTarget ? updateTarget($event, node.id, node.type) : undefined" @mouseleave="clearTarget(node.id)" + @dragstart="onTableReferenceDragStart" >
; + if ( + parsed.kind !== "dbx-table-reference" || + typeof parsed.connectionId !== "string" || + typeof parsed.database !== "string" || + typeof parsed.tableName !== "string" || + !parsed.connectionId || + !parsed.database || + !parsed.tableName + ) { + return null; + } + return { + kind: "dbx-table-reference", + connectionId: parsed.connectionId, + database: parsed.database, + schema: typeof parsed.schema === "string" && parsed.schema ? parsed.schema : undefined, + tableName: parsed.tableName, + databaseType: parsed.databaseType, + }; + } catch { + return null; + } +} + +export function tableReferenceInsertText( + payload: QueryEditorTableReferencePayload, + fallbackDatabaseType?: DatabaseType, +): string { + return qualifiedTableName({ + databaseType: payload.databaseType ?? fallbackDatabaseType, + schema: payload.schema, + tableName: payload.tableName, + }); +} diff --git a/packages/app-tests/queryEditorTableDrop.test.ts b/packages/app-tests/queryEditorTableDrop.test.ts new file mode 100644 index 0000000000..68e1cabdc0 --- /dev/null +++ b/packages/app-tests/queryEditorTableDrop.test.ts @@ -0,0 +1,78 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { + createTableReferencePayload, + parseTableReferencePayload, + serializeTableReferencePayload, + tableReferenceInsertText, +} from "../../apps/desktop/src/lib/queryEditorTableDrop.ts"; + +test("creates table drag payload only when table context is complete", () => { + assert.equal(createTableReferencePayload({ connectionId: "c1", database: "db" }), null); + assert.deepEqual( + createTableReferencePayload({ + connectionId: "c1", + database: "db", + schema: "public", + tableName: "very long table", + databaseType: "postgres", + }), + { + kind: "dbx-table-reference", + connectionId: "c1", + database: "db", + schema: "public", + tableName: "very long table", + databaseType: "postgres", + }, + ); +}); + +test("round trips table drag payload and rejects unrelated data", () => { + const payload = createTableReferencePayload({ + connectionId: "c1", + database: "db", + tableName: "orders", + databaseType: "mysql", + }); + assert.ok(payload); + assert.deepEqual(parseTableReferencePayload(serializeTableReferencePayload(payload)), payload); + assert.equal(parseTableReferencePayload("not json"), null); + assert.equal(parseTableReferencePayload(JSON.stringify({ kind: "dbx-table-reference", tableName: "orders" })), null); +}); + +test("formats dropped table reference for the source database type", () => { + assert.equal( + tableReferenceInsertText({ + kind: "dbx-table-reference", + connectionId: "c1", + database: "db", + schema: "sales", + tableName: "customer order", + databaseType: "postgres", + }), + '"sales"."customer order"', + ); + assert.equal( + tableReferenceInsertText({ + kind: "dbx-table-reference", + connectionId: "c1", + database: "db", + schema: "dbo", + tableName: "Order Detail", + databaseType: "sqlserver", + }), + "[dbo].[Order Detail]", + ); + assert.equal( + tableReferenceInsertText({ + kind: "dbx-table-reference", + connectionId: "c1", + database: "db", + schema: "ignored", + tableName: "order-detail", + databaseType: "mysql", + }), + "`order-detail`", + ); +});