diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index cf549dc73..7efed44ef 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch, sha import { CaseLower, CaseUpper, Code2, FileCode, Pencil, PencilRuler, Play, Copy, List, Search, Sparkles, Table2, TextSelect, Trash2 } from "@lucide/vue"; import { useI18n } from "vue-i18n"; import type { CompletionContext } from "@codemirror/autocomplete"; -import { Transaction } from "@codemirror/state"; +import { Transaction, StateEffect } from "@codemirror/state"; import type { EditorView as EditorViewType } from "@codemirror/view"; import { search as cmSearch } from "@codemirror/search"; import EditorSearchPanel from "./EditorSearchPanel.vue"; @@ -46,6 +46,7 @@ import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMon import { resolveSqlCompletionRoutineLookupTarget, resolveSqlCompletionTableLookupTarget } from "@/lib/sql/sqlCompletionLookupTarget"; import { usesOracleSessionCompletionColumns as shouldUseOracleSessionCompletionColumns } from "@/lib/sql/oracleCompletionSession"; import { extractIdentifierDetailsAt, isSqlKeyword, matchTable, mergeSqlObjectNavigationType, splitQualifiedIdentifier, sqlObjectHoverDetail, sqlObjectNavigationTarget, type SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; +import { buildHoverTableSql, hoverTableMatchesScope, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; import { lineColumnToOffset, parseSqlErrorLocation } from "@/lib/sql/sqlDiagnostics"; import { DBX_TABLE_REFERENCE_MIME, @@ -58,6 +59,7 @@ import { type QueryEditorTableReferenceDropDetail, type QueryEditorTableReferencePayload, } from "@/lib/editor/queryEditorTableDrop"; +import type { SqlHighlighter } from "@/lib/sql/sqlHighlighter"; import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, loadEditorTheme, editorFontTheme, sqlCompletionTheme, sqlSemanticHighlightTheme } from "@/lib/editor/editorThemes"; import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; @@ -76,6 +78,7 @@ import type { StatementExecutionMarker } from "@/lib/tabs/tabPresentation"; import { isSchemaAware, isSingleDatabase, supportsDatabaseSchemaQualifier, supportsSqlInListPaste } from "@/lib/database/databaseFeatureSupport"; import { metadataSchemaForConnection, sqlSnippetDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { usesLocalOnlyEditorCompletionMetadata, usesOnDemandOnlyEditorColumnMetadata } from "@/lib/metadata/completionMetadataPolicy"; +import { loadTableMetadata, type TableMetadataLoadResult } from "@/lib/metadata/tableMetadataCache"; import { queryContextObjectActions, queryContextObjectRoute, queryTableCandidateAtSqlPosition, resolveQueryContextCandidateDatabase, resolveQueryContextObjectTarget, type QueryContextObjectAction } from "@/lib/sql/queryCursorTableTarget"; import * as api from "@/lib/backend/api"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; @@ -94,7 +97,7 @@ import { sqlReferenceAnalysisDialectFor } from "@/lib/sql/semantic/dialect"; import { buildRedisSyntaxDiagnostics, shouldRunRedisDiagnostics } from "@/lib/redis/redisSyntaxDiagnostics"; import { buildRedisCompletionItemsFromContext, getRedisCompletionContext, getRedisCompletionResultValidFor, shouldAutoOpenRedisCompletion, takesKeyArgument, type RedisCompletionItem } from "@/lib/redis/redisCompletion"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionItem, SqlCompletionObject, SqlCompletionReferencedTable, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; -import type { CompletionAssistantObjectKind, DatabaseType, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; +import type { CompletionAssistantObjectKind, ColumnInfo, DatabaseType, IndexInfo, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database"; const props = defineProps<{ modelValue: string; @@ -150,6 +153,8 @@ const emit = defineEmits<{ const editorRef = ref(); const view = shallowRef(null); +const contextMenuOpen = ref(false); +let contextMenuPointerCleanup: (() => void) | null = null; let viewportEmitFrame: number | null = null; let viewportRestoreFrame: number | null = null; let latestViewport: { scrollTop: number; scrollLeft: number } | undefined = props.initialViewport; @@ -306,6 +311,7 @@ interface EditorGestureEvent extends Event { let editorViewModule: typeof import("@codemirror/view") | null = null; let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null; let codeMirrorEditorSelection: typeof import("@codemirror/state").EditorSelection | null = null; +let hoverCloseEffect: StateEffect | null = null; let fontThemeComp: import("@codemirror/state").Compartment | null = null; let codeMirrorTheme: import("@codemirror/state").Compartment | null = null; let wordWrapComp: import("@codemirror/state").Compartment | null = null; @@ -407,6 +413,10 @@ const cachedInsertValueHintColumnsByTable = new Map(); const cachedForeignKeysByTable = new Map(); const loadedColumnsByTable = new Set(); +// Hover tooltip uses the shared table metadata cache (loadTableMetadata) +// which provides TTL, invalidation, and in-flight deduplication. +let hoverSqlHighlighter: SqlHighlighter | null = null; + function sqlCompletionDialectOptions() { return { databaseType: props.databaseType, @@ -1717,7 +1727,7 @@ async function ensureForeignKeysForTables(tables: Array<{ name: string; database } } -function createHoverDom(title: string, detail: string, rows: string[] = []) { +function createHoverDom(title: string, detail: string, sqlContent?: string, rows: string[] = []) { const dom = document.createElement("div"); dom.className = "rounded-md border bg-popover px-3 py-2 text-xs text-popover-foreground shadow-md"; @@ -1731,6 +1741,24 @@ function createHoverDom(title: string, detail: string, rows: string[] = []) { detailNode.textContent = detail; dom.appendChild(detailNode); + if (sqlContent) { + const separator = document.createElement("div"); + separator.className = "mt-2 border-t border-border/60"; + dom.appendChild(separator); + + const sqlContainer = document.createElement("div"); + sqlContainer.className = "mt-1.5 max-h-64 overflow-y-auto text-[11px] leading-5 whitespace-pre font-mono"; + + if (hoverSqlHighlighter) { + sqlContainer.innerHTML = hoverSqlHighlighter(sqlContent, isDark.value ? "dark" : "light"); + } else { + sqlContainer.className += " text-muted-foreground"; + sqlContainer.textContent = sqlContent; + } + + dom.appendChild(sqlContainer); + } + for (const row of rows) { const rowNode = document.createElement("div"); rowNode.className = "mt-1 font-mono text-muted-foreground"; @@ -1777,7 +1805,7 @@ function createSignatureDom(signature: ReturnType 1 ? parts[parts.length - 2] : undefined; - const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions()) : null; + let semanticModel: ReturnType | null = null; + if (SEMANTIC_SQL_COMPLETION_ENABLED) { + try { + semanticModel = buildSqlSemanticModel(sql, pos, sqlCompletionDialectOptions()); + } catch (error) { + semanticModel = null; + console.warn(`[DBX] Failed to build semantic model for hover tooltip:`, error); + } + } const semanticTarget = semanticModel ? resolveSqlSemanticNavigationTarget(semanticModel, parts) : null; const semanticQualifierIsRowSource = !!qualifier && !!semanticTarget && (semanticTarget.alias?.toLowerCase() === qualifier.toLowerCase() || semanticTarget.source.name.toLowerCase() === qualifier.toLowerCase()); const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name; const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier; + const hoverTarget = completionMetadataTarget({ + name: tableLookupName, + catalog: props.catalog, + database: semanticTarget?.database, + schema: semanticTarget?.schema, + }); + if (!hoverTarget) return null; + const hoverScope: HoverTableScope = { + catalog: hoverTarget.catalog, + database: hoverTarget.database, + schema: hoverTarget.schema, + }; + try { - if (cachedTables.length === 0) { - cachedTables = usesLocalOnlyCompletionMetadata() - ? connectionStore.lookupLocalCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, props.catalog) - : await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog); + let hoverTables = cachedTables.filter((table) => hoverTableMatchesScope(table, hoverScope)); + if (hoverTables.length === 0) { + const loadedTables = usesLocalOnlyCompletionMetadata() + ? connectionStore.lookupLocalCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, hoverScope.catalog) + : await connectionStore.listCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, false, hoverScope.schema, hoverScope.catalog); + hoverTables = scopeHoverTables(loadedTables, hoverScope); + cachedTables = mergeCompletionTables(cachedTables, hoverTables); } - let table = matchTable(qualifiedTableLookup, cachedTables) ?? matchTable(tableLookupName, cachedTables) ?? matchTable(identifier, cachedTables) ?? matchTable(name, cachedTables); + let table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); if (!table && !usesLocalOnlyCompletionMetadata()) { - const hoverTables = await connectionStore.listCompletionTables(props.connectionId, props.database, tableLookupName, MAX_COMPLETION_TABLES, semanticTarget?.schema ?? props.schema, false, props.schema, props.catalog); - cachedTables = mergeCompletionTables(cachedTables, hoverTables); + const loadedTables = await connectionStore.listCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, false, hoverScope.schema, hoverScope.catalog); + const remoteHoverTables = scopeHoverTables(loadedTables, hoverScope); + hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables); + cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables); table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); } if (table && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) { + const hoverDatabase = hoverScope.database; + const hoverSchema = hoverScope.schema ?? table.schema ?? ""; + const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join("."); + let sqlContent: string | undefined; + let metadataLoadFailed = false; + + // Primary path: the backend's raw getTableDdl (SHOW CREATE TABLE, pg_ddl, + // build_sqlserver_ddl, ...) is authoritative. Parse it into structured + // fields and rebuild with vertical field alignment (name, type, extra, + // default, nullable, comment), stripping charset/COLLATE noise. + try { + const rawDdl = await api.getTableDdl(props.connectionId, hoverDatabase, hoverSchema, table.name, undefined, hoverScope.catalog); + if (rawDdl && rawDdl.trim()) { + sqlContent = reformatHoverDdl(rawDdl, quoteQualifiedName(hoverQualifiedName)); + } + } catch (error) { + console.warn(`[DBX] Failed to load table DDL for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); + } + + // Fallback path: rebuild the DDL from cached table metadata when the + // backend DDL is unavailable (empty result or request failure). + if (!sqlContent) { + let fullColumns: ColumnInfo[] = []; + let fullIndexes: IndexInfo[] = []; + let tableComment: string | undefined; + try { + const result: TableMetadataLoadResult = await loadTableMetadata({ + connectionId: props.connectionId, + database: hoverDatabase, + schema: hoverSchema, + tableName: table.name, + databaseType: props.databaseType ?? "", + catalog: hoverScope.catalog, + }); + fullColumns = result.metadata.columns; + fullIndexes = result.metadata.indexes; + } catch (error) { + metadataLoadFailed = true; + console.warn(`[DBX] Failed to load table metadata for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); + } + if (!metadataLoadFailed) { + try { + const commentResult = await api.getTableComment(props.connectionId, hoverDatabase, hoverSchema, table.name, hoverScope.catalog); + if (commentResult) tableComment = commentResult; + } catch (error) { + console.warn(`[DBX] Failed to load table comment for ${hoverDatabase}.${hoverSchema}.${table.name}:`, error); + } + } + if (fullColumns.length > 0) { + sqlContent = buildHoverTableSql(quoteQualifiedName(hoverQualifiedName), fullColumns, fullIndexes, tableComment); + metadataLoadFailed = false; + } + } + // Re-check after async metadata load — the context menu may have opened + // while the DDL request was in flight, and we must not display a hover + // tooltip on top of an open context menu. + if (contextMenuOpen.value) return null; return { pos: range.from, end: range.to, create: () => ({ - dom: createHoverDom(table.name, sqlObjectHoverDetail(table)), + dom: createHoverDom(table.name, sqlObjectHoverDetail(table), sqlContent, metadataLoadFailed ? ["[DBX] Failed to load table structure — check connection"] : undefined), }), }; } @@ -1837,7 +1948,7 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) pos: range.from, end: range.to, create: () => ({ - dom: createHoverDom(column.name, column.dataType || "column", [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]), + dom: createHoverDom(column.name, column.dataType || "column", undefined, [column.schema ? `${column.schema}.${column.table}` : column.table, ...(column.comment?.trim() ? [column.comment.trim()] : [])]), }), }; } @@ -3246,8 +3357,20 @@ function refreshCompletionCache() { onMounted(async () => { if (!editorRef.value) return; + // Pre-load SQL highlighter for hover tooltips (non-blocking) + void (async () => { + try { + const { createShikiSqlHighlighter } = await import("@/lib/sql/sqlHighlighter"); + hoverSqlHighlighter = await createShikiSqlHighlighter({ + appearance: () => (isDark.value ? "dark" : "light"), + }); + } catch { + // Highlighter unavailable; hover falls back to plain text + } + })(); + const [ - { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin }, + { EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin }, { EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField }, langSql, { autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField }, @@ -3260,6 +3383,7 @@ onMounted(async () => { keymap, rectangularSelection, } as typeof import("@codemirror/view"); + hoverCloseEffect = closeHoverTooltips; codeMirrorPrec = Prec; codeMirrorEditorSelection = EditorSelection; codeMirrorSnippetCompletion = snippetCompletion; @@ -3988,6 +4112,21 @@ onMounted(async () => { view.value.scrollDOM.addEventListener("scroll", scheduleEditorViewportEmit, { passive: true, }); + + // Register context-menu scroll listener on the actual EditorView scrollDOM + // (deferred until after creation so view.value is non-null). + const scrollDOM = view.value.scrollDOM; + const onEditorScroll = () => { + if (contextMenuOpen.value) { + contextMenuOpen.value = false; + } + }; + scrollDOM.addEventListener("scroll", onEditorScroll); + contextMenuPointerCleanup = () => { + scrollDOM.removeEventListener("scroll", onEditorScroll); + contextMenuPointerCleanup = null; + }; + restoreEditorViewport(); syncContextMenuState(view.value); syncEditorFontCssVars(liveFontSize.value, initialSettings.fontFamily); @@ -4254,6 +4393,7 @@ onBeforeUnmount(() => { view.value?.scrollDOM.removeEventListener("scroll", scheduleEditorViewportEmit); window.removeEventListener("keyup", clearTableNavigationHoverOnModifierRelease); window.removeEventListener("blur", clearTableNavigationHover); + contextMenuPointerCleanup?.(); zoomCommitScheduler.dispose(); view.value?.destroy(); }); @@ -4384,6 +4524,11 @@ function scrollCursorIntoView() { }); } +function closeHoverOnContextMenu() { + if (!view.value || !hoverCloseEffect) return; + view.value.dispatch({ effects: hoverCloseEffect }); +} + defineExpose({ openSearch, openReplace, @@ -4398,15 +4543,19 @@ defineExpose({