feat(editor): resolve table hover across schemas in current database

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-17 14:32:45 +08:00
1 parent 90ad4e62e6
commit 254b9baaff
21 files changed
+675 -29

No files matched your search

@@ -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<TableHoverLookupMode>(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<ClickTableNavigationTarget>(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(() => {
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2" data-editor-table-hover-lookup-mode>
<div class="min-w-0 space-y-1">
<Label>{{ t("settings.tableHoverLookupMode") }}</Label>
<p class="text-xs text-muted-foreground">
{{ tableHoverLookupModeDescription }}
</p>
</div>
<Select :model-value="editTableHoverLookupMode" @update:model-value="onTableHoverLookupModeChange">
<SelectTrigger class="h-8 w-48 shrink-0">
<SelectValue :placeholder="t('settings.tableHoverLookupMode')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">{{ t("settings.tableHoverLookupModeCurrent") }}</SelectItem>
<SelectItem value="fallback">{{ t("settings.tableHoverLookupModeFallback") }}</SelectItem>
<SelectItem value="always">{{ t("settings.tableHoverLookupModeAlways") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2 md:col-span-2" data-editor-default-transaction-mode>
<div class="min-w-0 space-y-1">
<Label for="editor-default-transaction-mode">{{ t("settings.defaultTransactionMode") }}</Label>
@@ -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.