From 254b9baaff95289bdff401133e2c29b4a6449956 Mon Sep 17 00:00:00 2001 From: Mo-Moore <33098900+Mo-Moore@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:32:45 +0800 Subject: [PATCH] feat(editor): resolve table hover across schemas in current database --- .../editor/EditorSettingsDialog.vue | 40 +++ .../src/components/editor/QueryEditor.vue | 157 +++++++++-- apps/desktop/src/i18n/locales/az.ts | 8 + apps/desktop/src/i18n/locales/en.ts | 8 + apps/desktop/src/i18n/locales/es.ts | 8 + apps/desktop/src/i18n/locales/it.ts | 8 + apps/desktop/src/i18n/locales/ja.ts | 8 + apps/desktop/src/i18n/locales/ko.ts | 8 + apps/desktop/src/i18n/locales/pt-BR.ts | 8 + apps/desktop/src/i18n/locales/tr.ts | 8 + apps/desktop/src/i18n/locales/zh-CN.ts | 8 + apps/desktop/src/i18n/locales/zh-TW.ts | 8 + .../editor/__tests__/hoverTableLookup.spec.ts | 261 ++++++++++++++++++ .../src/lib/editor/hoverTableLookup.ts | 130 +++++++++ .../__tests__/editorSettingsDraft.spec.ts | 4 + .../src/lib/settings/editorSettingsDraft.ts | 3 + .../src/lib/settings/settingsSearch.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 1 + apps/desktop/src/lib/sql/sqlNavigation.ts | 2 +- .../stores/__tests__/settingsStore.spec.ts | 18 ++ apps/desktop/src/stores/settingsStore.ts | 7 +- 21 files changed, 675 insertions(+), 29 deletions(-) create mode 100644 apps/desktop/src/lib/editor/__tests__/hoverTableLookup.spec.ts create mode 100644 apps/desktop/src/lib/editor/hoverTableLookup.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index d2a6e201a4..90f271b701 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -103,6 +103,7 @@ import { type ClickTableNavigationTarget, type EditorSettings, type SqlCompletionTriggerMode, + type TableHoverLookupMode, SIDEBAR_INDENT_MIN, SIDEBAR_INDENT_MAX, SIDEBAR_FONT_SIZE_MIN, @@ -657,6 +658,15 @@ const editGenerateSqlIncludeDatabaseName = ref(settingsStore.editorSettings.gene const editGenerateSqlQuoteIdentifiers = ref(settingsStore.editorSettings.generateSqlQuoteIdentifiers); const editFormatSqlOnSqlFileSave = ref(settingsStore.editorSettings.formatSqlOnSqlFileSave); const editShowTableDdlHoverPreview = ref(settingsStore.editorSettings.showTableDdlHoverPreview); +const editTableHoverLookupMode = ref(settingsStore.editorSettings.tableHoverLookupMode); +const tableHoverLookupModeDescription = computed(() => { + const key = { + current: "settings.tableHoverLookupModeCurrentDescription", + fallback: "settings.tableHoverLookupModeFallbackDescription", + always: "settings.tableHoverLookupModeAlwaysDescription", + }[editTableHoverLookupMode.value]; + return t(key); +}); const editClickTableNavigationTarget = ref(settingsStore.editorSettings.clickTableNavigationTarget); const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled); const editAutoDownloadUpdates = ref(settingsStore.editorSettings.autoDownloadUpdates); @@ -816,6 +826,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { generateSqlQuoteIdentifiers: editGenerateSqlQuoteIdentifiers.value, formatSqlOnSqlFileSave: editFormatSqlOnSqlFileSave.value, showTableDdlHoverPreview: editShowTableDdlHoverPreview.value, + tableHoverLookupMode: editTableHoverLookupMode.value, updateNotificationsEnabled: editUpdateNotificationsEnabled.value, autoDownloadUpdates: editAutoDownloadUpdates.value, sidebarObjectInfoMode: editSidebarObjectInfoMode.value, @@ -1341,6 +1352,7 @@ function syncEditorSettingsDraftFromStore() { editGenerateSqlQuoteIdentifiers.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers; editFormatSqlOnSqlFileSave.value = settingsStore.editorSettings.formatSqlOnSqlFileSave; editShowTableDdlHoverPreview.value = settingsStore.editorSettings.showTableDdlHoverPreview; + editTableHoverLookupMode.value = settingsStore.editorSettings.tableHoverLookupMode; editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget; editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled; editAutoDownloadUpdates.value = settingsStore.editorSettings.autoDownloadUpdates; @@ -1458,6 +1470,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { generateSqlQuoteIdentifiers: editGenerateSqlQuoteIdentifiers, formatSqlOnSqlFileSave: editFormatSqlOnSqlFileSave, showTableDdlHoverPreview: editShowTableDdlHoverPreview, + tableHoverLookupMode: editTableHoverLookupMode, updateNotificationsEnabled: editUpdateNotificationsEnabled, autoDownloadUpdates: editAutoDownloadUpdates, sidebarObjectInfoMode: editSidebarObjectInfoMode, @@ -1842,6 +1855,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editAppCloseUnsavedTabsMode.value = DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode; editSavedSqlOpenTargetMode.value = DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode; editShowTableDdlHoverPreview.value = DEFAULT_EDITOR_SETTINGS.showTableDdlHoverPreview; + editTableHoverLookupMode.value = DEFAULT_EDITOR_SETTINGS.tableHoverLookupMode; editExternalSqlEditorMaxMb.value = DEFAULT_EDITOR_SETTINGS.externalSqlEditorMaxMb; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editSqlVariableSubstitutionEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlVariableSubstitutionEnabled; @@ -2034,6 +2048,7 @@ function resetAllDefaults() { editGenerateSqlQuoteIdentifiers.value = DEFAULT_EDITOR_SETTINGS.generateSqlQuoteIdentifiers; editFormatSqlOnSqlFileSave.value = DEFAULT_EDITOR_SETTINGS.formatSqlOnSqlFileSave; editShowTableDdlHoverPreview.value = DEFAULT_EDITOR_SETTINGS.showTableDdlHoverPreview; + editTableHoverLookupMode.value = DEFAULT_EDITOR_SETTINGS.tableHoverLookupMode; editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled; editAutoDownloadUpdates.value = DEFAULT_EDITOR_SETTINGS.autoDownloadUpdates; editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode; @@ -2197,6 +2212,12 @@ function onCompletionTriggerModeChange(v: any) { } } +function onTableHoverLookupModeChange(v: any) { + if (v === "current" || v === "fallback" || v === "always") { + editTableHoverLookupMode.value = v; + } +} + function onSqlSemanticDiagnosticsEnabledChange(value: boolean) { editSqlSemanticDiagnosticsEnabled.value = value; editSqlSemanticDiagnosticsMode.value = value ? "enabled" : "disabled"; @@ -5605,6 +5626,25 @@ onUnmounted(() => { +
+
+ +

+ {{ tableHoverLookupModeDescription }} +

+
+ +
+
diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 6483ebd2fb..461e32aac6 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -128,6 +128,7 @@ import { type SqlObjectNavigationTarget, } from "@/lib/sql/sqlNavigation"; import { buildHoverTableSql, ddlForHoverPreview, hoverTableMatchesScope, normalizeAlignedSqlWhitespace, quoteIdentifier, quoteQualifiedName, reformatHoverDdl, scopeHoverTables, type HoverTableScope } from "@/lib/editor/hoverTableSql"; +import { matchHoverTableCandidates, resolveHoverTableLookupTarget } from "@/lib/editor/hoverTableLookup"; import { constrainSqlHoverLayout } from "@/lib/editor/sqlHoverLayout"; import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlHoverSearch"; import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics"; @@ -3176,11 +3177,24 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) const tableLookupName = semanticTarget && !semanticQualifierIsRowSource ? semanticTarget.name : name; const qualifiedTableLookup = semanticTarget?.schema ? `${semanticTarget.schema}.${semanticTarget.name}` : identifier; - const hoverTarget = completionMetadataTarget({ - name: tableLookupName, + const lookup = resolveHoverTableLookupTarget({ + database: props.database, + schema: props.schema, catalog: props.catalog, - database: semanticTarget?.database, - schema: semanticTarget?.schema, + databaseType: props.databaseType, + tableName: tableLookupName, + // Alias.column must not be treated as schema.table; keep the bare column/table token. + identifierParts: semanticQualifierIsRowSource ? [tableLookupName] : parts, + semanticDatabase: semanticQualifierIsRowSource ? undefined : semanticTarget?.database, + semanticSchema: semanticQualifierIsRowSource ? undefined : semanticTarget?.schema, + mode: settingsStore.editorSettings.tableHoverLookupMode, + }); + + const hoverTarget = completionMetadataTarget({ + name: lookup.tableName, + catalog: props.catalog, + database: lookup.database !== props.database ? lookup.database : undefined, + schema: lookup.schema, }); if (!hoverTarget) return null; const hoverScope: HoverTableScope = { @@ -3188,27 +3202,73 @@ async function resolveSqlHoverTooltip(currentView: EditorViewType, pos: number) database: hoverTarget.database, schema: hoverTarget.schema, }; + const preferredSchema = props.schema; + const matchHoverTable = (tables: typeof cachedTables) => + matchHoverTableCandidates(tables, { + lookups: [qualifiedTableLookup, identifier], + tableName: tableLookupName, + preferredSchema, + }); try { - let hoverTables = cachedTables.filter((table) => hoverTableMatchesScope(table, hoverScope)); - let table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); - if (!table) { - const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, hoverScope.schema, hoverScope.catalog); - const localHoverTables = scopeHoverTables(localTables, hoverScope); + const searchLocal = (schema?: string) => { + const scopeForFilter: HoverTableScope = { ...hoverScope, schema }; + const localTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, hoverScope.catalog); + const localHoverTables = schema ? scopeHoverTables(localTables, scopeForFilter) : scopeHoverTables(localTables, { ...hoverScope, schema: undefined }); + return localHoverTables; + }; + const searchRemote = async (schema: string | undefined, globalSearch: boolean) => { + const loadedTables = await connectionStore.listCompletionTables(props.connectionId!, hoverScope.database, tableLookupName, MAX_COMPLETION_TABLES, schema, globalSearch, preferredSchema, hoverScope.catalog); + return schema ? scopeHoverTables(loadedTables, { ...hoverScope, schema }) : loadedTables; + }; + + let hoverTables = cachedTables.filter((table) => (lookup.preferGlobalFirst || !hoverScope.schema ? hoverTableMatchesScope(table, { ...hoverScope, schema: undefined }) : hoverTableMatchesScope(table, hoverScope))); + let table = matchHoverTable(hoverTables); + + if (!table && !lookup.preferGlobalFirst) { + const localHoverTables = searchLocal(hoverScope.schema); hoverTables = mergeCompletionTables(localHoverTables, hoverTables); cachedTables = mergeCompletionTables(localHoverTables, cachedTables); - table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); + table = matchHoverTable(hoverTables); } - if (!table && !usesLocalOnlyCompletionMetadata()) { - 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); + + if (!table && lookup.preferGlobalFirst) { + const localHoverTables = searchLocal(undefined); + hoverTables = mergeCompletionTables(localHoverTables, hoverTables); + cachedTables = mergeCompletionTables(localHoverTables, cachedTables); + table = matchHoverTable(hoverTables); + } + + if (!table && !usesLocalOnlyCompletionMetadata() && !lookup.preferGlobalFirst) { + const remoteHoverTables = await searchRemote(hoverScope.schema, false); hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables); cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables); - table = matchTable(qualifiedTableLookup, hoverTables) ?? matchTable(tableLookupName, hoverTables) ?? matchTable(identifier, hoverTables) ?? matchTable(name, hoverTables); + table = matchHoverTable(hoverTables); } + + if (!table && !usesLocalOnlyCompletionMetadata() && lookup.preferGlobalFirst) { + const remoteHoverTables = await searchRemote(undefined, true); + hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables); + cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables); + table = matchHoverTable(hoverTables); + } + + if (!table && lookup.allowGlobalFallback && !lookup.preferGlobalFirst) { + const localHoverTables = searchLocal(undefined); + hoverTables = mergeCompletionTables(localHoverTables, hoverTables); + cachedTables = mergeCompletionTables(localHoverTables, cachedTables); + table = matchHoverTable(hoverTables); + if (!table && !usesLocalOnlyCompletionMetadata()) { + const remoteHoverTables = await searchRemote(undefined, true); + hoverTables = mergeCompletionTables(hoverTables, remoteHoverTables); + cachedTables = mergeCompletionTables(cachedTables, remoteHoverTables); + table = matchHoverTable(hoverTables); + } + } + if (table && settingsStore.editorSettings.showTableDdlHoverPreview && !semanticQualifierIsRowSource && (!qualifier || table.schema?.toLowerCase() === qualifier.toLowerCase() || table.name === name)) { - const hoverDatabase = hoverScope.database; - const hoverSchema = hoverScope.schema ?? table.schema ?? ""; + const hoverDatabase = table.database ?? hoverScope.database; + const hoverSchema = table.schema ?? hoverScope.schema ?? ""; const hoverQualifiedName = [hoverScope.catalog, hoverDatabase, hoverSchema, table.name].filter(Boolean).join("."); const objectMetadataRequest = { connectionId: props.connectionId, @@ -6851,6 +6911,22 @@ onMounted(async () => { const objectSchemaHint = identity.parts.length >= 3 ? identity.schema : identity.parts.length === 1 ? props.schema : undefined; const isRoutineCall = identity.role === "routine_call"; const isRelationColumnList = identity.role === "relation_column_list"; + const tableLookup = resolveHoverTableLookupTarget({ + database: props.database!, + schema: props.schema, + catalog: props.catalog, + databaseType: props.databaseType, + tableName: tableLookupFilter, + identifierParts, + mode: settingsStore.editorSettings.tableHoverLookupMode, + }); + const tableLookupSchema = tableLookup.preferGlobalFirst ? undefined : (tableLookup.schema ?? props.schema); + const matchNavigationTable = (tables: typeof cachedTables) => + matchHoverTableCandidates(tables, { + lookups: [identifier], + tableName: tableLookupFilter, + preferredSchema: props.schema, + }); const relationNavigationTarget = (target: SqlObjectNavigationTarget) => queryTableNavigationTargetAtSqlPosition( { @@ -6864,17 +6940,26 @@ onMounted(async () => { target, ); - // 1. Local table cache (sync). Relation column lists always prefer tables over routines. - if (cachedTables.length === 0) { - cachedTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, props.catalog); - } - - let matchedTable = matchTable(identifier, cachedTables); + // 1. Local table lookup with the resolved scope (do not trust stale editor cache alone — + // completion/hover may have filled cachedTables with the current schema only). + const localScopedTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, props.catalog); + cachedTables = mergeCompletionTables(localScopedTables, cachedTables); + let matchedTable = matchNavigationTable(localScopedTables); if (matchedTable) { emit("clickTable", relationNavigationTarget(matchedTable)); return; } + if (!matchedTable && tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) { + const localGlobalTables = connectionStore.lookupLocalCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, props.catalog); + cachedTables = mergeCompletionTables(cachedTables, localGlobalTables); + matchedTable = matchNavigationTable(localGlobalTables); + if (matchedTable) { + emit("clickTable", relationNavigationTarget(matchedTable)); + return; + } + } + const preserveOracleStoreCase = (value?: string) => !!value && value !== value.toUpperCase(); const openMatchedObject = (matchedObject: { name: string; schema?: string; type: string; signature?: string; parentName?: string; parentSchema?: string }) => { const navigationType = sqlObjectNavigationTypeFromCompletionObjectType(matchedObject.type); @@ -6929,21 +7014,39 @@ onMounted(async () => { // Routine-call sites may still hit this when a table and procedure share a name and // local caches were empty; table wins only if listed as a relation. if (!usesLocalOnlyCompletionMetadata() && (!isRoutineCall || isRelationColumnList || identity.role === "unknown")) { - cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, MAX_COMPLETION_TABLES, props.schema, false, props.schema, props.catalog); - matchedTable = matchTable(identifier, cachedTables); + cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog); + matchedTable = matchNavigationTable(cachedTables); if (matchedTable) { emit("clickTable", relationNavigationTarget(matchedTable)); return; } + if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) { + const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, MAX_COMPLETION_TABLES, undefined, true, props.schema, props.catalog); + cachedTables = mergeCompletionTables(cachedTables, globalTables); + matchedTable = matchNavigationTable(cachedTables); + if (matchedTable) { + emit("clickTable", relationNavigationTarget(matchedTable)); + return; + } + } } else if (!usesLocalOnlyCompletionMetadata() && isRoutineCall) { // Lightweight table check so INSERT INTO ORDERS(…) is not the only guarded path — - // still avoid global scans: only session-scoped prefix lookup. - cachedTables = await connectionStore.listCompletionTables(props.connectionId!, props.database!, tableLookupFilter, 20, props.schema, false, props.schema, props.catalog); - matchedTable = matchTable(identifier, cachedTables); + // still avoid unbounded scans: session-scoped first, optional name-filtered global fallback. + cachedTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, tableLookupSchema, tableLookup.preferGlobalFirst, props.schema, props.catalog); + matchedTable = matchNavigationTable(cachedTables); if (matchedTable) { emit("clickTable", relationNavigationTarget(matchedTable)); return; } + if (tableLookup.allowGlobalFallback && !tableLookup.preferGlobalFirst) { + const globalTables = await connectionStore.listCompletionTables(props.connectionId!, tableLookup.database, tableLookupFilter, 20, undefined, true, props.schema, props.catalog); + cachedTables = mergeCompletionTables(cachedTables, globalTables); + matchedTable = matchNavigationTable(cachedTables); + if (matchedTable) { + emit("clickTable", relationNavigationTarget(matchedTable)); + return; + } + } } // 1d. Optimistic routine open only for confirmed call sites after metadata + table checks. diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index b0c288f754..a89dce3834 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7303,6 +7303,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "Aktiv olduqda cədvəl adına Ctrl/Cmd+klik cədvəl strukturu redaktorunu (DDL) açır. Deaktiv olduqda cədvəl verilənləri görünüşünü açır.", showTableDdlHoverPreview: "Kursor üzərinə gətirildikdə cədvəlin DDL-ini göstər", showTableDdlHoverPreviewDescription: "SQL redaktorunda kursor cədvəl adının üzərinə gətirildikdə DDL önbaxışını göstər.", + tableHoverLookupMode: "Cədvəl üzərinə gəlmə axtarış əhatəsi", + tableHoverLookupModeDescription: "SQL redaktorunda ixtisassız cədvəl adlarına gəldikdə və ya Ctrl/Cmd+klik edəndə sxemin necə həll olunması.", + tableHoverLookupModeCurrent: "Yalnız cari sxem", + tableHoverLookupModeFallback: "Əvvəl cari sxem, sonra hamısı", + tableHoverLookupModeAlways: "Əvvəl bütün sxemlər", + tableHoverLookupModeCurrentDescription: "Cədvəlləri yalnız alət panelində seçilmiş sxemdə axtarır.", + tableHoverLookupModeFallbackDescription: "Əvvəl seçilmiş sxemi yoxlayır; tapılmasa cədvəl adına görə digər sxemləri axtarır.", + tableHoverLookupModeAlwaysDescription: "Cədvəl adına görə bütün sxemləri əvvəl axtarır. Çarpaz sxem proseduru yazarkən faydalıdır.", sqlVariableSyntax: "SQL dəyişənlərinin və yer tutucularının əvəzlənməsi", sqlVariableSyntaxDescription: "DBX-in SQL-i işlətməzdən əvvəl hansı dəyişən və yer tutucu sintaksislərini əvəzləyəcəyini verilənlər bazası növünə görə seçin. Hamısı standart olaraq aktivdir.", sqlVariableSubstitutionEnabled: "Aktiv", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 07bc93f491..df4096e54b 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7501,6 +7501,14 @@ export default { clickTableNavigationTargetDescription: "When enabled, Ctrl/Cmd+clicking a table name opens the table structure editor (DDL). When disabled, it opens the table data view.", showTableDdlHoverPreview: "Show table DDL on hover", showTableDdlHoverPreviewDescription: "Show a DDL preview when hovering over a table name in the SQL editor.", + tableHoverLookupMode: "Table hover lookup scope", + tableHoverLookupModeDescription: "How unqualified table names are resolved when hovering or Ctrl/Cmd+clicking in the SQL editor.", + tableHoverLookupModeCurrent: "Current schema only", + tableHoverLookupModeFallback: "Current schema, then all schemas", + tableHoverLookupModeAlways: "All schemas first", + tableHoverLookupModeCurrentDescription: "Only look up tables in the schema selected in the toolbar.", + tableHoverLookupModeFallbackDescription: "Try the selected schema first, then search other schemas by table name if not found.", + tableHoverLookupModeAlwaysDescription: "Search all schemas by table name first. Prefer when writing cross-schema procedures.", sqlVariableSyntax: "SQL variable & placeholder substitution", sqlVariableSyntaxDescription: "Choose which variable and placeholder syntaxes DBX substitutes before running SQL, per database type. All are enabled by default.", sqlVariableSubstitutionEnabled: "Enabled", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 431ec59d3d..47fab6a882 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7025,6 +7025,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "Cuando está activado, Ctrl/Cmd+clic en el nombre de la tabla abre el editor de estructura (DDL). Cuando está desactivado, abre la vista de datos.", showTableDdlHoverPreview: "Mostrar DDL de tabla al pasar el cursor", showTableDdlHoverPreviewDescription: "Muestra una vista previa del DDL al pasar el cursor sobre el nombre de una tabla en el editor SQL.", + tableHoverLookupMode: "Alcance de búsqueda al pasar el cursor", + tableHoverLookupModeDescription: "Cómo se resuelven los nombres de tabla sin calificar al pasar el cursor o al hacer Ctrl/Cmd+clic en el editor SQL.", + tableHoverLookupModeCurrent: "Solo esquema actual", + tableHoverLookupModeFallback: "Esquema actual, luego todos", + tableHoverLookupModeAlways: "Todos los esquemas primero", + tableHoverLookupModeCurrentDescription: "Busca tablas solo en el esquema seleccionado en la barra de herramientas.", + tableHoverLookupModeFallbackDescription: "Prueba primero el esquema seleccionado; si no aparece, busca por nombre en otros esquemas.", + tableHoverLookupModeAlwaysDescription: "Busca primero por nombre en todos los esquemas. Útil al escribir procedimientos entre esquemas.", sqlVariableSyntax: "Sustitución de variables y marcadores SQL", sqlVariableSyntaxDescription: "Elige qué sintaxis de variables y marcadores sustituye DBX antes de ejecutar SQL, por tipo de base de datos. Todas están habilitadas de forma predeterminada.", sqlVariableSubstitutionEnabled: "Activada", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 85e46bc0ef..01aafe5c4b 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7025,6 +7025,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "Quando attivato, Ctrl/Cmd+clic sul nome della tabella apre l'editor di struttura (DDL). Quando disattivato, apre la vista dati.", showTableDdlHoverPreview: "Mostra DDL tabella al passaggio del mouse", showTableDdlHoverPreviewDescription: "Mostra un'anteprima DDL quando si passa sul nome di una tabella nell'editor SQL.", + tableHoverLookupMode: "Ambito di ricerca al passaggio del mouse", + tableHoverLookupModeDescription: "Come risolvere i nomi di tabella non qualificati al passaggio del mouse o con Ctrl/Cmd+clic nell'editor SQL.", + tableHoverLookupModeCurrent: "Solo schema corrente", + tableHoverLookupModeFallback: "Schema corrente, poi tutti", + tableHoverLookupModeAlways: "Tutti gli schemi prima", + tableHoverLookupModeCurrentDescription: "Cerca le tabelle solo nello schema selezionato nella barra degli strumenti.", + tableHoverLookupModeFallbackDescription: "Prova prima lo schema selezionato; se non trovato, cerca per nome in altri schemi.", + tableHoverLookupModeAlwaysDescription: "Cerca prima per nome in tutti gli schemi. Utile per procedure cross-schema.", sqlVariableSyntax: "Sostituzione di variabili e segnaposto SQL", sqlVariableSyntaxDescription: "Scegli quali sintassi di variabili e segnaposto DBX sostituisce prima di eseguire SQL, per tipo di database. Tutte abilitate per impostazione predefinita.", sqlVariableSubstitutionEnabled: "Attiva", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 20c7302eff..3efe0462fa 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7038,6 +7038,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "有効にすると、Ctrl/Cmd+クリックでテーブル構造エディタ(DDL)を開きます。無効の場合はテーブルデータビューを開きます。", showTableDdlHoverPreview: "ホバー時にテーブル DDL を表示", showTableDdlHoverPreviewDescription: "SQL エディタでテーブル名にホバーしたときに DDL プレビューを表示します。", + tableHoverLookupMode: "テーブルホバーの検索範囲", + tableHoverLookupModeDescription: "SQL エディタで未修飾のテーブル名にホバーまたは Ctrl/Cmd+クリックしたときのスキーマ解決方法。", + tableHoverLookupModeCurrent: "現在のスキーマのみ", + tableHoverLookupModeFallback: "現在のスキーマ優先、次に全体", + tableHoverLookupModeAlways: "全スキーマを先に検索", + tableHoverLookupModeCurrentDescription: "ツールバーで選択中のスキーマ内だけを検索します。", + tableHoverLookupModeFallbackDescription: "選択中のスキーマを先に試し、見つからなければテーブル名で他スキーマも検索します。", + tableHoverLookupModeAlwaysDescription: "テーブル名で全スキーマを先に検索します。クロススキーマのプロシージャ作成向け。", sqlVariableSyntax: "SQL 変数・プレースホルダー置換", sqlVariableSyntaxDescription: "SQL 実行前に置換する変数・プレースホルダー構文をデータベース種別ごとに選択します。既定ではすべて有効です。", sqlVariableSubstitutionEnabled: "置換を有効化", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 3c649e0e05..6206aeb67c 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6219,6 +6219,14 @@ export default withEnglishFallback({ editorTab: "편집기", showTableDdlHoverPreview: "호버 시 테이블 DDL 표시", showTableDdlHoverPreviewDescription: "SQL 편집기에서 테이블 이름에 마우스를 올리면 DDL 미리 보기를 표시합니다.", + tableHoverLookupMode: "테이블 호버 조회 범위", + tableHoverLookupModeDescription: "SQL 편집기에서 한정되지 않은 테이블 이름에 호버하거나 Ctrl/Cmd+클릭할 때 스키마를 해석하는 방식입니다.", + tableHoverLookupModeCurrent: "현재 스키마만", + tableHoverLookupModeFallback: "현재 스키마 우선, 이후 전체", + tableHoverLookupModeAlways: "전체 스키마 우선 검색", + tableHoverLookupModeCurrentDescription: "도구 모음에서 선택한 스키마에서만 테이블을 찾습니다.", + tableHoverLookupModeFallbackDescription: "선택한 스키마를 먼저 찾고, 없으면 테이블 이름으로 다른 스키마도 검색합니다.", + tableHoverLookupModeAlwaysDescription: "테이블 이름으로 모든 스키마를 먼저 검색합니다. 크로스 스키마 프로시저 작성에 적합합니다.", globalConnectTimeout: "전역 연결 제한 시간 (초)", globalConnectTimeoutDescription: "전역 설정을 상속하는 연결에 사용됩니다.", globalQueryTimeout: "전역 쿼리 제한 시간 (초)", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 4a2e6e22af..d37e811266 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7027,6 +7027,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "Quando ativado, Ctrl/Cmd+clicar no nome da tabela abre o editor de estrutura (DDL). Quando desativado, abre a visualização de dados.", showTableDdlHoverPreview: "Mostrar DDL da tabela ao passar o mouse", showTableDdlHoverPreviewDescription: "Mostra uma prévia do DDL ao passar o mouse sobre o nome de uma tabela no editor SQL.", + tableHoverLookupMode: "Escopo de busca ao passar o mouse", + tableHoverLookupModeDescription: "Como nomes de tabela sem qualificação são resolvidos ao passar o mouse ou com Ctrl/Cmd+clique no editor SQL.", + tableHoverLookupModeCurrent: "Apenas o schema atual", + tableHoverLookupModeFallback: "Schema atual, depois todos", + tableHoverLookupModeAlways: "Todos os schemas primeiro", + tableHoverLookupModeCurrentDescription: "Procura tabelas apenas no schema selecionado na barra de ferramentas.", + tableHoverLookupModeFallbackDescription: "Tenta o schema selecionado primeiro; se não achar, busca pelo nome em outros schemas.", + tableHoverLookupModeAlwaysDescription: "Busca primeiro pelo nome em todos os schemas. Útil ao escrever procedures entre schemas.", sqlVariableSyntax: "Substituição de variáveis e espaços reservados SQL", sqlVariableSyntaxDescription: "Escolha quais sintaxes de variáveis e espaços reservados o DBX substitui antes de executar SQL, por tipo de banco de dados. Todas ativadas por padrão.", sqlVariableSubstitutionEnabled: "Ativada", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 95970c827a..0f4c520236 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7184,6 +7184,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "Etkinleştirildiğinde bir tablo adına Ctrl/Cmd+tıklamak tablo yapısı düzenleyicisini (DDL) açar. Kapatıldığında tablo veri görünümünü açar.", showTableDdlHoverPreview: "Üzerine gelindiğinde tablo DDL'ini göster", showTableDdlHoverPreviewDescription: "SQL düzenleyicisinde bir tablo adının üzerine gelindiğinde DDL önizlemesi göster.", + tableHoverLookupMode: "Tablo üzerine gelince arama kapsamı", + tableHoverLookupModeDescription: "SQL düzenleyicisinde nitelendirilmemiş tablo adlarına gelindiğinde veya Ctrl/Cmd+tıklandığında şemanın nasıl çözüleceği.", + tableHoverLookupModeCurrent: "Yalnızca geçerli şema", + tableHoverLookupModeFallback: "Önce geçerli şema, sonra tümü", + tableHoverLookupModeAlways: "Önce tüm şemalar", + tableHoverLookupModeCurrentDescription: "Tabloları yalnızca araç çubuğunda seçili şemada arar.", + tableHoverLookupModeFallbackDescription: "Önce seçili şemayı dener; bulunamazsa tablo adına göre diğer şemaları arar.", + tableHoverLookupModeAlwaysDescription: "Tablo adına göre tüm şemaları önce arar. Çapraz şema yordamları yazarken kullanışlıdır.", sqlVariableSyntax: "SQL değişken ve yer tutucu değiştirme", sqlVariableSyntaxDescription: "DBX'in SQL çalıştırmadan önce hangi değişken ve yer tutucu sözdizimlerini değiştireceğini veritabanı türüne göre seçin. Varsayılan olarak tümü etkindir.", sqlVariableSubstitutionEnabled: "Etkin", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index bbca64acc6..df71740012 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7473,6 +7473,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "开启后,Ctrl/Cmd+点击表名将打开表结构编辑器(DDL);关闭则打开表数据视图。", showTableDdlHoverPreview: "悬浮表名时显示 DDL", showTableDdlHoverPreviewDescription: "在 SQL 编辑器中悬浮表名时显示 DDL 预览。", + tableHoverLookupMode: "表悬浮查找范围", + tableHoverLookupModeDescription: "在 SQL 编辑器中悬浮或 Ctrl/Cmd+点击未限定表名时,如何解析所属模式。", + tableHoverLookupModeCurrent: "仅当前模式", + tableHoverLookupModeFallback: "当前模式优先,再查全部", + tableHoverLookupModeAlways: "优先全库按名查找", + tableHoverLookupModeCurrentDescription: "只在工具栏选中的模式中查找表。", + tableHoverLookupModeFallbackDescription: "先查选中模式,未找到再按表名跨模式查找。", + tableHoverLookupModeAlwaysDescription: "直接按表名跨模式查找。适合编写跨模式存储过程。", sqlVariableSyntax: "SQL 变量与占位符替换", sqlVariableSyntaxDescription: "按数据库类型选择执行 SQL 前替换哪些变量与占位符语法,默认全部开启。", sqlVariableSubstitutionEnabled: "启用替换", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 0afab4c566..fd211c7063 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6338,6 +6338,14 @@ export default withEnglishFallback({ clickTableNavigationTargetDescription: "開啟後,Ctrl/Cmd+點擊表名將開啟表結構編輯器(DDL);關閉則開啟表資料檢視。", showTableDdlHoverPreview: "滑過表名時顯示 DDL", showTableDdlHoverPreviewDescription: "在 SQL 編輯器中滑過表名時顯示 DDL 預覽。", + tableHoverLookupMode: "資料表懸停查詢範圍", + tableHoverLookupModeDescription: "在 SQL 編輯器中懸停或 Ctrl/Cmd+點擊未限定資料表名稱時,如何解析所屬結構描述。", + tableHoverLookupModeCurrent: "僅目前結構描述", + tableHoverLookupModeFallback: "目前結構描述優先,再查全部", + tableHoverLookupModeAlways: "優先全庫依名稱查詢", + tableHoverLookupModeCurrentDescription: "只在工具列選取的結構描述中查詢資料表。", + tableHoverLookupModeFallbackDescription: "先查選取的結構描述,找不到再依資料表名稱跨結構描述查詢。", + tableHoverLookupModeAlwaysDescription: "直接依資料表名稱跨結構描述查詢。適合撰寫跨結構描述預存程序。", sqlVariableSyntax: "SQL 變數與佔位符替換", sqlVariableSyntaxDescription: "依資料庫類型選擇執行 SQL 前替換哪些變數與佔位符語法,預設全部開啟。", sqlVariableSubstitutionEnabled: "啟用替換", diff --git a/apps/desktop/src/lib/editor/__tests__/hoverTableLookup.spec.ts b/apps/desktop/src/lib/editor/__tests__/hoverTableLookup.spec.ts new file mode 100644 index 0000000000..9bd2766681 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/hoverTableLookup.spec.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; +import { matchHoverTableCandidates, normalizeTableHoverLookupMode, pickHoverTableMatch, resolveHoverTableLookupTarget } from "@/lib/editor/hoverTableLookup"; + +describe("normalizeTableHoverLookupMode", () => { + it("defaults to fallback", () => { + expect(normalizeTableHoverLookupMode(undefined)).toBe("fallback"); + expect(normalizeTableHoverLookupMode("invalid")).toBe("fallback"); + }); + + it("preserves valid modes", () => { + expect(normalizeTableHoverLookupMode("current")).toBe("current"); + expect(normalizeTableHoverLookupMode("fallback")).toBe("fallback"); + expect(normalizeTableHoverLookupMode("always")).toBe("always"); + }); +}); + +describe("resolveHoverTableLookupTarget", () => { + it("uses schema.table qualifier regardless of mode", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["other", "users"], + mode: "current", + }); + expect(target).toMatchObject({ + database: "app", + schema: "other", + tableName: "users", + schemaFromQualifier: true, + allowGlobalFallback: false, + preferGlobalFirst: false, + }); + }); + + it("uses db.schema.table qualifier", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["analytics", "reporting", "users"], + mode: "fallback", + }); + expect(target).toMatchObject({ + database: "analytics", + schema: "reporting", + tableName: "users", + schemaFromQualifier: true, + allowGlobalFallback: false, + }); + }); + + it("treats MySQL-style db.table as database qualifier", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + databaseType: "mysql", + tableName: "users", + identifierParts: ["other_db", "users"], + mode: "fallback", + }); + expect(target).toMatchObject({ + database: "other_db", + schema: undefined, + schemaFromQualifier: true, + allowGlobalFallback: false, + }); + }); + + it("prefers semantic schema over identifier parts", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["t"], + semanticSchema: "reporting", + mode: "current", + }); + expect(target).toMatchObject({ + schema: "reporting", + schemaFromQualifier: true, + allowGlobalFallback: false, + }); + }); + + it("scopes unqualified names to the selected schema in current mode", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["users"], + mode: "current", + }); + expect(target).toMatchObject({ + schema: "public", + schemaFromQualifier: false, + allowGlobalFallback: false, + preferGlobalFirst: false, + }); + }); + + it("allows global fallback for unqualified names in fallback mode", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["users"], + mode: "fallback", + }); + expect(target).toMatchObject({ + schema: "public", + allowGlobalFallback: true, + preferGlobalFirst: false, + }); + }); + + it("prefers global search first in always mode", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["users"], + mode: "always", + }); + expect(target).toMatchObject({ + schema: undefined, + allowGlobalFallback: true, + preferGlobalFirst: true, + }); + }); +}); + +describe("pickHoverTableMatch", () => { + const tables = [ + { name: "users", schema: "reporting" }, + { name: "users", schema: "public" }, + { name: "orders", schema: "public" }, + ]; + + it("prefers the selected schema when multiple schemas match", () => { + expect(pickHoverTableMatch("users", tables, "public")).toEqual({ name: "users", schema: "public" }); + }); + + it("falls back to the first name match when preferred schema is absent", () => { + expect(pickHoverTableMatch("users", tables, "missing")).toEqual({ name: "users", schema: "reporting" }); + expect(pickHoverTableMatch("users", tables)).toEqual({ name: "users", schema: "reporting" }); + }); + + it("returns null when the table name is missing", () => { + expect(pickHoverTableMatch("missing", tables, "public")).toBeNull(); + }); +}); + +describe("matchHoverTableCandidates", () => { + const tables = [ + { name: "users", schema: "reporting" }, + { name: "users", schema: "public" }, + { name: "orders", schema: "sales" }, + ]; + + it("prefers the toolbar schema for unqualified names even when another schema appears first", () => { + expect( + matchHoverTableCandidates(tables, { + lookups: ["users"], + tableName: "users", + preferredSchema: "public", + }), + ).toEqual({ name: "users", schema: "public" }); + }); + + it("resolves qualified schema.table without using preferredSchema", () => { + expect( + matchHoverTableCandidates(tables, { + lookups: ["reporting.users"], + tableName: "users", + preferredSchema: "public", + }), + ).toEqual({ name: "users", schema: "reporting" }); + }); + + it("uses semantic qualified lookup before unqualified preferred-schema match", () => { + expect( + matchHoverTableCandidates(tables, { + lookups: ["sales.orders", "orders"], + tableName: "orders", + preferredSchema: "public", + }), + ).toEqual({ name: "orders", schema: "sales" }); + }); + + it("ignores unqualified lookups in the lookups list and still prefers schema", () => { + // Unqualified entries must not short-circuit via matchTable's first-name-wins behavior. + expect( + matchHoverTableCandidates(tables, { + lookups: ["users", "public.users"], + tableName: "users", + preferredSchema: "public", + }), + ).toEqual({ name: "users", schema: "public" }); + }); + + it("returns null when nothing matches", () => { + expect( + matchHoverTableCandidates(tables, { + lookups: ["missing.table"], + tableName: "missing", + preferredSchema: "public", + }), + ).toBeNull(); + }); +}); + +describe("resolveHoverTableLookupTarget cache-scope modes", () => { + it("fallback keeps selected schema for the first local pass", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["users"], + mode: "fallback", + }); + expect(target.schema).toBe("public"); + expect(target.preferGlobalFirst).toBe(false); + expect(target.allowGlobalFallback).toBe(true); + }); + + it("always clears schema so local lookup scans same-database schemas", () => { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["users"], + mode: "always", + }); + expect(target.schema).toBeUndefined(); + expect(target.preferGlobalFirst).toBe(true); + }); + + it("qualified names never enable same-database global fallback", () => { + for (const mode of ["current", "fallback", "always"] as const) { + const target = resolveHoverTableLookupTarget({ + database: "app", + schema: "public", + databaseType: "postgres", + tableName: "users", + identifierParts: ["other", "users"], + mode, + }); + expect(target.allowGlobalFallback).toBe(false); + expect(target.preferGlobalFirst).toBe(false); + expect(target.schema).toBe("other"); + } + }); +}); diff --git a/apps/desktop/src/lib/editor/hoverTableLookup.ts b/apps/desktop/src/lib/editor/hoverTableLookup.ts new file mode 100644 index 0000000000..45c6531177 --- /dev/null +++ b/apps/desktop/src/lib/editor/hoverTableLookup.ts @@ -0,0 +1,130 @@ +import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport"; +import { matchTable, splitQualifiedIdentifier } from "@/lib/sql/sqlNavigation"; +import type { DatabaseType } from "@/types/database"; + +export const TABLE_HOVER_LOOKUP_MODES = ["current", "fallback", "always"] as const; +export type TableHoverLookupMode = (typeof TABLE_HOVER_LOOKUP_MODES)[number]; + +export function normalizeTableHoverLookupMode(value: unknown, fallback: TableHoverLookupMode = "fallback"): TableHoverLookupMode { + return value === "current" || value === "fallback" || value === "always" ? value : fallback; +} + +export interface ResolveHoverTableLookupTargetInput { + database: string; + /** Currently selected schema from the editor toolbar. */ + schema?: string; + catalog?: string; + databaseType?: DatabaseType; + tableName: string; + /** Identifier segments from the hovered/clicked token (e.g. `["other", "users"]`). */ + identifierParts: string[]; + semanticDatabase?: string; + semanticSchema?: string; + mode: TableHoverLookupMode; +} + +export interface HoverTableLookupTarget { + database: string; + schema?: string; + catalog?: string; + tableName: string; + /** Schema/database came from SQL qualifier or semantic model — do not global-search. */ + schemaFromQualifier: boolean; + /** After a scoped miss, retry with globalSearch by exact table name. */ + allowGlobalFallback: boolean; + /** Unqualified + always mode: search across schemas first. */ + preferGlobalFirst: boolean; +} + +/** + * Resolve the metadata scope for table hover / Ctrl+click navigation. + * + * Qualified names (`schema.table`, `db.schema.table`, MySQL-style `db.table`) + * always use the SQL qualifier. Unqualified names follow `mode`: + * - `current`: only the selected schema + * - `fallback`: selected schema first, then name-filtered global search + * - `always`: name-filtered global search first + * + * "Global" here means same-database cross-schema, not cross-database. + */ +export function resolveHoverTableLookupTarget(input: ResolveHoverTableLookupTargetInput): HoverTableLookupTarget { + const tableName = input.tableName; + let database = input.semanticDatabase ?? input.database; + let schema = input.semanticSchema; + let schemaFromQualifier = !!input.semanticSchema; + + if (!schemaFromQualifier && input.identifierParts.length >= 2) { + const parts = input.identifierParts; + if (parts.length >= 3) { + database = parts[parts.length - 3]!; + schema = parts[parts.length - 2]; + schemaFromQualifier = true; + } else { + if (input.databaseType && !isSchemaAware(input.databaseType) && !isSingleDatabase(input.databaseType)) { + database = parts[0]!; + schema = undefined; + } else { + schema = parts[0]; + } + schemaFromQualifier = true; + } + } + + if (!schemaFromQualifier) { + schema = input.schema; + } + + const unqualified = !schemaFromQualifier; + const preferGlobalFirst = unqualified && input.mode === "always"; + const allowGlobalFallback = unqualified && (input.mode === "fallback" || input.mode === "always"); + + return { + database, + schema: preferGlobalFirst ? undefined : schema, + catalog: input.catalog, + tableName, + schemaFromQualifier, + allowGlobalFallback, + preferGlobalFirst, + }; +} + +/** Prefer the selected schema when the same table name exists in multiple schemas. */ +export function pickHoverTableMatch(tableName: string, tables: readonly T[], preferredSchema?: string): T | null { + const nameLower = tableName.toLowerCase(); + const nameMatches = tables.filter((table) => table.name.toLowerCase() === nameLower); + if (nameMatches.length === 0) return null; + if (preferredSchema) { + const preferred = nameMatches.find((table) => table.schema?.toLowerCase() === preferredSchema.toLowerCase()); + if (preferred) return preferred; + } + return nameMatches[0] ?? null; +} + +export interface MatchHoverTableCandidatesOptions { + /** Qualified forms to try first (e.g. `schema.table` from the token or semantic model). */ + lookups?: string[]; + tableName: string; + preferredSchema?: string; +} + +/** + * Match hover/navigation table candidates. + * + * Qualified lookups use {@link matchTable}. Unqualified names go through + * {@link pickHoverTableMatch} so the toolbar schema wins over arbitrary order. + */ +export function matchHoverTableCandidates(tables: readonly T[], options: MatchHoverTableCandidatesOptions): T | null { + const seen = new Set(); + for (const lookup of options.lookups ?? []) { + const trimmed = lookup.trim(); + if (!trimmed) continue; + const key = trimmed.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + if (splitQualifiedIdentifier(trimmed).length < 2) continue; + const matched = matchTable(trimmed, tables); + if (matched) return matched; + } + return pickHoverTableMatch(options.tableName, tables, options.preferredSchema); +} diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index ca6284bb85..c1743edc4c 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -126,6 +126,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("completionTriggerMode"); }); + it("includes tableHoverLookupMode", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("tableHoverLookupMode"); + }); + it("includes the SQL variable substitution master switch", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("sqlVariableSubstitutionEnabled"); }); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 5ee95ab85e..695f1fd189 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -4,6 +4,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit"; import { normalizeExternalSqlEditorMaxMb } from "@/lib/sql/sqlFileOpen"; import { normalizeCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy"; +import { normalizeTableHoverLookupMode } from "@/lib/editor/hoverTableLookup"; import { normalizeRedisKeyTemplates } from "@/lib/redis/redisKeyTemplates"; export const EDITOR_SETTINGS_DRAFT_KEYS = [ @@ -85,6 +86,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "generateSqlQuoteIdentifiers", "formatSqlOnSqlFileSave", "showTableDdlHoverPreview", + "tableHoverLookupMode", "updateNotificationsEnabled", "autoDownloadUpdates", "sidebarObjectInfoMode", @@ -138,6 +140,7 @@ function normalizedDraftValue(key: EditorSettingsDraftKey, value: unknown): unkn if (key === "queryResultMaxRows") return normalizeQueryResultMaxRowsDraft(value); if (key === "externalSqlEditorMaxMb") return normalizeExternalSqlEditorMaxMb(value); if (key === "completionTriggerMode") return normalizeCompletionTriggerMode(value); + if (key === "tableHoverLookupMode") return normalizeTableHoverLookupMode(value); if (key === "redisKeyTemplates") return normalizeRedisKeyTemplates(value); if (key === "backgroundImage") return normalizeBackgroundImageSettings(value); return value; diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index b61f6d3011..74cfb7d365 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -138,6 +138,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-generate-sql-quote-identifiers", category: "editor", titleKey: "settings.generateSqlQuoteIdentifiers", descriptionKey: "settings.generateSqlQuoteIdentifiersDescription", targetId: "editor" }, { id: "editor-format-sql-on-sql-file-save", category: "editor", titleKey: "settings.formatSqlOnSqlFileSave", descriptionKey: "settings.formatSqlOnSqlFileSaveDescription", targetId: "editor" }, { id: "editor-table-ddl-hover-preview", category: "editor", titleKey: "settings.showTableDdlHoverPreview", descriptionKey: "settings.showTableDdlHoverPreviewDescription", targetId: "editor" }, + { id: "editor-table-hover-lookup-mode", category: "editor", titleKey: "settings.tableHoverLookupMode", descriptionKey: "settings.tableHoverLookupModeDescription", targetId: "editor" }, { id: "editor-diagnostics", category: "editor", titleKey: "settings.sqlSemanticDiagnosticsEnabled", descriptionKey: "settings.sqlSemanticDiagnosticsEnabledDescription", targetId: "editor" }, { id: "editor-sql-variables", category: "editor", titleKey: "settings.sqlVariableSyntax", descriptionKey: "settings.sqlVariableSyntaxDescription", targetId: "editor" }, { id: "editor-saved-sql-target", category: "editor", titleKey: "settings.savedSqlOpenTarget", targetId: "editor" }, diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index ceccbdc19b..e22a537d6e 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -100,6 +100,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record(identifier: string, tables: T[]): T | null { +export function matchTable(identifier: string, tables: readonly T[]): T | null { const parts = splitQualifiedIdentifier(identifier); const normalizedIdentifier = parts.length > 0 ? parts.join(".").toLowerCase() : identifier.toLowerCase(); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 78c845be2f..6b1728ff64 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -676,6 +676,24 @@ describe("normalizeEditorSettings - showTableDdlHoverPreview", () => { }); }); +describe("normalizeEditorSettings - tableHoverLookupMode", () => { + it("defaults tableHoverLookupMode to fallback", () => { + expect(normalizeEditorSettings({}).tableHoverLookupMode).toBe("fallback"); + }); + + it("preserves the three valid modes", () => { + expect(normalizeEditorSettings({ tableHoverLookupMode: "current" }).tableHoverLookupMode).toBe("current"); + expect(normalizeEditorSettings({ tableHoverLookupMode: "fallback" }).tableHoverLookupMode).toBe("fallback"); + expect(normalizeEditorSettings({ tableHoverLookupMode: "always" }).tableHoverLookupMode).toBe("always"); + }); + + it("falls back to fallback for invalid values", () => { + expect(normalizeEditorSettings({ tableHoverLookupMode: "invalid" } as any).tableHoverLookupMode).toBe("fallback"); + expect(normalizeEditorSettings({ tableHoverLookupMode: undefined } as any).tableHoverLookupMode).toBe("fallback"); + expect(normalizeEditorSettings({ tableHoverLookupMode: null } as any).tableHoverLookupMode).toBe("fallback"); + }); +}); + describe("normalizeEditorSettings - completionTriggerMode", () => { it("defaults completionTriggerMode to positional", () => { expect(normalizeEditorSettings({}).completionTriggerMode).toBe("positional"); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index b9b36da293..690b8a3e29 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -27,13 +27,14 @@ import { DEFAULT_SQL_FORMATTER_SETTINGS, normalizeSqlFormatterSettings, type Sql import { normalizeSqlVariableSyntaxOverrides, type SqlVariableSyntaxOverrides } from "@/lib/sql/sqlVariableSyntax"; import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; import { type DataTabReuseMode, DEFAULT_DATA_TAB_REUSE_MODE, normalizeDataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; +import { normalizeTableHoverLookupMode, type TableHoverLookupMode } from "@/lib/editor/hoverTableLookup"; import { normalizeCompletionTriggerMode, type SqlCompletionTriggerMode } from "@/lib/sql/sqlCompletionTriggerPolicy"; import { DEFAULT_CSV_QUOTE_MODE, normalizeCsvQuoteMode, type CsvQuoteMode } from "@/lib/export/csvQuoteMode"; import { configureMetadataRuntimeCache, METADATA_CACHE_DEFAULT_MEMORY_MB, normalizeMetadataCacheMemoryMb } from "@/lib/metadata/metadataRuntimeCache"; import type { AiApiStyle, AiAssistantMode, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiModelEffortPreference, AiProvider, AiReasoningLevel, AiTestConnectionResult } from "@/types/ai"; import type { SqlShortcutAction, SqlSnippet, TableInfoTab } from "@/types/database"; -export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, CsvQuoteMode, DataTabReuseMode, SavedSqlOpenTargetMode, SqlCompletionTriggerMode }; +export type { AiApiStyle, AiAuthMethod, AiChatSelectionState, AiConfig, AiConfigItem, AiConfiguredModel, AiEffortLevel, AiEffortSelection, AiProvider, AiReasoningLevel, AiTestConnectionResult, CsvQuoteMode, DataTabReuseMode, SavedSqlOpenTargetMode, SqlCompletionTriggerMode, TableHoverLookupMode }; export interface DesktopSettings { show_tray_icon: boolean; @@ -895,6 +896,7 @@ export interface EditorSettings { sqlVariableSyntaxOverrides: SqlVariableSyntaxOverrides; continueOnErrorOnBatch: boolean; showTableDdlHoverPreview: boolean; + tableHoverLookupMode: TableHoverLookupMode; clickTableNavigationTarget: ClickTableNavigationTarget; completionTriggerMode: SqlCompletionTriggerMode; defaultTransactionMode: DefaultTransactionMode; @@ -1136,6 +1138,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { sqlVariableSyntaxOverrides: {}, continueOnErrorOnBatch: false, showTableDdlHoverPreview: true, + tableHoverLookupMode: "fallback", clickTableNavigationTarget: "data", completionTriggerMode: "positional", defaultTransactionMode: "auto", @@ -1650,6 +1653,7 @@ export function normalizeEditorSettings(settings: Partial, exist sqlVariableSyntaxOverrides: normalizeSqlVariableSyntaxOverrides(settings.sqlVariableSyntaxOverrides), continueOnErrorOnBatch: settings.continueOnErrorOnBatch === true, showTableDdlHoverPreview: typeof settings.showTableDdlHoverPreview === "boolean" ? settings.showTableDdlHoverPreview : DEFAULT_EDITOR_SETTINGS.showTableDdlHoverPreview, + tableHoverLookupMode: normalizeTableHoverLookupMode(settings.tableHoverLookupMode, DEFAULT_EDITOR_SETTINGS.tableHoverLookupMode), clickTableNavigationTarget: normalizeClickTableNavigationTarget(settings.clickTableNavigationTarget), completionTriggerMode: normalizeCompletionTriggerMode(settings.completionTriggerMode), defaultTransactionMode: normalizeDefaultTransactionMode(settings.defaultTransactionMode), @@ -2374,6 +2378,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.sqlVariableSyntaxOverrides !== undefined) editorSettings.value.sqlVariableSyntaxOverrides = normalizeSqlVariableSyntaxOverrides(partial.sqlVariableSyntaxOverrides); if (partial.continueOnErrorOnBatch !== undefined) editorSettings.value.continueOnErrorOnBatch = partial.continueOnErrorOnBatch === true; if (partial.showTableDdlHoverPreview !== undefined) editorSettings.value.showTableDdlHoverPreview = partial.showTableDdlHoverPreview === true; + if (partial.tableHoverLookupMode !== undefined) editorSettings.value.tableHoverLookupMode = normalizeTableHoverLookupMode(partial.tableHoverLookupMode, DEFAULT_EDITOR_SETTINGS.tableHoverLookupMode); if (partial.clickTableNavigationTarget !== undefined) editorSettings.value.clickTableNavigationTarget = normalizeClickTableNavigationTarget(partial.clickTableNavigationTarget); if (partial.completionTriggerMode !== undefined) editorSettings.value.completionTriggerMode = normalizeCompletionTriggerMode(partial.completionTriggerMode); if (partial.defaultTransactionMode !== undefined) editorSettings.value.defaultTransactionMode = normalizeDefaultTransactionMode(partial.defaultTransactionMode);