6962 lines
302 KiB
TypeScript
6962 lines
302 KiB
TypeScript
import { defineStore } from "pinia";
|
||
import { uuid } from "@/lib/common/utils";
|
||
import { ref, computed, watch, markRaw } from "vue";
|
||
import type {
|
||
ColumnInfo,
|
||
CompletionAssistantCandidate,
|
||
CompletionAssistantObjectKind,
|
||
CompletionAssistantRequest,
|
||
ConnectionConfig,
|
||
DatabaseType,
|
||
DatabaseConnectionInfo,
|
||
DatabaseStorageInfo,
|
||
SqlServerCompletionContext,
|
||
CatalogInfo,
|
||
ForeignKeyInfo,
|
||
ObjectInfo,
|
||
ObjectStatistics,
|
||
SchemaInfo,
|
||
SidebarLayout,
|
||
TableNameFilter,
|
||
TableInfo,
|
||
TreeNode,
|
||
TunnelProfile,
|
||
VectorCollectionMeta,
|
||
} from "@/types/database";
|
||
import {
|
||
inheritNaturalTreeNodeOrder,
|
||
migrateLegacyPinnedTreeNodeOrder,
|
||
normalizePinnedTreeNodeOrder,
|
||
orderItemsByPinnedTreeNodeOrder,
|
||
pinnedTreeNodeIdentityMatches,
|
||
removePinnedTreeNodesFromOrder,
|
||
reorderPinnedTreeNodeOrder,
|
||
replacePinnedTreeNodeInOrder,
|
||
syncPinnedTreeNodeStateInPlace,
|
||
treeNodePinIdentity,
|
||
treeNodePinKey,
|
||
type PinnedTreeNodeIdentity,
|
||
type PinnedTreeNodeIdentityCanonicalizer,
|
||
} from "@/lib/app/pinnedItems";
|
||
import {
|
||
reconcileLayout,
|
||
buildTreeNodesFromLayout,
|
||
emptyLayout,
|
||
appendConnectionToLayout,
|
||
removeConnectionFromSidebarLayout,
|
||
findConnectionLocation,
|
||
createGroup as createGroupOp,
|
||
renameGroup as renameGroupOp,
|
||
deleteGroup as deleteGroupOp,
|
||
toggleGroupCollapsed as toggleGroupCollapsedOp,
|
||
collapseAllGroups as collapseAllGroupsOp,
|
||
moveConnectionToGroup as moveConnectionToGroupOp,
|
||
remapSidebarLayoutConnectionIds,
|
||
reorderEntry as reorderEntryOp,
|
||
buildConnectionGroupPathMap,
|
||
type DropPosition,
|
||
} from "@/lib/sidebar/sidebarLayout";
|
||
import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion";
|
||
import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
|
||
import * as api from "@/lib/backend/api";
|
||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
|
||
import { connectionIsDorisFamilyCatalogCapable, isInternalDorisCatalog, isSchemaAware, normalizeSidebarObjectKind, sidebarObjectKindsForDatabase, usesTreeSchemaMode } from "@/lib/database/databaseCapabilities";
|
||
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionShouldDiscoverJdbcSchemas, connectionShouldLoadIdentifierQuote, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbIdentifierQuoteOverride } from "@/lib/database/jdbcDialect";
|
||
import { buildDatabaseTreeNodes, buildDuckDbConnectionTreeNodes, compareSidebarNames, sortSidebarDatabases, sortSidebarNames, shouldIncludeDefaultDatabaseNode } from "@/lib/database/databaseTree";
|
||
import { buildSqlServerDatabaseTreeNodes } from "@/lib/database/sqlServerTree";
|
||
import { collapseExpandedTreeNodes } from "@/lib/sidebar/sidebarTreeCollapse";
|
||
import { findDatabaseTreeNode } from "@/lib/sidebar/treeRefreshTarget";
|
||
import { simpleModeEmptyShellNeedsConfirmedLoad, treeNodeLoadedChildrenContentPresent } from "@/lib/sidebar/treeLoadedChildrenMarker";
|
||
import { shouldMarkDisconnected } from "@/lib/connection/connectionHealth";
|
||
import { connectionAttemptOriginalErrorMessage, connectionAttemptTimeoutMessage, connectionAttemptTimeoutMs } from "@/lib/connection/connectionAttemptTimeout";
|
||
import { migrateSqlServerLegacyCompatibilityConfig, requiresSqlServerLegacyCompatibilityComponent, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
|
||
import { deleteTabResultSnapshotsForOwner } from "@/lib/tabs/tabResultCache";
|
||
import { connectionUsesVisibleSchemaFilter, filterDatabaseNamesForConnection, filterSchemaNamesForConnection, filterVisibleDatabaseNames, normalizeVisibleDatabaseSelection } from "@/lib/database/visibleDatabases";
|
||
import {
|
||
buildObjectGroupPlaceholderNodes,
|
||
buildGroupedObjectTreeNodes,
|
||
buildSimpleObjectTreeNodes,
|
||
buildTableTreeNodes,
|
||
appendTableTreeLoadMoreNode,
|
||
expandCachedObjectBrowserNodes,
|
||
filterSimpleSidebarSupplementalObjects,
|
||
mergeTableInfosIntoObjects,
|
||
mergeTableTreePageChildren,
|
||
objectGroupRefreshParentId,
|
||
objectTypesForGroupNode,
|
||
sortDatabaseObjectsByName,
|
||
tablePartitionGroups,
|
||
withoutTableTreeLoadMoreNodes,
|
||
type TableTreeLoadMoreParent,
|
||
type DatabaseObjectTreeKind,
|
||
} from "@/lib/table/tableTree";
|
||
import { hasTreeNodeDatabaseContext, normalizeCataloglessDatabaseNodes, treeNodeSchemaCachePrefix } from "@/lib/sidebar/treeNodeContext";
|
||
import { decodeSchemaTreeCache, encodeSchemaTreeCache } from "@/lib/metadata/schemaTreeCache";
|
||
import { sortSidebarTreeChildrenForParent } from "@/lib/sidebar/sidebarNodeOrdering";
|
||
import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUserAdmin";
|
||
import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities";
|
||
import { useSettingsStore } from "@/stores/settingsStore";
|
||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
|
||
import { toMongoCollectionKind } from "@/lib/sidebar/mongoCollectionMutation";
|
||
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
|
||
import { kvRootNodeLabel } from "@/lib/kv/kvRootPresentation";
|
||
import { REDIS_SCAN_PAGE_SIZE_DEFAULT } from "@/lib/redis/redisKeyPattern";
|
||
import { normalizeRedisDatabaseAliases, redisDatabaseAlias, redisDatabaseLabel } from "@/lib/redis/redisDatabaseAlias";
|
||
import { appendAgentDriverUpdateHint, hasAgentDriverUpdate, hasInstalledAgentVersion, type AgentDriverInstallState } from "@/lib/connection/agentDriverInstallHint";
|
||
import { appendConnectionErrorHints } from "@/lib/connection/connectionErrorHints";
|
||
import { appendVisibleDatabaseSelection } from "@/lib/connection/connectionVisibleDatabases";
|
||
import { configuredDatabaseProductName, connectionConfigFingerprint, normalizeDatabaseConnectionInfo } from "@/lib/connection/connectionDatabaseInfo";
|
||
import { createMetadataLoadTrace, logMetadataLoadTrace, MetadataLoadCoordinator, type MetadataLoadTraceLogger } from "@/lib/metadata/metadataLoadCoordinator";
|
||
import type { MetadataScopeInput } from "@/lib/metadata/metadataLoadScope";
|
||
import { MetadataResultCache, type MetadataCacheInvalidation } from "@/lib/metadata/metadataResultCache";
|
||
import { invalidateTableMetadataCache } from "@/lib/metadata/tableMetadataCache";
|
||
import { invalidateObjectDdlCache } from "@/lib/metadata/objectDdlCache";
|
||
import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache";
|
||
import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
|
||
import { TreeNodeLoadRegistry, type TreeNodeLoadHandle } from "@/lib/metadata/treeNodeLoadHandle";
|
||
import i18n from "@/i18n";
|
||
import type { MqAdminConfig } from "@/types/mq";
|
||
import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
|
||
import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage";
|
||
|
||
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
||
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
|
||
const SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY = "dbx-sidebar-table-name-filters";
|
||
const CONNECTION_HEALTH_CHECK_TTL_MS = 2000;
|
||
const CONNECTION_HEALTH_CHECK_TIMEOUT_MS = 5000;
|
||
const METADATA_LOAD_MIN_TIMEOUT_MS = 15_000;
|
||
const METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS = 60_000;
|
||
const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
|
||
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
|
||
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
|
||
const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160;
|
||
const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000;
|
||
export const COMPLETION_METADATA_CONCURRENCY = 2;
|
||
const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy";
|
||
const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)";
|
||
const XUGU_TABLE_CHILD_METADATA_AGENT_VERSION = "0.1.23";
|
||
const SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE = "Connection attempt was superseded by a newer attempt";
|
||
|
||
function normalizeTableNameFilter(filter: Partial<TableNameFilter> | undefined | null): TableNameFilter {
|
||
const normalizePatterns = (patterns: unknown): string[] => (Array.isArray(patterns) ? patterns.map((pattern) => (typeof pattern === "string" ? pattern.trim() : "")).filter(Boolean) : []);
|
||
return {
|
||
includePatterns: normalizePatterns(filter?.includePatterns),
|
||
excludePatterns: normalizePatterns(filter?.excludePatterns),
|
||
};
|
||
}
|
||
|
||
function tableNameFilterIsEmpty(filter: TableNameFilter | undefined | null): boolean {
|
||
return !filter || (filter.includePatterns.length === 0 && filter.excludePatterns.length === 0);
|
||
}
|
||
|
||
function loadSidebarTableNameFilters(): Record<string, TableNameFilter> {
|
||
if (typeof localStorage === "undefined") return {};
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY) || "{}");
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||
const result: Record<string, TableNameFilter> = {};
|
||
for (const [key, value] of Object.entries(parsed)) {
|
||
const filter = normalizeTableNameFilter(value as Partial<TableNameFilter>);
|
||
if (!tableNameFilterIsEmpty(filter)) result[key] = filter;
|
||
}
|
||
return result;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function saveSidebarTableNameFilters(filters: Record<string, TableNameFilter>) {
|
||
if (typeof localStorage === "undefined") return;
|
||
localStorage.setItem(SIDEBAR_TABLE_NAME_FILTERS_STORAGE_KEY, JSON.stringify(filters));
|
||
}
|
||
|
||
function sidebarObjectGroupPageSize(): number {
|
||
const settingsStore = useSettingsStore();
|
||
const size = settingsStore.desktopSettings.sidebar_table_page_size;
|
||
return typeof size === "number" && size > 0 ? size : 500;
|
||
}
|
||
|
||
function isFlatMqConnection(config: ConnectionConfig | undefined): boolean {
|
||
if (!config || config.db_type !== "mq") return false;
|
||
if (config.driver_profile === "kafka" || config.driver_profile === "rocketmq" || config.driver_profile === "rabbitmq") return true;
|
||
const kind = (config.external_config as Partial<MqAdminConfig> | undefined)?.systemKind;
|
||
return kind === "kafka" || kind === "rocketmq" || kind === "rabbitmq";
|
||
}
|
||
|
||
type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip";
|
||
|
||
interface LocateTableTarget {
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName: string;
|
||
}
|
||
|
||
function nodeIdPart(value: string): string {
|
||
return encodeURIComponent(value);
|
||
}
|
||
|
||
function sqlServerLinkedRootId(connectionId: string): string {
|
||
return `${connectionId}:__linked_servers`;
|
||
}
|
||
|
||
function sqlServerLinkedServerId(connectionId: string, server: string): string {
|
||
return `${sqlServerLinkedRootId(connectionId)}:${nodeIdPart(server)}`;
|
||
}
|
||
|
||
function sqlServerLinkedCatalogId(connectionId: string, server: string, catalog: string): string {
|
||
return `${sqlServerLinkedServerId(connectionId, server)}:${nodeIdPart(catalog)}`;
|
||
}
|
||
|
||
function dorisCatalogId(connectionId: string, catalog: string): string {
|
||
return `${connectionId}:doris-catalog:${nodeIdPart(catalog)}`;
|
||
}
|
||
|
||
function dorisCatalogDatabaseId(connectionId: string, catalog: string, database: string): string {
|
||
return `${dorisCatalogId(connectionId, catalog)}:${nodeIdPart(database)}`;
|
||
}
|
||
|
||
function sqlServerLinkedRuntimeDatabase(config?: ConnectionConfig): string {
|
||
return config?.database?.trim() || "master";
|
||
}
|
||
|
||
function sqlServerLinkedRootNode(connectionId: string, database: string): TreeNode {
|
||
return {
|
||
id: sqlServerLinkedRootId(connectionId),
|
||
label: "tree.linkedServers",
|
||
type: "linked-server-root",
|
||
connectionId,
|
||
database,
|
||
isExpanded: false,
|
||
children: [],
|
||
};
|
||
}
|
||
|
||
function ensureSqlServerLinkedRootNode(connectionId: string, children: TreeNode[], config?: ConnectionConfig): TreeNode[] {
|
||
if (config?.db_type !== "sqlserver") return children;
|
||
if (children.some((child) => child.type === "linked-server-root" || child.id === sqlServerLinkedRootId(connectionId))) {
|
||
return children;
|
||
}
|
||
return [...children, sqlServerLinkedRootNode(connectionId, sqlServerLinkedRuntimeDatabase(config))];
|
||
}
|
||
|
||
// Temporary storage for DataGrip import payload (used to read Keychain passwords after import)
|
||
let pendingDataGripPayload: { format: "datagrip-import"; dataSources: string; dataSourcesLocal?: string; dbForestConfig?: string } | null = null;
|
||
|
||
interface TreeClipboardTableEntry {
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName: string;
|
||
tableComment?: string | null;
|
||
}
|
||
|
||
interface TreeClipboardConnectionEntry {
|
||
config: ConnectionConfig;
|
||
sourceGroupId: string | null;
|
||
}
|
||
|
||
export type TreeClipboard =
|
||
| {
|
||
kind: "table-copy";
|
||
tables: TreeClipboardTableEntry[];
|
||
}
|
||
| {
|
||
kind: "connection-copy";
|
||
connections: TreeClipboardConnectionEntry[];
|
||
};
|
||
|
||
interface LoadTreeOptions {
|
||
force?: boolean;
|
||
connectedOnly?: boolean;
|
||
expectedSidebarSearchQuery?: string;
|
||
searchFilter?: string;
|
||
sidebarTableSearchParentId?: string;
|
||
expectedSidebarTableSearchQuery?: string;
|
||
tableNameFilterScopeKey?: string;
|
||
expectedTableNameFilterRevision?: number;
|
||
}
|
||
|
||
interface PersistedTreeChildrenLoadResult {
|
||
hit: boolean;
|
||
isStale: boolean;
|
||
}
|
||
|
||
type MetadataListPageResult = TableInfo[] | ObjectInfo[];
|
||
|
||
type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
|
||
|
||
export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled";
|
||
|
||
function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
|
||
return config?.driver_profile || config?.db_type;
|
||
}
|
||
|
||
export const useConnectionStore = defineStore("connection", () => {
|
||
const settingsStore = useSettingsStore();
|
||
const tunnelProfileStore = useTunnelProfileStore();
|
||
const connections = ref<ConnectionConfig[]>([]);
|
||
const isDesktop = isTauriRuntime();
|
||
const activeConnectionId = ref<string | null>(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY));
|
||
const selectedTreeNodeId = ref<string | null>(null);
|
||
const selectedTreeNodeIds = ref<string[]>([]);
|
||
// O(1) membership set — rebuilds only when selectedTreeNodeIds changes.
|
||
// Avoids O(N) Array.includes() in every visible TreeItem's isMultiSelected
|
||
// computed during scrolling and selection changes.
|
||
const selectedTreeNodeIdsSet = computed(() => new Set(selectedTreeNodeIds.value));
|
||
const treeSelectionAnchorId = ref<string | null>(null);
|
||
const connectionMultiSelectActive = ref(false);
|
||
const treeClipboard = ref<TreeClipboard | null>(null);
|
||
|
||
watch(activeConnectionId, (id) => {
|
||
if (id) localStorage.setItem(ACTIVE_CONNECTION_STORAGE_KEY, id);
|
||
else localStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY);
|
||
});
|
||
const treeNodes = ref<TreeNode[]>([]);
|
||
const sidebarDatabaseStorageCache = new Map<string, { expiresAt: number; value: DatabaseStorageInfo[] }>();
|
||
const sidebarDatabaseStorageInFlight = new Map<string, Promise<DatabaseStorageInfo[]>>();
|
||
const sidebarTableStorageCache = new Map<string, { expiresAt: number; value: ObjectStatistics[] }>();
|
||
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
|
||
const pinnedTreeNodeOrder = ref<string[]>([]);
|
||
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
|
||
const activePinnedTreeNodeReorderKey = ref<string | null>(null);
|
||
let pinnedTreeNodePersistQueue: Promise<void> = Promise.resolve();
|
||
const connectedIds = ref<Set<string>>(new Set());
|
||
const identifierQuotes = ref<Record<string, string>>({});
|
||
const lastConnectionHealthCheckAt = ref<Record<string, number>>({});
|
||
const agentDrivers = ref<AgentDriverInstallState[]>([]);
|
||
let agentDriversRefreshPromise: Promise<void> | null = null;
|
||
let localAgentDriversRefreshPromise: Promise<void> | null = null;
|
||
const loadedTreeNodeChildrenIds = ref<Set<string>>(new Set());
|
||
/** Simple-mode database/schema nodes loaded successfully with zero objects (not refresh stale shells). */
|
||
const confirmedEmptyTreeNodeIds = ref<Set<string>>(new Set());
|
||
const connectionErrors = ref<Record<string, string>>({});
|
||
const connectingIds = ref<Set<string>>(new Set());
|
||
const editingConnectionId = ref<string | null>(null);
|
||
const newConnectionGroupId = ref<string | null>(null);
|
||
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
|
||
const completionObjectsCache = ref<Record<string, SqlCompletionObject[]>>({});
|
||
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
|
||
const completionForeignKeysCache = ref<Record<string, ForeignKeyInfo[]>>({});
|
||
const completionDatabasesCache = ref<Record<string, string[]>>({});
|
||
const sqlServerCompletionContextCache = ref<Record<string, SqlServerCompletionContext>>({});
|
||
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
|
||
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
|
||
const mongoCompletionCollectionsCache = ref<Record<string, string[]>>({});
|
||
const mongoCompletionFieldsCache = ref<Record<string, MongoCompletionField[]>>({});
|
||
const schemaListCache = ref<Record<string, string[]>>({});
|
||
const sidebarSearchQuery = ref("");
|
||
const sidebarTableSearchQueries = ref<Record<string, string>>({});
|
||
const sidebarTableNameFilters = ref<Record<string, TableNameFilter>>(loadSidebarTableNameFilters());
|
||
const sidebarTableNameFilterRevisions = new Map<string, number>();
|
||
const completionTableIndex = new Map<string, { touched: number; tables: SqlCompletionTable[] }>();
|
||
const completionObjectIndex = new Map<string, { touched: number; objects: SqlCompletionObject[] }>();
|
||
const completionColumnIndex = new Map<string, { touched: number; columns: SqlCompletionColumn[] }>();
|
||
const completionForeignKeyIndex = new Map<string, { touched: number; foreignKeys: SqlCompletionForeignKey[] }>();
|
||
const completionInFlight = new Map<string, Promise<unknown>>();
|
||
const completionMetadataLimiter = new MetadataTaskLimiter(COMPLETION_METADATA_CONCURRENCY, (event) => {
|
||
console.debug("[DBX][completion-metadata:limit]", event);
|
||
});
|
||
const transferSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
catalog?: string;
|
||
schema?: string;
|
||
tables?: string[];
|
||
targetConnectionId?: string;
|
||
targetDatabase?: string;
|
||
targetSchema?: string;
|
||
} | null>(null);
|
||
const schemaDiffSource = ref<{ connectionId: string; database: string; schema?: string } | null>(null);
|
||
const dataCompareSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName?: string;
|
||
} | null>(null);
|
||
const sqlFileSource = ref<{ connectionId: string; database: string; filePath?: string } | null>(null);
|
||
const diagramSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName?: string;
|
||
} | null>(null);
|
||
const tableImportSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName?: string;
|
||
} | null>(null);
|
||
const tableDataGenerateSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName: string;
|
||
} | null>(null);
|
||
const fieldLineageSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName: string;
|
||
columnName: string;
|
||
} | null>(null);
|
||
const databaseSearchSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
} | null>(null);
|
||
const databaseExportSource = ref<{
|
||
connectionId: string;
|
||
database: string;
|
||
schema?: string;
|
||
tableName?: string;
|
||
tableNames?: string[];
|
||
allDatabases?: boolean;
|
||
} | null>(null);
|
||
const sidebarLayout = ref<SidebarLayout>(emptyLayout());
|
||
const connectionGroupPaths = computed(() => buildConnectionGroupPathMap(sidebarLayout.value));
|
||
let layoutPersistTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const staleTreeRefreshIds = new Set<string>();
|
||
const activeTreeRefreshGenerations = new Map<string, number>();
|
||
let nextTreeRefreshGeneration = 0;
|
||
const metadataLoadCoordinator = new MetadataLoadCoordinator((event) => {
|
||
console.debug("[DBX][metadata-load:coordinator]", event);
|
||
});
|
||
const metadataListPageCache = new MetadataResultCache<MetadataListPageResult>({
|
||
ttlMs: METADATA_LIST_PAGE_CACHE_TTL_MS,
|
||
maxEntries: METADATA_LIST_PAGE_CACHE_MAX_ENTRIES,
|
||
});
|
||
const metadataTraceLogger: MetadataLoadTraceLogger = (event) => {
|
||
console.debug("[DBX][metadata-load:trace]", event);
|
||
};
|
||
const connectInFlight = new Map<string, Promise<void>>();
|
||
const disconnectInFlight = new Map<string, Promise<void>>();
|
||
const disconnectInFlightScoped = new Map<string, boolean>();
|
||
const cancelDisconnectInFlight = new Map<string, Promise<void>>();
|
||
const activeLocalConnectionAttempts = new Map<string, number>();
|
||
const cancelledLocalConnectionAttempts = new Map<string, Set<number>>();
|
||
const successfulLocalConnectionAttempts = new Map<string, number>();
|
||
const connectionStateRevisions = new Map<string, number>();
|
||
const connectionErrorRevisions = new Map<string, number>();
|
||
const treeNodeLoads = new TreeNodeLoadRegistry();
|
||
let nextLocalConnectionAttempt = 0;
|
||
let beforeConnectHandler: BeforeConnectHandler | null = null;
|
||
let initFromDiskPromise: Promise<void> | null = null;
|
||
|
||
// Loading/stale ownership stays on TreeNodeLoadRegistry (per-node generation), not the
|
||
// coordinator: many specialty loaders bypass runTreeMetadataLoad, and coordinator would
|
||
// otherwise need TreeNode/connected awareness. Keep coordinator for scope dedupe only.
|
||
// connectionStateRevision remains for disconnect/error cleanup; reconnect also invalidates
|
||
// tree loads via bump → treeNodeLoads.invalidateConnection.
|
||
function runTreeMetadataLoad<T>(scope: MetadataScopeInput, task: () => Promise<T>, options?: LoadTreeOptions): Promise<T> {
|
||
return metadataLoadCoordinator.run(scope, task, { force: options?.force, kind: scope.kind });
|
||
}
|
||
|
||
async function loadCachedMetadataListPage<T extends MetadataListPageResult>(scope: MetadataScopeInput, load: () => Promise<T>, options?: { force?: boolean }): Promise<T> {
|
||
const trace = createMetadataLoadTrace(scope);
|
||
if (!options?.force) {
|
||
const cached = metadataListPageCache.get(scope);
|
||
if (cached) {
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", {
|
||
cacheStatus: cached.stale ? "stale" : "hit",
|
||
resultCount: cached.value.length,
|
||
stale: cached.stale,
|
||
});
|
||
return cached.value as T;
|
||
}
|
||
}
|
||
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: options?.force ? "refresh" : "miss", force: options?.force === true });
|
||
const errorRevision = connectionErrorRevision(scope.connectionId);
|
||
const result = await load();
|
||
clearConnectionErrorIfUnchanged(scope.connectionId, errorRevision);
|
||
metadataListPageCache.set(scope, result);
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "done", {
|
||
cacheStatus: options?.force ? "refresh" : "miss",
|
||
resultCount: result.length,
|
||
force: options?.force === true,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
function startEditing(id: string) {
|
||
editingConnectionId.value = id;
|
||
}
|
||
|
||
function stopEditing() {
|
||
editingConnectionId.value = null;
|
||
}
|
||
|
||
function startCreatingConnectionInGroup(groupId: string) {
|
||
stopEditing();
|
||
newConnectionGroupId.value = groupId;
|
||
}
|
||
|
||
function stopCreatingConnectionInGroup() {
|
||
newConnectionGroupId.value = null;
|
||
}
|
||
|
||
const configById = computed(() => new Map(connections.value.map((c) => [c.id, c])));
|
||
|
||
function getConfig(connectionId: string) {
|
||
return configById.value.get(connectionId);
|
||
}
|
||
|
||
function connectionIdentifierQuote(connectionId?: string): string | undefined {
|
||
if (!connectionId) return undefined;
|
||
const override = gaussdbIdentifierQuoteOverride(getConfig(connectionId));
|
||
if (override != null) return override;
|
||
return identifierQuotes.value[connectionId];
|
||
}
|
||
|
||
function clearConnectionIdentifierQuote(connectionId: string) {
|
||
if (!(connectionId in identifierQuotes.value)) return;
|
||
const next = { ...identifierQuotes.value };
|
||
delete next[connectionId];
|
||
identifierQuotes.value = next;
|
||
}
|
||
|
||
async function refreshConnectionIdentifierQuote(connectionId: string, config: ConnectionConfig) {
|
||
clearConnectionIdentifierQuote(connectionId);
|
||
if (!connectionShouldLoadIdentifierQuote(config)) return;
|
||
const quote = await api.connectionIdentifierQuote(connectionId).catch(() => undefined);
|
||
if (quote != null) identifierQuotes.value = { ...identifierQuotes.value, [connectionId]: quote };
|
||
}
|
||
|
||
function connectionErrorMessage(error: unknown): string {
|
||
if (error instanceof Error) return error.message;
|
||
return String(error);
|
||
}
|
||
|
||
function isSupersededConnectionAttempt(error: unknown): boolean {
|
||
return connectionErrorMessage(error).includes(SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE);
|
||
}
|
||
|
||
function isCancelledConnectionAttempt(error: unknown): boolean {
|
||
return connectionErrorMessage(error).includes(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
|
||
}
|
||
|
||
function beginLocalConnectionAttempt(connectionId: string): number {
|
||
const attempt = ++nextLocalConnectionAttempt;
|
||
bumpConnectionStateRevision(connectionId);
|
||
activeLocalConnectionAttempts.set(connectionId, attempt);
|
||
connectingIds.value.add(connectionId);
|
||
const node = findConnectionNode(connectionId);
|
||
if (node) node.isLoading = true;
|
||
return attempt;
|
||
}
|
||
|
||
function markSuccessfulLocalConnectionAttempt(connectionId: string, attempt: number) {
|
||
successfulLocalConnectionAttempts.set(connectionId, attempt);
|
||
}
|
||
|
||
function forgetSuccessfulLocalConnectionAttempt(connectionId: string) {
|
||
successfulLocalConnectionAttempts.delete(connectionId);
|
||
}
|
||
|
||
function bumpConnectionStateRevision(connectionId: string): number {
|
||
const revision = (connectionStateRevisions.get(connectionId) ?? 0) + 1;
|
||
connectionStateRevisions.set(connectionId, revision);
|
||
// Invalidate per-node load generations under this connection (and clear sticky
|
||
// spinners on surviving nodes). Active loaders reclaim after ensureConnected.
|
||
treeNodeLoads.invalidateConnection(connectionId, findConnectionNode(connectionId));
|
||
return revision;
|
||
}
|
||
|
||
function connectionStateRevision(connectionId: string): number {
|
||
return connectionStateRevisions.get(connectionId) ?? 0;
|
||
}
|
||
|
||
function isCurrentConnectionStateRevision(connectionId: string, revision: number): boolean {
|
||
return connectionStateRevision(connectionId) === revision;
|
||
}
|
||
|
||
function isCurrentLocalConnectionAttempt(connectionId: string, attempt: number): boolean {
|
||
return activeLocalConnectionAttempts.get(connectionId) === attempt;
|
||
}
|
||
|
||
function isCancelledLocalConnectionAttempt(connectionId: string, attempt: number): boolean {
|
||
return cancelledLocalConnectionAttempts.get(connectionId)?.has(attempt) === true;
|
||
}
|
||
|
||
function getLocalConnectionAttempt(connectionId: string): number | undefined {
|
||
return activeLocalConnectionAttempts.get(connectionId);
|
||
}
|
||
|
||
function finishLocalConnectionAttempt(connectionId: string, attempt: number) {
|
||
if (isCancelledLocalConnectionAttempt(connectionId, attempt)) {
|
||
const attempts = cancelledLocalConnectionAttempts.get(connectionId);
|
||
attempts?.delete(attempt);
|
||
if (attempts?.size === 0) {
|
||
cancelledLocalConnectionAttempts.delete(connectionId);
|
||
}
|
||
}
|
||
if (!isCurrentLocalConnectionAttempt(connectionId, attempt)) return;
|
||
activeLocalConnectionAttempts.delete(connectionId);
|
||
connectingIds.value.delete(connectionId);
|
||
clearConnectionNodeLoading(connectionId);
|
||
}
|
||
|
||
function cancelLocalConnectionAttempt(connectionId: string): boolean {
|
||
const attempt = activeLocalConnectionAttempts.get(connectionId);
|
||
if (attempt == null) return false;
|
||
const attempts = cancelledLocalConnectionAttempts.get(connectionId) ?? new Set<number>();
|
||
attempts.add(attempt);
|
||
cancelledLocalConnectionAttempts.set(connectionId, attempts);
|
||
activeLocalConnectionAttempts.delete(connectionId);
|
||
connectingIds.value.delete(connectionId);
|
||
clearConnectionNodeLoading(connectionId);
|
||
clearConnectionRootMetadataLoad(connectionId);
|
||
connectInFlight.delete(connectionId);
|
||
return true;
|
||
}
|
||
|
||
function clearConnectionRootMetadataLoad(connectionId: string) {
|
||
metadataLoadCoordinator.clear({
|
||
kind: "connection-databases",
|
||
connectionId,
|
||
driverProfile: metadataDriverProfile(getConfig(connectionId)),
|
||
});
|
||
}
|
||
|
||
function getBlockingDisconnectInFlight(connectionId: string): Promise<void> | undefined {
|
||
return disconnectInFlightScoped.get(connectionId) ? undefined : disconnectInFlight.get(connectionId);
|
||
}
|
||
|
||
async function waitForBlockingDisconnectInFlight(connectionId: string): Promise<void> {
|
||
const pending = getBlockingDisconnectInFlight(connectionId);
|
||
if (pending) await pending;
|
||
}
|
||
|
||
function trackDisconnectRequest(connectionId: string, request: Promise<void>, scoped: boolean): Promise<void> {
|
||
const bounded = withDisconnectRequestTimeout(connectionId, request);
|
||
const tracked = bounded
|
||
.catch((error) => {
|
||
console.warn("[DBX][connection:disconnect-error]", { connectionId, error });
|
||
})
|
||
.finally(() => {
|
||
if (disconnectInFlight.get(connectionId) === tracked) {
|
||
disconnectInFlight.delete(connectionId);
|
||
disconnectInFlightScoped.delete(connectionId);
|
||
}
|
||
});
|
||
disconnectInFlight.set(connectionId, tracked);
|
||
disconnectInFlightScoped.set(connectionId, scoped);
|
||
return bounded;
|
||
}
|
||
|
||
function startDisconnectRequest(connectionId: string): Promise<void> {
|
||
const clientAttempt = activeLocalConnectionAttempts.get(connectionId) ?? successfulLocalConnectionAttempts.get(connectionId);
|
||
let request: Promise<void>;
|
||
try {
|
||
request = api.disconnectDb(connectionId, clientAttempt);
|
||
} catch (error) {
|
||
request = Promise.reject(error);
|
||
}
|
||
return trackDisconnectRequest(connectionId, request, clientAttempt != null);
|
||
}
|
||
|
||
function cancelDisconnectKey(connectionId: string, attempt: number): string {
|
||
return `${connectionId}:${attempt}`;
|
||
}
|
||
|
||
function startCancelDisconnectRequest(connectionId: string, attempt: number): Promise<void> {
|
||
const key = cancelDisconnectKey(connectionId, attempt);
|
||
const existing = cancelDisconnectInFlight.get(key);
|
||
if (existing) return existing;
|
||
let request: Promise<void>;
|
||
try {
|
||
request = api.disconnectDb(connectionId, attempt);
|
||
} catch (error) {
|
||
request = Promise.reject(error);
|
||
}
|
||
const tracked = withDisconnectRequestTimeout(connectionId, request)
|
||
.catch((error) => {
|
||
console.warn("[DBX][connection:cancel-disconnect-error]", { connectionId, attempt, error });
|
||
throw error;
|
||
})
|
||
.finally(() => {
|
||
if (cancelDisconnectInFlight.get(key) === tracked) {
|
||
cancelDisconnectInFlight.delete(key);
|
||
}
|
||
});
|
||
cancelDisconnectInFlight.set(key, tracked);
|
||
return tracked;
|
||
}
|
||
|
||
async function cleanupResolvedCancelledConnectionAttempt(connectionId: string, attempt: number) {
|
||
try {
|
||
// A cancel request can reach the backend before connect_db registers the
|
||
// attempt, so clean again if that cancelled connect later returns a pool.
|
||
await withDisconnectRequestTimeout(connectionId, api.disconnectDb(connectionId, attempt));
|
||
} catch (error) {
|
||
console.warn("[DBX][connection:cancel-result-cleanup-error]", { connectionId, attempt, error });
|
||
}
|
||
}
|
||
|
||
async function ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId: string, attempt: number, cleanupConnectionId: string) {
|
||
if (isCancelledLocalConnectionAttempt(connectionId, attempt)) {
|
||
await cleanupResolvedCancelledConnectionAttempt(cleanupConnectionId, attempt);
|
||
throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
|
||
}
|
||
ensureLocalConnectionAttemptActive(connectionId, attempt);
|
||
}
|
||
|
||
function ensureLocalConnectionAttemptActive(connectionId: string, attempt: number) {
|
||
if (isCancelledLocalConnectionAttempt(connectionId, attempt)) {
|
||
throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
|
||
}
|
||
if (!isCurrentLocalConnectionAttempt(connectionId, attempt)) {
|
||
throw new Error(SUPERSEDED_CONNECTION_ATTEMPT_MESSAGE);
|
||
}
|
||
}
|
||
|
||
function setConnectionError(connectionId: string, message: string) {
|
||
connectionErrors.value[connectionId] = message;
|
||
connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1);
|
||
}
|
||
|
||
function updateConnectionErrorPresentation(connectionId: string, expectedMessage: string, message: string) {
|
||
// Hints decorate the current error event and must not invalidate a retry's recovery revision.
|
||
if (connectionErrors.value[connectionId] !== expectedMessage) return;
|
||
connectionErrors.value[connectionId] = message;
|
||
}
|
||
|
||
function connectionErrorRevision(connectionId?: string | null): number {
|
||
return connectionId ? (connectionErrorRevisions.get(connectionId) ?? 0) : 0;
|
||
}
|
||
|
||
function clearConnectionErrorIfUnchanged(connectionId: string | null | undefined, revision: number) {
|
||
if (!connectionId || connectionErrorRevision(connectionId) !== revision) return;
|
||
clearConnectionError(connectionId);
|
||
}
|
||
|
||
function agentDriverUpdateHint(): string {
|
||
return i18n.global.t("connection.agentDriverUpdateConnectionHint");
|
||
}
|
||
|
||
function connectionErrorWithDriverUpdateHint(config: ConnectionConfig | undefined, message: string): string {
|
||
if (!config) return message;
|
||
message = appendConnectionErrorHints(config, message, i18n.global.t);
|
||
if (!hasAgentDriverUpdate(config.db_type, agentDrivers.value, config.driver_profile)) return message;
|
||
return appendAgentDriverUpdateHint(message, agentDriverUpdateHint());
|
||
}
|
||
|
||
function refreshAgentDriversForErrorHint(): Promise<void> {
|
||
if (agentDriversRefreshPromise) return agentDriversRefreshPromise;
|
||
agentDriversRefreshPromise = api
|
||
.listInstalledAgents()
|
||
.then((drivers) => {
|
||
agentDrivers.value = drivers;
|
||
})
|
||
.catch(() => undefined)
|
||
.finally(() => {
|
||
agentDriversRefreshPromise = null;
|
||
});
|
||
return agentDriversRefreshPromise;
|
||
}
|
||
|
||
function refreshLocalAgentDrivers(): Promise<void> {
|
||
if (localAgentDriversRefreshPromise) return localAgentDriversRefreshPromise;
|
||
localAgentDriversRefreshPromise = api
|
||
.listInstalledAgentsLocal()
|
||
.then((drivers) => {
|
||
agentDrivers.value = drivers;
|
||
})
|
||
.catch(() => undefined)
|
||
.finally(() => {
|
||
localAgentDriversRefreshPromise = null;
|
||
});
|
||
return localAgentDriversRefreshPromise;
|
||
}
|
||
|
||
async function supportsXuguTableChildMetadata(): Promise<boolean> {
|
||
if (!hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION)) {
|
||
await refreshLocalAgentDrivers();
|
||
}
|
||
return hasInstalledAgentVersion(agentDrivers.value, "xugu", XUGU_TABLE_CHILD_METADATA_AGENT_VERSION);
|
||
}
|
||
|
||
function maybeAppendAgentDriverUpdateHint(connectionId: string, baseMessage: string) {
|
||
const config = getConfig(connectionId);
|
||
const message = connectionErrorWithDriverUpdateHint(config, baseMessage);
|
||
if (message !== baseMessage) {
|
||
updateConnectionErrorPresentation(connectionId, baseMessage, message);
|
||
return;
|
||
}
|
||
void refreshAgentDriversForErrorHint().then(() => {
|
||
if (connectionErrors.value[connectionId] !== baseMessage) return;
|
||
const refreshedMessage = connectionErrorWithDriverUpdateHint(config, baseMessage);
|
||
if (refreshedMessage !== baseMessage) updateConnectionErrorPresentation(connectionId, baseMessage, refreshedMessage);
|
||
});
|
||
}
|
||
|
||
function clearConnectionError(connectionId: string) {
|
||
if (!connectionErrors.value[connectionId]) return;
|
||
delete connectionErrors.value[connectionId];
|
||
connectionErrorRevisions.set(connectionId, connectionErrorRevision(connectionId) + 1);
|
||
}
|
||
|
||
function markConnectionHealthChecked(connectionId: string) {
|
||
lastConnectionHealthCheckAt.value[connectionId] = Date.now();
|
||
}
|
||
|
||
function clearConnectionHealthCheck(connectionId: string) {
|
||
if (!lastConnectionHealthCheckAt.value[connectionId]) return;
|
||
delete lastConnectionHealthCheckAt.value[connectionId];
|
||
}
|
||
|
||
function hasRecentConnectionHealthCheck(connectionId: string) {
|
||
const checkedAt = lastConnectionHealthCheckAt.value[connectionId];
|
||
return typeof checkedAt === "number" && Date.now() - checkedAt < CONNECTION_HEALTH_CHECK_TTL_MS;
|
||
}
|
||
|
||
function clearConnectionNodeLoading(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (node) node.isLoading = false;
|
||
}
|
||
|
||
function metadataLoadTimeoutMs(config?: ConnectionConfig): number {
|
||
const queryTimeoutSecs = Number(config?.query_timeout_secs);
|
||
if (queryTimeoutSecs === 0) return METADATA_LOAD_DISABLED_QUERY_TIMEOUT_MS;
|
||
const boundedTimeoutSecs = Number.isFinite(queryTimeoutSecs) && queryTimeoutSecs > 0 ? queryTimeoutSecs + 5 : 35;
|
||
return Math.max(METADATA_LOAD_MIN_TIMEOUT_MS, boundedTimeoutSecs * 1000);
|
||
}
|
||
|
||
async function withConnectionHealthTimeout(connectionId: string, promise: Promise<void>): Promise<void> {
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
reject(new Error(`Connection health check timed out after ${Math.ceil(CONNECTION_HEALTH_CHECK_TIMEOUT_MS / 1000)}s.`));
|
||
}, CONNECTION_HEALTH_CHECK_TIMEOUT_MS);
|
||
}),
|
||
]);
|
||
} catch (error) {
|
||
clearConnectionNodeLoading(connectionId);
|
||
throw error;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function withMetadataLoadTimeout<T>(connectionId: string, promise: Promise<T>, label: string): Promise<T> {
|
||
const timeoutMs = metadataLoadTimeoutMs(getConfig(connectionId));
|
||
const errorRevision = connectionErrorRevision(connectionId);
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
try {
|
||
const result = await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
reject(new Error(`Connection timed out while loading ${label} after ${Math.ceil(timeoutMs / 1000)}s. Please check the network or VPN and try again.`));
|
||
}, timeoutMs);
|
||
}),
|
||
]);
|
||
clearConnectionErrorIfUnchanged(connectionId, errorRevision);
|
||
return result;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function withDisconnectRequestTimeout(connectionId: string, promise: Promise<void>): Promise<void> {
|
||
let timedOut = false;
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
void promise.catch((error) => {
|
||
if (timedOut) console.warn("[DBX][connection:disconnect-late-error]", { connectionId, error });
|
||
});
|
||
try {
|
||
await Promise.race([
|
||
promise,
|
||
new Promise<void>((resolve) => {
|
||
timer = setTimeout(() => {
|
||
timedOut = true;
|
||
console.warn("[DBX][connection:disconnect-timeout]", { connectionId, timeoutMs: DISCONNECT_REQUEST_TIMEOUT_MS });
|
||
resolve();
|
||
}, DISCONNECT_REQUEST_TIMEOUT_MS);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function recordConnectionError(connectionId: string, error: unknown): string {
|
||
const message = connectionErrorMessage(error);
|
||
if (isCancelledConnectionAttempt(message)) {
|
||
clearConnectionError(connectionId);
|
||
return "";
|
||
}
|
||
setConnectionError(connectionId, message);
|
||
maybeAppendAgentDriverUpdateHint(connectionId, message);
|
||
return message;
|
||
}
|
||
|
||
function markConnectionLost(connectionId: string, error: unknown) {
|
||
connectedIds.value.delete(connectionId);
|
||
clearConnectionIdentifierQuote(connectionId);
|
||
clearConnectionNodeLoading(connectionId);
|
||
clearConnectionHealthCheck(connectionId);
|
||
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
||
recordConnectionError(connectionId, error);
|
||
}
|
||
|
||
function recordConnectionLostError(connectionId: string, error: unknown): boolean {
|
||
if (shouldMarkDisconnected(error)) {
|
||
markConnectionLost(connectionId, error);
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Metadata loaders keep this internal: match connection-loss errors before recording generic errors.
|
||
function recordMetadataLoadError(connectionId: string, error: unknown, load?: TreeNodeLoadHandle) {
|
||
if (load && !load.isCurrent()) return;
|
||
if (recordConnectionLostError(connectionId, error)) return;
|
||
recordConnectionError(connectionId, error);
|
||
}
|
||
|
||
async function withConnectionAttemptTimeout<T>(promise: Promise<T>, config: ConnectionConfig): Promise<T> {
|
||
const timeoutMs = connectionAttemptTimeoutMs(config, tunnelProfileStore.profileById);
|
||
const timeoutMessage = connectionAttemptTimeoutMessage(timeoutMs);
|
||
let timedOut = false;
|
||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||
void promise.then(
|
||
(connectionId) => {
|
||
if (!timedOut) return;
|
||
const cleanupConnectionId = typeof connectionId === "string" && connectionId ? connectionId : config.id;
|
||
if (connectedIds.value.has(cleanupConnectionId)) return;
|
||
void api.disconnectDb(cleanupConnectionId).catch((error) => {
|
||
console.warn("[DBX][connection:timeout-cleanup-failed]", { connectionId: cleanupConnectionId, error });
|
||
});
|
||
},
|
||
(error) => {
|
||
if (!timedOut) return;
|
||
const current = connectionErrors.value[config.id];
|
||
if (current !== timeoutMessage) return;
|
||
setConnectionError(config.id, connectionAttemptOriginalErrorMessage(timeoutMessage, connectionErrorMessage(error)));
|
||
},
|
||
);
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise<never>((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
timedOut = true;
|
||
reject(new Error(timeoutMessage));
|
||
}, timeoutMs);
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function normalizeConnection(config: ConnectionConfig): ConnectionConfig {
|
||
config = { ...config };
|
||
migrateSqlServerLegacyCompatibilityConfig(config);
|
||
const labelMap: Record<string, string> = {
|
||
mysql: "MySQL",
|
||
postgres: "PostgreSQL",
|
||
sqlite: "SQLite",
|
||
redis: "Redis",
|
||
etcd: "etcd",
|
||
zookeeper: "Apache ZooKeeper",
|
||
duckdb: "DuckDB",
|
||
clickhouse: "ClickHouse",
|
||
sqlserver: "SQL Server",
|
||
mongodb: "MongoDB",
|
||
oracle: "Oracle",
|
||
"mongodb-legacy": MONGO_LEGACY_DRIVER_LABEL,
|
||
elasticsearch: "Elasticsearch",
|
||
easysearch: "Easysearch",
|
||
qdrant: "Qdrant",
|
||
milvus: "Milvus",
|
||
weaviate: "Weaviate",
|
||
chromadb: "ChromaDB",
|
||
doris: "Doris",
|
||
starrocks: "StarRocks",
|
||
manticoresearch: "Manticore Search",
|
||
redshift: "Redshift",
|
||
dameng: "达梦 Dameng",
|
||
gaussdb: "GaussDB",
|
||
questdb: "QuestDB",
|
||
kwdb: "KWDB",
|
||
kingbase: "KingBase",
|
||
highgo: "瀚高 HighGo",
|
||
uxdb: "优炫 UXDB",
|
||
yashandb: "崖山 YashanDB",
|
||
vastbase: "Vastbase",
|
||
goldendb: "GoldenDB",
|
||
access: "Microsoft Access",
|
||
h2: "H2",
|
||
snowflake: "Snowflake",
|
||
trino: "Trino",
|
||
prestosql: "PrestoSQL",
|
||
hive: "Hive",
|
||
spark: "Apache Spark",
|
||
db2: "DB2",
|
||
informix: "Informix",
|
||
neo4j: "Neo4j",
|
||
cassandra: "Cassandra",
|
||
bigquery: "BigQuery",
|
||
kylin: "Kylin",
|
||
sundb: "SunDB",
|
||
oscar: "神通 OSCAR",
|
||
influxdb: "InfluxDB",
|
||
};
|
||
|
||
const profile = config.driver_profile || config.db_type;
|
||
let dbType = config.db_type;
|
||
if ((profile === "gaussdb" || profile === "opengauss") && dbType === "postgres") {
|
||
dbType = "gaussdb" as ConnectionConfig["db_type"];
|
||
} else if (profile === "kwdb" && dbType === "postgres") {
|
||
dbType = "kwdb" as ConnectionConfig["db_type"];
|
||
} else if (profile === "questdb" && dbType === "postgres") {
|
||
dbType = "questdb" as ConnectionConfig["db_type"];
|
||
} else if (profile === "redshift" && dbType === "postgres") {
|
||
dbType = "redshift" as ConnectionConfig["db_type"];
|
||
} else if (profile === "kingbase" && dbType === "postgres") {
|
||
dbType = "kingbase" as ConnectionConfig["db_type"];
|
||
} else if (profile === "highgo" && dbType === "postgres") {
|
||
dbType = "highgo" as ConnectionConfig["db_type"];
|
||
} else if (profile === "uxdb" && dbType === "postgres") {
|
||
dbType = "uxdb" as ConnectionConfig["db_type"];
|
||
} else if (profile === "vastbase" && dbType === "postgres") {
|
||
dbType = "vastbase" as ConnectionConfig["db_type"];
|
||
} else if (profile === "goldendb" && dbType === "mysql") {
|
||
dbType = "goldendb" as ConnectionConfig["db_type"];
|
||
}
|
||
|
||
return {
|
||
...config,
|
||
db_type: dbType,
|
||
driver_profile: profile,
|
||
driver_label: config.driver_label || labelMap[profile] || config.db_type,
|
||
url_params: config.url_params || "",
|
||
agent_java_options: Array.isArray(config.agent_java_options) ? config.agent_java_options : [],
|
||
attached_databases: Array.isArray(config.attached_databases) ? config.attached_databases.filter((database) => database.name?.trim() && database.path?.trim()) : [],
|
||
init_script: config.init_script?.trim() ? config.init_script : undefined,
|
||
transport_layers: Array.isArray(config.transport_layers) ? config.transport_layers : [],
|
||
show_system_schemas: config.show_system_schemas === true,
|
||
connect_timeout_secs: config.connect_timeout_secs || 10,
|
||
query_timeout_secs: config.query_timeout_secs ?? 30,
|
||
idle_timeout_secs: config.idle_timeout_secs ?? 60,
|
||
keepalive_interval_secs: config.keepalive_interval_secs ?? DEFAULT_KEEPALIVE_INTERVAL_SECS,
|
||
redis_database_aliases: normalizeRedisDatabaseAliases(config.redis_database_aliases),
|
||
database_info: normalizeDatabaseConnectionInfo(config.database_info),
|
||
};
|
||
}
|
||
|
||
function loadPinnedTreeNodeOrderFromLocalStorage(): string[] {
|
||
try {
|
||
if (typeof localStorage === "undefined") return [];
|
||
const saved = localStorage.getItem(PINNED_TREE_NODES_STORAGE_KEY);
|
||
const ids = saved ? JSON.parse(saved) : [];
|
||
return normalizePinnedTreeNodeOrder(Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : []);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function loadPinnedTreeNodeOrder(): Promise<string[]> {
|
||
if (!isDesktop) return loadPinnedTreeNodeOrderFromLocalStorage();
|
||
const ids = await api.loadPinnedTreeNodeIds().catch(() => []);
|
||
const valid = normalizePinnedTreeNodeOrder(ids.filter((id): id is string => typeof id === "string"));
|
||
if (valid.length > 0) return valid;
|
||
|
||
// Migrate legacy localStorage values for existing desktop users.
|
||
const legacy = loadPinnedTreeNodeOrderFromLocalStorage();
|
||
if (legacy.length > 0) {
|
||
await api.savePinnedTreeNodeIds(legacy).catch(() => undefined);
|
||
if (typeof localStorage !== "undefined") {
|
||
localStorage.removeItem(PINNED_TREE_NODES_STORAGE_KEY);
|
||
}
|
||
}
|
||
return legacy;
|
||
}
|
||
|
||
function setPinnedTreeNodeOrder(order: readonly string[]) {
|
||
const normalized = normalizePinnedTreeNodeOrder(order);
|
||
pinnedTreeNodeOrder.value = normalized;
|
||
pinnedTreeNodeIds.value = new Set(normalized);
|
||
}
|
||
|
||
function persistPinnedTreeNodeIds() {
|
||
const snapshot = [...pinnedTreeNodeOrder.value];
|
||
if (isDesktop) {
|
||
// A later drag must never be persisted before an earlier request finishes:
|
||
// otherwise a slow old request can overwrite the final ordering on disk.
|
||
pinnedTreeNodePersistQueue = pinnedTreeNodePersistQueue.catch(() => undefined).then(() => api.savePinnedTreeNodeIds(snapshot).catch(() => undefined));
|
||
return;
|
||
}
|
||
if (typeof localStorage === "undefined") return;
|
||
localStorage.setItem(PINNED_TREE_NODES_STORAGE_KEY, JSON.stringify(snapshot));
|
||
}
|
||
|
||
function findLoadedTreeNodeById(nodes: readonly TreeNode[], id: string): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return node;
|
||
const child = node.children ? findLoadedTreeNodeById(node.children, id) : null;
|
||
if (child) return child;
|
||
const hiddenChild = node.hiddenChildren ? findLoadedTreeNodeById(node.hiddenChildren, id) : null;
|
||
if (hiddenChild) return hiddenChild;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isTreeNodePinned(node: TreeNode | string): boolean {
|
||
if (typeof node !== "string") return pinnedTreeNodeIds.value.has(treeNodePinKey(node)) || pinnedTreeNodeIds.value.has(node.id);
|
||
if (pinnedTreeNodeIds.value.has(node)) return true;
|
||
const loadedNode = findLoadedTreeNodeById(treeNodes.value, node);
|
||
return !!loadedNode && pinnedTreeNodeIds.value.has(treeNodePinKey(loadedNode));
|
||
}
|
||
|
||
function isFixedPriorityTreeNode(node: TreeNode): boolean {
|
||
if (node.type !== "database" && node.type !== "redis-db" && node.type !== "mongo-db") return false;
|
||
return !!node.connectionId && typeof node.database === "string" && isDefaultDatabase(node.connectionId, node.database);
|
||
}
|
||
|
||
function orderByPinnedTreeNodes<T>(items: readonly T[], matches: (item: T, identity: PinnedTreeNodeIdentity) => boolean): T[] {
|
||
return orderItemsByPinnedTreeNodeOrder(items, pinnedTreeNodeOrder.value, matches, treeNodes.value);
|
||
}
|
||
|
||
function syncPinnedTreeState(nodes: TreeNode[]) {
|
||
syncPinnedTreeNodeStateInPlace(nodes, pinnedTreeNodeIds.value, pinnedTreeNodeOrder.value, isFixedPriorityTreeNode);
|
||
}
|
||
|
||
function isConnectionUtilityNode(node: TreeNode): boolean {
|
||
return node.type === "user-admin" || node.type === "dameng-job-admin";
|
||
}
|
||
|
||
function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] {
|
||
return (children || []).filter((child) => !isConnectionUtilityNode(child));
|
||
}
|
||
|
||
function hasConnectionMetadataChildren(children: TreeNode[] | undefined): boolean {
|
||
return connectionMetadataChildren(children).length > 0;
|
||
}
|
||
|
||
function preserveExistingConnectionMetadataChildren(parent: TreeNode, children: TreeNode[]): TreeNode[] {
|
||
if (parent.type !== "connection" || hasConnectionMetadataChildren(children)) return children;
|
||
|
||
const existingMetadataChildren = connectionMetadataChildren(parent.children);
|
||
const nextUtilityChildren = children.filter(isConnectionUtilityNode);
|
||
if (existingMetadataChildren.length === 0 || nextUtilityChildren.length === 0) return children;
|
||
|
||
return [...existingMetadataChildren, ...nextUtilityChildren];
|
||
}
|
||
|
||
// Leaf tree nodes (table columns / indexes / foreign keys / triggers) are
|
||
// immutable data payloads: they never expand, never load children, and their
|
||
// fields are never mutated after creation. A large schema can produce tens of
|
||
// thousands of them, and Vue's deep reactivity wraps every node AND its nested
|
||
// `meta` object in a Proxy — the dominant memory cost of the schema tree.
|
||
// Marking each leaf raw keeps Vue from wrapping it (and, since Vue does not
|
||
// recurse into raw objects, its `meta` too), mirroring the markRaw() treatment
|
||
// queryStore already applies to result rows. Containers stay reactive so their
|
||
// children / isExpanded / isLoading mutations still drive the UI.
|
||
const LEAF_TREE_NODE_TYPES = new Set<TreeNode["type"]>(["column", "index", "fkey", "trigger"]);
|
||
|
||
function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] {
|
||
for (const node of nodes) {
|
||
if (LEAF_TREE_NODE_TYPES.has(node.type)) {
|
||
markRaw(node);
|
||
} else if (node.children && node.children.length > 0) {
|
||
markRawLeafTreeNodes(node.children);
|
||
}
|
||
}
|
||
return nodes;
|
||
}
|
||
|
||
function clearDescendantLoadedChildrenMarkers(parentId: string) {
|
||
const descendantPrefix = `${parentId}:`;
|
||
for (const id of loadedTreeNodeChildrenIds.value) {
|
||
if (id.startsWith(descendantPrefix)) loadedTreeNodeChildrenIds.value.delete(id);
|
||
}
|
||
for (const id of confirmedEmptyTreeNodeIds.value) {
|
||
if (id.startsWith(descendantPrefix)) confirmedEmptyTreeNodeIds.value.delete(id);
|
||
}
|
||
}
|
||
|
||
/** Drop loaded/confirmed-empty markers, metadata caches, and generations for a discarded shell. */
|
||
function forgetTreeNodeLoadState(nodeId: string) {
|
||
clearLoadedChildrenCache(nodeId);
|
||
treeNodeLoads.invalidatePrefix(nodeId);
|
||
}
|
||
|
||
function syncConfirmedEmptyTreeNodeId(parent: TreeNode) {
|
||
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return;
|
||
const childCount = parent.children?.length ?? 0;
|
||
if (childCount === 0) confirmedEmptyTreeNodeIds.value.add(parent.id);
|
||
else confirmedEmptyTreeNodeIds.value.delete(parent.id);
|
||
}
|
||
|
||
function sameConnectionMetadataChildIds(existing: TreeNode[] | undefined, next: TreeNode[]): boolean {
|
||
const previousIds = new Set(connectionMetadataChildren(existing).map((child) => child.id));
|
||
const nextIds = new Set(connectionMetadataChildren(next).map((child) => child.id));
|
||
if (previousIds.size !== nextIds.size) return false;
|
||
for (const id of previousIds) {
|
||
if (!nextIds.has(id)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function directChildIdWasRemoved(existing: TreeNode[] | undefined, next: TreeNode[]): boolean {
|
||
const nextIds = new Set(next.map((child) => child.id));
|
||
for (const child of existing ?? []) {
|
||
// Pagination placeholders are replaced on every page fetch, not structural removals.
|
||
if (child.type === "load-more") continue;
|
||
if (!nextIds.has(child.id)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function shouldClearDescendantLoadedMarkers(parent: TreeNode, nextChildren: TreeNode[]): boolean {
|
||
if (parent.type === "connection") {
|
||
return !sameConnectionMetadataChildIds(parent.children, nextChildren);
|
||
}
|
||
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema" || objectTypesForGroupNode(parent.type)) {
|
||
return directChildIdWasRemoved(parent.children, nextChildren);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setChildren(parent: TreeNode, children: TreeNode[]) {
|
||
// Compare markers against the resolved child list (after connection preserve), not the raw loader payload.
|
||
children = preserveExistingConnectionMetadataChildren(parent, children);
|
||
if (shouldClearDescendantLoadedMarkers(parent, children)) {
|
||
clearDescendantLoadedChildrenMarkers(parent.id);
|
||
// Parent load may still be current; only supersede descendant generations.
|
||
treeNodeLoads.invalidateDescendants(parent.id);
|
||
}
|
||
if (parent.children && parent.children.length > 0) {
|
||
const oldMap = new Map(parent.children.map((c) => [c.id, c] as const));
|
||
const nextIds = new Set(children.map((child) => child.id));
|
||
for (const [oldId, old] of oldMap) {
|
||
// Removed children keep no loaded markers; also bump generations so in-flight
|
||
// loads cannot apply if the same id is recreated later.
|
||
if (!nextIds.has(oldId) && old.type !== "load-more") {
|
||
forgetTreeNodeLoadState(oldId);
|
||
}
|
||
}
|
||
children = children.map((child) => {
|
||
const old = oldMap.get(child.id);
|
||
if (old?.isLoading) {
|
||
const isExpanded = old.isExpanded;
|
||
const isLoading = old.isLoading;
|
||
const oldChildren = old.children;
|
||
const objectCount = child.objectCount ?? old.objectCount;
|
||
Object.assign(old, child);
|
||
old.isExpanded = isExpanded;
|
||
old.isLoading = isLoading;
|
||
old.children = oldChildren;
|
||
old.objectCount = objectCount;
|
||
return old;
|
||
}
|
||
if (old?.isExpanded) {
|
||
return { ...child, isExpanded: true, children: old.children, objectCount: child.objectCount ?? old.objectCount };
|
||
}
|
||
if (old && objectTypesForGroupNode(old.type)) {
|
||
return { ...child, objectCount: child.objectCount ?? old.objectCount };
|
||
}
|
||
// Same-id collapsed database/schema shell replace (e.g. DDL → force loadDatabases):
|
||
// prior confirmed-empty markers belong to the discarded instance and must not skip
|
||
// the next expand reload. Do not do this for tables/groups — load-more and list
|
||
// refresh must preserve nested loaded markers (columns, etc.).
|
||
if (old && (old.type === "database" || old.type === "schema" || old.type === "linked-server-schema")) {
|
||
forgetTreeNodeLoadState(child.id);
|
||
}
|
||
return child;
|
||
});
|
||
}
|
||
const migratedPins = migrateLegacyPinnedTreeNodeOrder(children, pinnedTreeNodeOrder.value);
|
||
if (migratedPins.changed) {
|
||
setPinnedTreeNodeOrder(migratedPins.order);
|
||
persistPinnedTreeNodeIds();
|
||
}
|
||
syncPinnedTreeState(children);
|
||
parent.children = markRawLeafTreeNodes(children);
|
||
loadedTreeNodeChildrenIds.value.add(parent.id);
|
||
syncConfirmedEmptyTreeNodeId(parent);
|
||
}
|
||
|
||
function removePinnedTreeNodes(nodes: readonly TreeNode[], canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean {
|
||
const nextPinnedOrder = removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, nodes, canonicalize, legacyKeys);
|
||
if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||
syncPinnedTreeState(treeNodes.value);
|
||
persistPinnedTreeNodeIds();
|
||
return true;
|
||
}
|
||
|
||
function replacePinnedTreeNode(oldNode: TreeNode, newNode: TreeNode, canonicalize: PinnedTreeNodeIdentityCanonicalizer = (identity) => identity, legacyKeys: readonly string[] = []): boolean {
|
||
// Use the freshly loaded sidebar node when available so the persisted key
|
||
// carries its real id, not the id of the pre-rename object.
|
||
const loadedReplacement = findTreeNodes(treeNodes.value, (node) => pinnedTreeNodeIdentityMatches(treeNodePinIdentity(node), treeNodePinIdentity(newNode), canonicalize))[0];
|
||
// A caller may provide a virtual row while the sidebar object is unloaded;
|
||
// persisting that row id would create a pin that the sidebar cannot restore.
|
||
const nextPinnedOrder = loadedReplacement ? replacePinnedTreeNodeInOrder(pinnedTreeNodeOrder.value, oldNode, loadedReplacement, canonicalize, legacyKeys) : removePinnedTreeNodesFromOrder(pinnedTreeNodeOrder.value, [oldNode], canonicalize, legacyKeys);
|
||
if (nextPinnedOrder.length === pinnedTreeNodeOrder.value.length && nextPinnedOrder.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||
syncPinnedTreeState(treeNodes.value);
|
||
persistPinnedTreeNodeIds();
|
||
return true;
|
||
}
|
||
|
||
function removeTreeNode(nodeId: string) {
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (node) removePinnedTreeNodes([node]);
|
||
|
||
const parent = findParentNode(treeNodes.value, nodeId);
|
||
if (parent?.children) {
|
||
parent.children = parent.children.filter((c) => c.id !== nodeId);
|
||
// Keep the group badge in sync with remaining real children (exclude load-more).
|
||
if (parent.objectCount != null) {
|
||
parent.objectCount = withoutLoadMoreNodes(parent.children).length;
|
||
}
|
||
}
|
||
if (parent?.hiddenChildren) {
|
||
parent.hiddenChildren = parent.hiddenChildren.filter((child) => child.id !== nodeId);
|
||
}
|
||
if (selectedTreeNodeId.value === nodeId) selectedTreeNodeId.value = null;
|
||
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => id !== nodeId);
|
||
if (treeSelectionAnchorId.value === nodeId) treeSelectionAnchorId.value = null;
|
||
}
|
||
|
||
function buildUserAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
|
||
const config = getConfig(connectionId);
|
||
if (!connectionSupportsDatabaseUserAdmin(config)) return undefined;
|
||
const existing = existingConnectionNode?.children?.find((child) => child.type === "user-admin");
|
||
return {
|
||
id: `${connectionId}:__user_admin`,
|
||
label: "tree.userAdmin",
|
||
type: "user-admin",
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: existing?.isExpanded ?? false,
|
||
};
|
||
}
|
||
|
||
function buildDamengJobAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined {
|
||
const config = getConfig(connectionId);
|
||
if (effectiveDatabaseTypeForConnection(config) !== "dameng") return undefined;
|
||
const existing = existingConnectionNode?.children?.find((child) => child.type === "dameng-job-admin");
|
||
return {
|
||
id: `${connectionId}:__dameng_jobs`,
|
||
label: "tree.damengJobAdmin",
|
||
type: "dameng-job-admin",
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: existing?.isExpanded ?? false,
|
||
};
|
||
}
|
||
|
||
function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
|
||
const nonUtilityChildren = connectionMetadataChildren(children);
|
||
const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode);
|
||
const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode);
|
||
return [...nonUtilityChildren, userAdminNode, damengJobAdminNode].filter(Boolean) as TreeNode[];
|
||
}
|
||
|
||
function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] {
|
||
return withConnectionUtilityNodes(connectionId, children, existingConnectionNode);
|
||
}
|
||
|
||
function schemaCacheKey(...parts: string[]): string {
|
||
return parts.map((part) => encodeURIComponent(part)).join(":");
|
||
}
|
||
|
||
function ownerAwareMetadataCacheVersion(config: ConnectionConfig | undefined, version: string): string {
|
||
return config?.db_type === "informix" ? `${version}-informix-owner-v2` : version;
|
||
}
|
||
|
||
function supportedSidebarObjectTypes(config?: ConnectionConfig): DatabaseObjectTreeKind[] {
|
||
const dbType = effectiveDatabaseTypeForConnection(config);
|
||
return sidebarObjectKindsForDatabase(dbType);
|
||
}
|
||
|
||
function sortSidebarSchemaInfos(schemas: readonly SchemaInfo[]): SchemaInfo[] {
|
||
const byName = new Map<string, SchemaInfo>();
|
||
for (const schema of schemas) {
|
||
const name = schema.name.trim();
|
||
if (!name) continue;
|
||
byName.set(name, { name, comment: schema.comment ?? null });
|
||
}
|
||
return sortSidebarNames([...byName.keys()]).map((name) => byName.get(name)!);
|
||
}
|
||
|
||
function buildExtensionManagementNode(connectionId: string, database: string): TreeNode {
|
||
return {
|
||
id: `${connectionId}:${database}:__extensions`,
|
||
label: "tree.extensions",
|
||
type: "group-extensions",
|
||
connectionId,
|
||
database,
|
||
isExpanded: false,
|
||
children: [],
|
||
};
|
||
}
|
||
|
||
function objectGroupCacheKey(node: TreeNode): string {
|
||
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
|
||
const cacheVersion = ownerAwareMetadataCacheVersion(config, config?.db_type === "oracle" ? "objects-v7" : "objects-v6");
|
||
return schemaCacheKey(node.connectionId || "", node.database || "", node.schema || "", node.type, cacheVersion);
|
||
}
|
||
|
||
function tableNameFilterScopeKey(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): string {
|
||
return schemaCacheKey(parts.connectionId || "", parts.catalog || "", parts.database || "", parts.schema || "", parts.nodeKind || "group-tables");
|
||
}
|
||
|
||
function tableNameFilterForScope(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): TableNameFilter | undefined {
|
||
return sidebarTableNameFilters.value[tableNameFilterScopeKey(parts)];
|
||
}
|
||
|
||
function activeTableNameFilterForScope(parts: { connectionId?: string | null; database?: string | null; schema?: string | null; nodeKind?: string | null; catalog?: string | null }): TableNameFilter | undefined {
|
||
const filter = tableNameFilterForScope(parts);
|
||
return tableNameFilterIsEmpty(filter) ? undefined : filter;
|
||
}
|
||
|
||
function tableNameFilterMetadataExtra(filter: TableNameFilter | undefined): MetadataScopeInput["extra"] {
|
||
return filter
|
||
? {
|
||
tableNameFilterInclude: filter.includePatterns,
|
||
tableNameFilterExclude: filter.excludePatterns,
|
||
}
|
||
: undefined;
|
||
}
|
||
|
||
function setSidebarTableNameFilter(scopeKey: string, filter: TableNameFilter) {
|
||
const normalized = normalizeTableNameFilter(filter);
|
||
const next = { ...sidebarTableNameFilters.value };
|
||
if (tableNameFilterIsEmpty(normalized)) delete next[scopeKey];
|
||
else next[scopeKey] = normalized;
|
||
sidebarTableNameFilters.value = next;
|
||
saveSidebarTableNameFilters(next);
|
||
const revision = (sidebarTableNameFilterRevisions.get(scopeKey) ?? 0) + 1;
|
||
sidebarTableNameFilterRevisions.set(scopeKey, revision);
|
||
return revision;
|
||
}
|
||
|
||
function removeSidebarTableNameFiltersForConnections(connectionIds: Iterable<string>) {
|
||
const encodedPrefixes = [...connectionIds].map((connectionId) => `${encodeURIComponent(connectionId)}:`);
|
||
if (encodedPrefixes.length === 0) return;
|
||
let changed = false;
|
||
const next = { ...sidebarTableNameFilters.value };
|
||
for (const key of Object.keys(next)) {
|
||
if (!encodedPrefixes.some((prefix) => key.startsWith(prefix))) continue;
|
||
delete next[key];
|
||
sidebarTableNameFilterRevisions.delete(key);
|
||
changed = true;
|
||
}
|
||
if (!changed) return;
|
||
sidebarTableNameFilters.value = next;
|
||
saveSidebarTableNameFilters(next);
|
||
}
|
||
|
||
function tableNameFilterRevisionMatches(options?: LoadTreeOptions): boolean {
|
||
if (!options?.tableNameFilterScopeKey) return true;
|
||
return (sidebarTableNameFilterRevisions.get(options.tableNameFilterScopeKey) ?? 0) === options.expectedTableNameFilterRevision;
|
||
}
|
||
|
||
function listTablesWithOptionalTableNameFilter(connectionId: string, database: string, schema: string, filter?: string, limit?: number, offset?: number, objectTypes?: DatabaseObjectTreeKind[], catalog?: string, tableNameFilter?: TableNameFilter) {
|
||
if (tableNameFilter) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes, catalog, tableNameFilter);
|
||
if (catalog) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes, catalog);
|
||
if (objectTypes) return api.listTables(connectionId, database, schema, filter, limit, offset, objectTypes);
|
||
return api.listTables(connectionId, database, schema, filter, limit, offset);
|
||
}
|
||
|
||
function metadataListDriverProfile(connectionId?: string): string | undefined {
|
||
return connectionId ? metadataDriverProfile(getConfig(connectionId)) : undefined;
|
||
}
|
||
|
||
function metadataListCacheScope(options: {
|
||
kind: string;
|
||
connectionId?: string | null;
|
||
database?: string | null;
|
||
schema?: string | null;
|
||
nodeKind?: string | null;
|
||
objectTypes?: readonly string[] | null;
|
||
searchFilter?: string | null;
|
||
limit?: number | null;
|
||
offset?: number | null;
|
||
sidebarDisplayMode?: string | null;
|
||
extra?: MetadataScopeInput["extra"];
|
||
}): MetadataScopeInput {
|
||
return {
|
||
kind: options.kind,
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.schema,
|
||
nodeKind: options.nodeKind,
|
||
objectTypes: options.objectTypes,
|
||
searchFilter: options.searchFilter,
|
||
limit: options.limit,
|
||
offset: options.offset,
|
||
sidebarDisplayMode: options.sidebarDisplayMode,
|
||
driverProfile: metadataListDriverProfile(options.connectionId || undefined),
|
||
extra: options.extra,
|
||
};
|
||
}
|
||
|
||
function invalidateMetadataCaches(match: MetadataCacheInvalidation): number {
|
||
return metadataListPageCache.invalidate(match) + invalidateTableMetadataCache(match) + invalidateObjectBrowserRowsCache(match);
|
||
}
|
||
|
||
function invalidateMetadataCachesByTreePrefix(prefix: string) {
|
||
const [connectionId, database, schema, tableName] = prefix.split(":").map((part) => {
|
||
try {
|
||
return decodeURIComponent(part);
|
||
} catch {
|
||
return part;
|
||
}
|
||
});
|
||
if (!connectionId) return;
|
||
invalidateMetadataCaches({
|
||
connectionId,
|
||
database: database || undefined,
|
||
schema: schema || undefined,
|
||
tableName: tableName && !tableName.startsWith("__") ? tableName : undefined,
|
||
});
|
||
}
|
||
|
||
function invalidateMetadataCachesForNode(node: TreeNode) {
|
||
if (!node.connectionId) return;
|
||
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" ? node.label : undefined);
|
||
const match = {
|
||
connectionId: node.connectionId,
|
||
database: node.database || undefined,
|
||
schema: node.schema || undefined,
|
||
tableName,
|
||
};
|
||
invalidateMetadataCaches(match);
|
||
void invalidateObjectDdlCache(match);
|
||
}
|
||
|
||
function invalidateMetadataCache(connectionId: string, database?: string, schema?: string, tableName?: string) {
|
||
const match = { connectionId, database, schema, tableName };
|
||
invalidateMetadataCaches(match);
|
||
void invalidateObjectDdlCache(match);
|
||
}
|
||
|
||
function buildLoadMoreNode(parent: TreeNode, offset: number, pageSize: number): TreeNode {
|
||
return {
|
||
id: `${parent.id}:__load_more:${offset}`,
|
||
label: "tree.loadMore",
|
||
type: "load-more",
|
||
connectionId: parent.connectionId,
|
||
database: parent.database,
|
||
schema: parent.schema,
|
||
isLoading: false,
|
||
loadMore: {
|
||
parentId: parent.id,
|
||
offset,
|
||
pageSize,
|
||
},
|
||
};
|
||
}
|
||
|
||
function withoutLoadMoreNodes(children: TreeNode[] | undefined): TreeNode[] {
|
||
return (children || []).filter((child) => child.type !== "load-more");
|
||
}
|
||
|
||
function objectGroupChildrenFromObjects(options: { node: TreeNode; parentNodeId: string; effectiveSchema?: string; objectTypes: DatabaseObjectTreeKind[]; objects: ObjectInfo[] }): TreeNode[] {
|
||
const grouped = buildGroupedObjectTreeNodes({
|
||
nodeId: options.parentNodeId,
|
||
connectionId: options.node.connectionId || "",
|
||
database: options.node.database || "",
|
||
schema: options.effectiveSchema,
|
||
objects: options.objects.filter((object) => options.objectTypes.includes(normalizedObjectTreeKind(object.object_type))),
|
||
});
|
||
const refreshedGroup = grouped.find((group) => group.type === options.node.type);
|
||
return refreshedGroup?.children ?? [];
|
||
}
|
||
|
||
function tableInfosToCompletionTables(tables: readonly TableInfo[], schema?: string): SqlCompletionTable[] {
|
||
return tables.map((table) => ({
|
||
name: table.name,
|
||
schema,
|
||
type: sqlObjectNavigationTypeFromTableType(table.table_type),
|
||
}));
|
||
}
|
||
|
||
function sameSidebarObjectName(left: string | undefined, right: string | undefined): boolean {
|
||
return (left || "").toLowerCase() === (right || "").toLowerCase();
|
||
}
|
||
|
||
function treeNodeObjectIdentity(node: TreeNode): string {
|
||
return `${node.type}\0${node.schema || ""}\0${node.label}`;
|
||
}
|
||
|
||
function mergeLocatedTreeChildren(parent: TreeNode, currentChildren: TreeNode[], pageChildren: TreeNode[], connectionId: string, database: string): TreeNode[] {
|
||
const tableChildren = pageChildren.filter((child) => child.type === "table");
|
||
const nonTableChildren = pageChildren.filter((child) => child.type !== "table");
|
||
let merged = tableChildren.length ? mergeTableTreePageChildren(currentChildren, tableChildren, connectionId, database) : [...currentChildren];
|
||
const existing = new Set(merged.map(treeNodeObjectIdentity));
|
||
for (const child of nonTableChildren) {
|
||
const key = treeNodeObjectIdentity(child);
|
||
if (existing.has(key)) continue;
|
||
merged.push(child);
|
||
existing.add(key);
|
||
}
|
||
const config = parent.connectionId ? getConfig(parent.connectionId) : undefined;
|
||
return sortSidebarTreeChildrenForParent(
|
||
parent,
|
||
sortDatabaseObjectsByName(merged, (node) => node.label),
|
||
config?.db_type,
|
||
);
|
||
}
|
||
|
||
function findTreeNodes(nodes: TreeNode[], predicate: (node: TreeNode) => boolean): TreeNode[] {
|
||
const matches: TreeNode[] = [];
|
||
for (const node of nodes) {
|
||
if (predicate(node)) matches.push(node);
|
||
if (node.children) matches.push(...findTreeNodes(node.children, predicate));
|
||
const hiddenOnlyChildren = node.hiddenChildren?.filter((child) => !(node.children || []).includes(child));
|
||
if (hiddenOnlyChildren?.length) matches.push(...findTreeNodes(hiddenOnlyChildren, predicate));
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
async function loadPagedTableGroupChildren(options: {
|
||
node: TreeNode;
|
||
parentNodeId: string;
|
||
querySchema: string;
|
||
effectiveSchema?: string;
|
||
objectTypes: DatabaseObjectTreeKind[];
|
||
offset: number;
|
||
pageSize: number;
|
||
searchFilter?: string;
|
||
force?: boolean;
|
||
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> {
|
||
if (!options.node.connectionId || options.node.database == null) {
|
||
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
|
||
}
|
||
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
|
||
const tableNameFilter = activeTableNameFilterForScope({
|
||
connectionId: options.node.connectionId,
|
||
database: options.node.database,
|
||
schema: options.node.schema,
|
||
nodeKind: options.node.type,
|
||
catalog: options.node.catalog,
|
||
});
|
||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
||
const fetchOffset = searchFilter ? undefined : options.offset;
|
||
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
||
metadataListCacheScope({
|
||
kind: "table-list-page",
|
||
connectionId: options.node.connectionId,
|
||
database: options.node.database,
|
||
schema: options.querySchema,
|
||
nodeKind: options.node.type,
|
||
objectTypes: options.objectTypes,
|
||
searchFilter,
|
||
limit: fetchLimit,
|
||
offset: fetchOffset,
|
||
sidebarDisplayMode: "grouped",
|
||
extra: tableNameFilterMetadataExtra(tableNameFilter),
|
||
}),
|
||
() => listTablesWithOptionalTableNameFilter(options.node.connectionId!, options.node.database!, options.querySchema, searchFilter, fetchLimit, fetchOffset, options.objectTypes, options.node.catalog, tableNameFilter),
|
||
{ force: options.force },
|
||
);
|
||
const hasMore = searchFilter ? false : tables.length > options.pageSize;
|
||
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
|
||
indexCompletionTables(options.node.connectionId, options.node.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema));
|
||
const objects = mergeTableInfosIntoObjects([], pageTables, options.effectiveSchema);
|
||
const children = objectGroupChildrenFromObjects({
|
||
node: options.node,
|
||
parentNodeId: options.parentNodeId,
|
||
effectiveSchema: options.effectiveSchema,
|
||
objectTypes: options.objectTypes,
|
||
objects,
|
||
});
|
||
const lastTable = pageTables[pageTables.length - 1];
|
||
return {
|
||
children,
|
||
objectCount: children.length,
|
||
hasMore,
|
||
nextOffset: options.offset + pageTables.length,
|
||
loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined,
|
||
};
|
||
}
|
||
|
||
async function loadPagedObjectGroupChildren(options: {
|
||
node: TreeNode;
|
||
parentNodeId: string;
|
||
querySchema: string;
|
||
effectiveSchema?: string;
|
||
objectTypes: DatabaseObjectTreeKind[];
|
||
offset: number;
|
||
pageSize: number;
|
||
searchFilter?: string;
|
||
force?: boolean;
|
||
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number }> {
|
||
if (!options.node.connectionId || options.node.database == null) {
|
||
return { children: [], objectCount: 0, hasMore: false, nextOffset: options.offset };
|
||
}
|
||
const searchFilter = options.searchFilter || undefined;
|
||
const fetchLimit = searchFilter ? undefined : options.pageSize + 1;
|
||
const fetchOffset = searchFilter ? undefined : options.offset;
|
||
const objects = await loadCachedMetadataListPage<ObjectInfo[]>(
|
||
metadataListCacheScope({
|
||
kind: "object-list-page",
|
||
connectionId: options.node.connectionId,
|
||
database: options.node.database,
|
||
schema: options.querySchema,
|
||
nodeKind: options.node.type,
|
||
objectTypes: options.objectTypes,
|
||
searchFilter,
|
||
limit: fetchLimit,
|
||
offset: fetchOffset,
|
||
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
|
||
}),
|
||
() => api.listObjects(options.node.connectionId!, options.node.database!, options.querySchema, options.objectTypes, searchFilter, fetchLimit, fetchOffset),
|
||
{ force: options.force },
|
||
);
|
||
const hasMore = searchFilter ? false : objects.length > options.pageSize;
|
||
const pageObjects = hasMore ? objects.slice(0, options.pageSize) : objects;
|
||
const children = objectGroupChildrenFromObjects({
|
||
node: options.node,
|
||
parentNodeId: options.parentNodeId,
|
||
effectiveSchema: options.effectiveSchema,
|
||
objectTypes: options.objectTypes,
|
||
objects: pageObjects,
|
||
});
|
||
return {
|
||
children,
|
||
objectCount: children.length,
|
||
hasMore,
|
||
nextOffset: options.offset + pageObjects.length,
|
||
};
|
||
}
|
||
|
||
async function loadPagedSimpleTableChildren(options: {
|
||
nodeId: string;
|
||
connectionId: string;
|
||
database: string;
|
||
querySchema: string;
|
||
effectiveSchema?: string;
|
||
nonTableObjectTypes: DatabaseObjectTreeKind[];
|
||
offset: number;
|
||
pageSize: number;
|
||
searchFilter?: string;
|
||
force?: boolean;
|
||
}): Promise<{ children: TreeNode[]; objectCount: number; hasMore: boolean; nextOffset: number; loadMoreParent?: TableTreeLoadMoreParent }> {
|
||
const searchFilter = (options.searchFilter ?? sidebarSearchQuery.value) || undefined;
|
||
const tableNameFilter = activeTableNameFilterForScope({
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.effectiveSchema ?? options.querySchema,
|
||
nodeKind: "simple-tables",
|
||
});
|
||
const fetchLimit = searchFilter ? options.pageSize : options.pageSize + 1;
|
||
const fetchOffset = searchFilter ? undefined : options.offset;
|
||
const tables = await loadCachedMetadataListPage<TableInfo[]>(
|
||
metadataListCacheScope({
|
||
kind: "table-list-page",
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.querySchema,
|
||
nodeKind: "simple-tables",
|
||
searchFilter,
|
||
limit: fetchLimit,
|
||
offset: fetchOffset,
|
||
sidebarDisplayMode: "simple",
|
||
extra: tableNameFilterMetadataExtra(tableNameFilter),
|
||
}),
|
||
() => listTablesWithOptionalTableNameFilter(options.connectionId, options.database, options.querySchema, searchFilter, fetchLimit, fetchOffset, undefined, undefined, tableNameFilter),
|
||
{ force: options.force },
|
||
);
|
||
const hasMore = searchFilter ? false : tables.length > options.pageSize;
|
||
const pageTables = hasMore ? tables.slice(0, options.pageSize) : tables;
|
||
indexCompletionTables(options.connectionId, options.database, options.effectiveSchema, tableInfosToCompletionTables(pageTables, options.effectiveSchema));
|
||
|
||
const children = buildTableTreeNodes({
|
||
nodeId: options.nodeId,
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.effectiveSchema,
|
||
tables: pageTables,
|
||
});
|
||
const lastTable = pageTables[pageTables.length - 1];
|
||
return {
|
||
children,
|
||
objectCount: children.length,
|
||
hasMore,
|
||
nextOffset: options.offset + pageTables.length,
|
||
loadMoreParent: lastTable?.parent_name ? { schema: lastTable.parent_schema, name: lastTable.parent_name } : undefined,
|
||
};
|
||
}
|
||
|
||
async function loadSimpleSupplementalObjectChildren(options: {
|
||
node: TreeNode;
|
||
nodeId: string;
|
||
connectionId: string;
|
||
database: string;
|
||
querySchema: string;
|
||
effectiveSchema?: string;
|
||
objectTypes: DatabaseObjectTreeKind[];
|
||
cacheKey: string;
|
||
loadOptions?: LoadTreeOptions;
|
||
load: TreeNodeLoadHandle;
|
||
}) {
|
||
if (options.objectTypes.length === 0) return;
|
||
const searchFilter = activeTreeLoadSearchFilter(options.loadOptions);
|
||
if (searchFilter) return;
|
||
|
||
try {
|
||
const objects = await loadCachedMetadataListPage<ObjectInfo[]>(
|
||
metadataListCacheScope({
|
||
kind: "object-list-page",
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.querySchema,
|
||
nodeKind: "simple-supplemental",
|
||
objectTypes: options.objectTypes,
|
||
sidebarDisplayMode: "simple",
|
||
}),
|
||
() => api.listObjects(options.connectionId, options.database, options.querySchema, options.objectTypes),
|
||
{ force: options.loadOptions?.force },
|
||
);
|
||
const supplementalObjects = filterSimpleSidebarSupplementalObjects(objects);
|
||
if (supplementalObjects.length === 0) return;
|
||
const supplementalChildren = buildSimpleObjectTreeNodes({
|
||
nodeId: options.nodeId,
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.effectiveSchema,
|
||
objects: supplementalObjects,
|
||
});
|
||
if (supplementalChildren.length === 0) return;
|
||
if (isTreeLoadSearchChanged(searchFilter, options.loadOptions)) return;
|
||
const targetNode = treeNodeLoadTarget(options.load);
|
||
if (!targetNode) return;
|
||
|
||
const loadMoreNodes = (targetNode.children || []).filter((child) => child.type === "load-more");
|
||
const currentChildren = withoutLoadMoreNodes(targetNode.children);
|
||
const mergedChildren = mergeLocatedTreeChildren(targetNode, currentChildren, supplementalChildren, options.connectionId, options.database);
|
||
const nextChildren = [...mergedChildren, ...loadMoreNodes];
|
||
setChildren(targetNode, nextChildren);
|
||
await savePersistedTreeChildren(options.cacheKey, nextChildren);
|
||
} catch (error) {
|
||
// Some drivers only expose table metadata; keep the already-rendered table tree usable.
|
||
console.debug("[DBX][metadata:simple-supplemental:error]", {
|
||
connectionId: options.connectionId,
|
||
database: options.database,
|
||
schema: options.effectiveSchema,
|
||
error,
|
||
});
|
||
}
|
||
}
|
||
|
||
function refreshStaleTreeNode(node: TreeNode) {
|
||
const searchFilter = sidebarSearchQuery.value || "";
|
||
if (searchFilter) return;
|
||
const liveNode = treeNodeInSidebarTree(node);
|
||
if (!liveNode) return;
|
||
if (staleTreeRefreshIds.has(liveNode.id)) return;
|
||
staleTreeRefreshIds.add(liveNode.id);
|
||
const expandedIds = collectExpandedNodeIds([liveNode]);
|
||
clearLoadedChildrenCache(liveNode.id);
|
||
const refreshOptions = { force: true, expectedSidebarSearchQuery: searchFilter };
|
||
void loadTreeNodeChildren(liveNode, refreshOptions)
|
||
.then(() => {
|
||
if ((sidebarSearchQuery.value || "") !== searchFilter) return;
|
||
return restoreExpandedChildren(liveNode, expandedIds, refreshOptions);
|
||
})
|
||
.finally(() => staleTreeRefreshIds.delete(liveNode.id));
|
||
}
|
||
|
||
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string, load: TreeNodeLoadHandle): Promise<PersistedTreeChildrenLoadResult> {
|
||
const trace = createMetadataLoadTrace({
|
||
kind: "persisted-tree-cache",
|
||
connectionId: node.connectionId,
|
||
database: node.database,
|
||
schema: node.schema,
|
||
nodeKind: node.type,
|
||
extra: { cacheKey },
|
||
});
|
||
const payload = await api.loadSchemaCache<unknown>(cacheKey).catch(() => null);
|
||
const decoded = decodeSchemaTreeCache<TreeNode[]>(payload);
|
||
if (!decoded) {
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss" });
|
||
return { hit: false, isStale: false };
|
||
}
|
||
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
|
||
const cachedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children));
|
||
const childrenWithLinkedServers = node.type === "connection" && node.connectionId ? ensureSqlServerLinkedRootNode(node.connectionId, cachedChildren, config) : cachedChildren;
|
||
if (node.type === "connection" && !hasConnectionMetadataChildren(childrenWithLinkedServers)) {
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss", resultCount: 0 });
|
||
return { hit: false, isStale: false };
|
||
}
|
||
// Gate cache apply on the same per-node generation as network apply — connection
|
||
// revision alone is not enough when a newer force-load supersedes this handle.
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) {
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss" });
|
||
return { hit: false, isStale: false };
|
||
}
|
||
const normalizedChildren = sortSidebarTreeChildrenForParent(targetNode, childrenWithLinkedServers, config?.db_type);
|
||
setChildren(targetNode, targetNode.type === "connection" && targetNode.connectionId ? withSavedSqlRoot(targetNode.connectionId, normalizedChildren, targetNode) : normalizedChildren);
|
||
targetNode.isExpanded = true;
|
||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-hit", {
|
||
cacheStatus: decoded.isStale ? "stale" : "hit",
|
||
resultCount: normalizedChildren.length,
|
||
stale: decoded.isStale,
|
||
});
|
||
return { hit: true, isStale: decoded.isStale };
|
||
}
|
||
|
||
async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) {
|
||
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined);
|
||
}
|
||
|
||
function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null {
|
||
if (!parent.connectionId || !parent.database) return null;
|
||
if (parent.type === "group-tables") return objectGroupCacheKey(parent);
|
||
if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return null;
|
||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const cacheVersion = ownerAwareMetadataCacheVersion(getConfig(parent.connectionId), simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
|
||
return schemaCacheKey(parent.connectionId, parent.database, parent.schema || "", cacheVersion);
|
||
}
|
||
|
||
function sidebarTableSearchIndexCacheKey(parent: TreeNode): string | null {
|
||
const treeCacheKey = sidebarTableSearchTreeCacheKey(parent);
|
||
return treeCacheKey ? `${treeCacheKey}:table-search-index-v1` : null;
|
||
}
|
||
|
||
async function loadSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[] | null> {
|
||
const parent = findNode(treeNodes.value, parentNodeId);
|
||
if (!parent) return null;
|
||
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
|
||
if (!cacheKey) return null;
|
||
const decoded = decodeSchemaTreeCache<TreeNode[]>(await api.loadSchemaCache<unknown>(cacheKey).catch(() => null));
|
||
const index = decoded?.tableSearchIndex;
|
||
if (!index) return null;
|
||
return index.entries.map((entry) => ({ name: entry.name, table_type: entry.tableType }));
|
||
}
|
||
|
||
async function refreshSidebarTableSearchIndex(parentNodeId: string): Promise<TableInfo[]> {
|
||
const parent = findNode(treeNodes.value, parentNodeId);
|
||
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return [];
|
||
const cacheKey = sidebarTableSearchIndexCacheKey(parent);
|
||
if (!cacheKey) return [];
|
||
await ensureConnected(parent.connectionId);
|
||
const config = getConfig(parent.connectionId);
|
||
const querySchema = connectionObjectTreeQuerySchema(config, parent.database, parent.schema);
|
||
const objectTypes = parent.type === "group-tables" ? (objectTypesForGroupNode(parent.type) ?? undefined) : undefined;
|
||
const pageSize = sidebarObjectGroupPageSize();
|
||
const entries: TableInfo[] = [];
|
||
for (let offset = 0; ; offset += pageSize) {
|
||
const page = await listTablesWithOptionalTableNameFilter(parent.connectionId, parent.database, querySchema, undefined, pageSize, offset, objectTypes, parent.catalog);
|
||
entries.push(...page);
|
||
if (page.length < pageSize) break;
|
||
}
|
||
const deduped = [...new Map(entries.map((entry) => [`${entry.table_type}\0${entry.name}`, entry])).values()];
|
||
const tableSearchIndex = { complete: true as const, indexedAt: new Date().toISOString(), entries: deduped.map((entry) => ({ name: entry.name, tableType: entry.table_type })) };
|
||
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache<TreeNode[]>([], Date.now(), tableSearchIndex));
|
||
return deduped;
|
||
}
|
||
|
||
async function savePersistedConnectionTreeChildren(cacheKey: string, children: TreeNode[]) {
|
||
const metadataChildren = connectionMetadataChildren(children);
|
||
if (metadataChildren.length === 0) return;
|
||
await savePersistedTreeChildren(cacheKey, metadataChildren);
|
||
}
|
||
|
||
function connectionRootCacheKey(connectionId: string, config: ConnectionConfig | undefined): string | null {
|
||
if (!config || connectionIsDorisFamilyCatalogCapable(config)) return null;
|
||
if (config.db_type === "duckdb") return schemaCacheKey(connectionId, "duckdb-root");
|
||
if (connectionUsesVisibleSchemaFilter(config)) {
|
||
return schemaCacheKey(connectionId, config.database || "", config.db_type === "oracle" ? "schemas-v2" : "schemas", config.show_system_schemas === true ? "show-system" : "hide-system");
|
||
}
|
||
return schemaCacheKey(connectionId, "databases-v2");
|
||
}
|
||
|
||
async function hydrateTreeNodeFromCache(node: TreeNode | null, cacheKey: string | null): Promise<boolean> {
|
||
if (!node || !cacheKey || loadedTreeNodeChildrenIds.value.has(node.id)) return false;
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
return (await loadPersistedTreeChildren(node, cacheKey, load)).hit;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function hydrateConnectionRootFromCache(connectionId: string, config: ConnectionConfig | undefined): Promise<boolean> {
|
||
return hydrateTreeNodeFromCache(findConnectionNode(connectionId), connectionRootCacheKey(connectionId, config));
|
||
}
|
||
|
||
function isTreeNodeLoadedChildrenUsable(node: TreeNode): boolean {
|
||
const sidebarObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay;
|
||
if (!treeNodeLoadedChildrenContentPresent(node, sidebarObjectDisplay)) return false;
|
||
if (simpleModeEmptyShellNeedsConfirmedLoad(node, sidebarObjectDisplay) && !confirmedEmptyTreeNodeIds.value.has(node.id)) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function canUseLoadedTreeNodeToggle(node: TreeNode): boolean {
|
||
return loadedTreeNodeChildrenIds.value.has(node.id) && isTreeNodeLoadedChildrenUsable(node);
|
||
}
|
||
|
||
function useCachedChildren(node: TreeNode, options: LoadTreeOptions | undefined, load: TreeNodeLoadHandle): boolean {
|
||
if (options?.force || !loadedTreeNodeChildrenIds.value.has(node.id)) return false;
|
||
if (!load.isCurrent()) return false;
|
||
if (node.type === "connection" && node.connectionId) {
|
||
if (!hasConnectionMetadataChildren(node.children)) {
|
||
clearLoadedChildrenCache(node.id);
|
||
return false;
|
||
}
|
||
const normalizedChildren = sortSidebarTreeChildrenForParent(node, withSavedSqlRoot(node.connectionId, node.children || [], node), getConfig(node.connectionId)?.db_type);
|
||
const liveNode = treeNodeLoadTarget(load);
|
||
if (!liveNode) return false;
|
||
setChildren(liveNode, normalizedChildren);
|
||
liveNode.isExpanded = true;
|
||
} else if (!isTreeNodeLoadedChildrenUsable(node)) {
|
||
clearLoadedChildrenCache(node.id);
|
||
return false;
|
||
}
|
||
const liveNode = treeNodeLoadTarget(load);
|
||
if (!liveNode) return false;
|
||
liveNode.isExpanded = true;
|
||
return true;
|
||
}
|
||
|
||
function isSidebarSearchQueryChanged(options?: LoadTreeOptions) {
|
||
return options?.expectedSidebarSearchQuery !== undefined && (sidebarSearchQuery.value || "") !== options.expectedSidebarSearchQuery;
|
||
}
|
||
|
||
function isSidebarTableSearchQueryChanged(options?: LoadTreeOptions) {
|
||
if (!options?.sidebarTableSearchParentId || options.expectedSidebarTableSearchQuery === undefined) return false;
|
||
return (sidebarTableSearchQueries.value[options.sidebarTableSearchParentId]?.trim() || "") !== options.expectedSidebarTableSearchQuery;
|
||
}
|
||
|
||
function activeTreeLoadSearchFilter(options?: LoadTreeOptions): string {
|
||
return (options?.searchFilter ?? sidebarSearchQuery.value) || "";
|
||
}
|
||
|
||
function isTreeLoadSearchChanged(searchFilter: string, options?: LoadTreeOptions): boolean {
|
||
if (options?.sidebarTableSearchParentId) return isSidebarTableSearchQueryChanged(options);
|
||
return (sidebarSearchQuery.value || "") !== searchFilter || isSidebarSearchQueryChanged(options);
|
||
}
|
||
|
||
function isTreeNodeChildrenLoaded(nodeId: string): boolean {
|
||
return loadedTreeNodeChildrenIds.value.has(nodeId);
|
||
}
|
||
|
||
// Collapsing a node only hides it — its loaded children stay in memory, so a
|
||
// long browsing session accumulates every schema the user ever expanded and
|
||
// the webview creeps upward. When a *large* subtree is collapsed we drop its
|
||
// children so the memory is reclaimed; re-expanding reloads them (fast, from
|
||
// the schema cache). Small subtrees are kept so routine expand/collapse stays
|
||
// instant and never triggers a reload.
|
||
const RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS = 400;
|
||
|
||
function countTreeNodeDescendants(node: TreeNode, cap: number): number {
|
||
let count = 0;
|
||
const stack: TreeNode[] = [...(node.children ?? [])];
|
||
while (stack.length) {
|
||
const current = stack.pop()!;
|
||
count += 1;
|
||
if (count >= cap) return count;
|
||
if (current.children?.length) stack.push(...current.children);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function forgetLoadedChildrenIdsForSubtree(node: TreeNode) {
|
||
loadedTreeNodeChildrenIds.value.delete(node.id);
|
||
confirmedEmptyTreeNodeIds.value.delete(node.id);
|
||
for (const child of node.children ?? []) {
|
||
forgetLoadedChildrenIdsForSubtree(child);
|
||
}
|
||
}
|
||
|
||
// Returns true when the collapsed node's children were released. Caller should
|
||
// have already set node.isExpanded = false. Re-expanding reloads on demand
|
||
// because the node id is removed from loadedTreeNodeChildrenIds.
|
||
function releaseCollapsedTreeNodeChildren(nodeId: string): boolean {
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (!node?.children?.length) return false;
|
||
if (countTreeNodeDescendants(node, RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) < RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) {
|
||
return false;
|
||
}
|
||
forgetLoadedChildrenIdsForSubtree(node);
|
||
node.children = [];
|
||
return true;
|
||
}
|
||
|
||
function treeNodeInSidebarTree(node: TreeNode): TreeNode | null {
|
||
return findNode(treeNodes.value, node.id);
|
||
}
|
||
|
||
function beginTreeNodeLoad(node: TreeNode): TreeNodeLoadHandle {
|
||
return treeNodeLoads.begin(node);
|
||
}
|
||
|
||
function reclaimTreeNodeLoad(load: TreeNodeLoadHandle, node: TreeNode): TreeNodeLoadHandle {
|
||
return load.reclaim(treeNodeInSidebarTree(node) ?? node);
|
||
}
|
||
|
||
function treeNodeLoadTarget(load: TreeNodeLoadHandle): TreeNode | null {
|
||
return load.targetNode(
|
||
(nodeId) => findNode(treeNodes.value, nodeId),
|
||
(connectionId) => connectedIds.value.has(connectionId),
|
||
) as TreeNode | null;
|
||
}
|
||
|
||
function finishTreeNodeLoad(load: TreeNodeLoadHandle) {
|
||
load.finish((nodeId) => findNode(treeNodes.value, nodeId));
|
||
}
|
||
|
||
/** Apply to a related node only while this load handle is still current. */
|
||
function treeNodeLoadRelatedTarget(load: TreeNodeLoadHandle, related: TreeNode): TreeNode | null {
|
||
if (!load.isCurrent()) return null;
|
||
const current = treeNodeInSidebarTree(related);
|
||
if (!current) return null;
|
||
if (current.connectionId && !connectedIds.value.has(current.connectionId)) return null;
|
||
return current;
|
||
}
|
||
|
||
function clearLoadedChildrenCache(prefix: string, options?: { deletePersisted?: boolean }) {
|
||
for (const id of loadedTreeNodeChildrenIds.value) {
|
||
if (id === prefix || id.startsWith(`${prefix}:`)) {
|
||
loadedTreeNodeChildrenIds.value.delete(id);
|
||
}
|
||
}
|
||
for (const id of confirmedEmptyTreeNodeIds.value) {
|
||
if (id === prefix || id.startsWith(`${prefix}:`)) {
|
||
confirmedEmptyTreeNodeIds.value.delete(id);
|
||
}
|
||
}
|
||
invalidateMetadataCachesByTreePrefix(prefix);
|
||
if (options?.deletePersisted === false) return;
|
||
const rawPrefix = `${prefix}:`;
|
||
const encodedPrefix = `${schemaCacheKey(prefix)}:`;
|
||
if (rawPrefix === encodedPrefix) {
|
||
api.deleteSchemaCachePrefix(rawPrefix).catch(() => undefined);
|
||
} else {
|
||
Promise.all([api.deleteSchemaCachePrefix(rawPrefix), api.deleteSchemaCachePrefix(encodedPrefix)]).catch(() => undefined);
|
||
}
|
||
}
|
||
|
||
function schemaCachePrefixForNode(node: TreeNode): string | null {
|
||
return treeNodeSchemaCachePrefix(node);
|
||
}
|
||
|
||
async function clearPersistedTreeCacheForNode(node: TreeNode) {
|
||
const prefix = schemaCachePrefixForNode(node);
|
||
if (!prefix) return;
|
||
await api.deleteSchemaCachePrefix(prefix).catch(() => undefined);
|
||
}
|
||
|
||
function findParentNode(nodes: TreeNode[], id: string, parent: TreeNode | null = null): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return parent;
|
||
if (node.children) {
|
||
const found = findParentNode(node.children, id, node);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function toggleTreeNodePin(node: TreeNode) {
|
||
const pinKey = treeNodePinKey(node);
|
||
const wasPinned = pinnedTreeNodeIds.value.has(pinKey) || pinnedTreeNodeIds.value.has(node.id);
|
||
// Remove the legacy bare id as part of every toggle so old ambiguous pins
|
||
// cannot continue matching objects in a different database. Newly pinned
|
||
// nodes append to the persisted order, placing them last in their sibling
|
||
// pin section until the user explicitly reorders them.
|
||
const next = pinnedTreeNodeOrder.value.filter((id) => id !== node.id && id !== pinKey);
|
||
if (!wasPinned) next.push(pinKey);
|
||
setPinnedTreeNodeOrder(next);
|
||
persistPinnedTreeNodeIds();
|
||
|
||
// Pinning is infrequent; synchronizing the loaded tree here also clears any
|
||
// stale flags created by legacy unscoped ids without rebuilding metadata.
|
||
syncPinnedTreeState(treeNodes.value);
|
||
}
|
||
|
||
function findPinnedTreeNodeLocation(nodes: TreeNode[], pinKey: string): { node: TreeNode; siblings: TreeNode[] } | null {
|
||
for (const node of nodes) {
|
||
if (treeNodePinKey(node) === pinKey) return { node, siblings: nodes };
|
||
if (node.children) {
|
||
const found = findPinnedTreeNodeLocation(node.children, pinKey);
|
||
if (found) return found;
|
||
}
|
||
if (node.hiddenChildren) {
|
||
const found = findPinnedTreeNodeLocation(node.hiddenChildren, pinKey);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function collectPinnedTreeNodeReorderTargets(draggedKey: string): Set<string> {
|
||
const dragged = findPinnedTreeNodeLocation(treeNodes.value, draggedKey);
|
||
if (!dragged || !isTreeNodePinned(dragged.node) || isFixedPriorityTreeNode(dragged.node)) return new Set();
|
||
|
||
const targets = new Set<string>();
|
||
for (const sibling of dragged.siblings) {
|
||
const siblingKey = treeNodePinKey(sibling);
|
||
if (siblingKey === draggedKey || !isTreeNodePinned(sibling) || isFixedPriorityTreeNode(sibling)) continue;
|
||
targets.add(siblingKey);
|
||
}
|
||
return targets;
|
||
}
|
||
|
||
const activePinnedTreeNodeReorderTargets = computed(() => {
|
||
const draggedKey = activePinnedTreeNodeReorderKey.value;
|
||
return draggedKey ? collectPinnedTreeNodeReorderTargets(draggedKey) : new Set<string>();
|
||
});
|
||
|
||
function beginPinnedTreeNodeReorder(draggedKey: string) {
|
||
activePinnedTreeNodeReorderKey.value = draggedKey || null;
|
||
}
|
||
|
||
function endPinnedTreeNodeReorder() {
|
||
activePinnedTreeNodeReorderKey.value = null;
|
||
}
|
||
|
||
function isPinnedTreeNodeReorderTarget(targetKey: string): boolean {
|
||
return !!targetKey && targetKey !== activePinnedTreeNodeReorderKey.value && activePinnedTreeNodeReorderTargets.value.has(targetKey);
|
||
}
|
||
|
||
function canReorderPinnedTreeNodes(draggedKey: string, targetKey: string): boolean {
|
||
if (!draggedKey || !targetKey || draggedKey === targetKey) return false;
|
||
if (activePinnedTreeNodeReorderKey.value === draggedKey) return activePinnedTreeNodeReorderTargets.value.has(targetKey);
|
||
return collectPinnedTreeNodeReorderTargets(draggedKey).has(targetKey);
|
||
}
|
||
|
||
function reorderPinnedTreeNodes(draggedKey: string, targetKey: string, position: DropPosition): boolean {
|
||
if (position === "inside" || !canReorderPinnedTreeNodes(draggedKey, targetKey)) return false;
|
||
const next = reorderPinnedTreeNodeOrder(pinnedTreeNodeOrder.value, draggedKey, targetKey, position);
|
||
if (next.length === pinnedTreeNodeOrder.value.length && next.every((key, index) => key === pinnedTreeNodeOrder.value[index])) return false;
|
||
setPinnedTreeNodeOrder(next);
|
||
syncPinnedTreeState(treeNodes.value);
|
||
persistPinnedTreeNodeIds();
|
||
return true;
|
||
}
|
||
|
||
async function addConnection(config: ConnectionConfig, targetGroupId?: string | null) {
|
||
const normalized = normalizeConnection(config);
|
||
const existing = connections.value.findIndex((c) => c.id === normalized.id);
|
||
const nextConnections = [...connections.value];
|
||
if (existing >= 0) {
|
||
nextConnections[existing] = normalized;
|
||
} else {
|
||
nextConnections.push(normalized);
|
||
const groupId = targetGroupId !== undefined ? targetGroupId : newConnectionGroupId.value;
|
||
sidebarLayout.value = appendConnectionToLayout(sidebarLayout.value, normalized.id, groupId);
|
||
}
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
rebuildTreeNodes();
|
||
persistSidebarLayoutDebounced();
|
||
stopCreatingConnectionInGroup();
|
||
}
|
||
|
||
function copyConnectionsToTreeClipboard(connectionIds: Iterable<string>): number {
|
||
const seen = new Set<string>();
|
||
const entries: TreeClipboardConnectionEntry[] = [];
|
||
for (const connectionId of connectionIds) {
|
||
if (seen.has(connectionId)) continue;
|
||
seen.add(connectionId);
|
||
const config = getConfig(connectionId);
|
||
if (!config) continue;
|
||
entries.push({
|
||
config: { ...config },
|
||
sourceGroupId: findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null,
|
||
});
|
||
}
|
||
if (!entries.length) return 0;
|
||
treeClipboard.value = { kind: "connection-copy", connections: entries };
|
||
return entries.length;
|
||
}
|
||
|
||
async function pasteConnectionClipboard(targetGroupId?: string | null): Promise<number> {
|
||
const clipboard = treeClipboard.value;
|
||
if (clipboard?.kind !== "connection-copy" || clipboard.connections.length === 0) return 0;
|
||
|
||
let pastedCount = 0;
|
||
for (const entry of clipboard.connections) {
|
||
await addConnection(
|
||
{
|
||
...entry.config,
|
||
id: uuid(),
|
||
name: `${entry.config.name} (Copy)`,
|
||
},
|
||
targetGroupId === undefined ? entry.sourceGroupId : targetGroupId,
|
||
);
|
||
pastedCount += 1;
|
||
}
|
||
return pastedCount;
|
||
}
|
||
|
||
function invalidateCompletionCache(connectionId: string, database?: string) {
|
||
invalidateMetadataCaches({ connectionId, database });
|
||
if (database == null) delete completionDatabasesCache.value[connectionId];
|
||
const cachePrefix = database == null ? `${connectionId}:` : `${connectionId}:${database}:`;
|
||
const exactCacheKey = database == null ? null : `${connectionId}:${database}`;
|
||
for (const key of Object.keys(completionTablesCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionTablesCache.value[key];
|
||
}
|
||
for (const key of Object.keys(completionObjectsCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionObjectsCache.value[key];
|
||
}
|
||
for (const key of Object.keys(completionColumnsCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionColumnsCache.value[key];
|
||
}
|
||
for (const key of Object.keys(completionForeignKeysCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete completionForeignKeysCache.value[key];
|
||
}
|
||
for (const key of Object.keys(schemaListCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete schemaListCache.value[key];
|
||
}
|
||
for (const key of Object.keys(sqlServerCompletionContextCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete sqlServerCompletionContextCache.value[key];
|
||
}
|
||
for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key];
|
||
}
|
||
for (const key of Object.keys(redisCompletionKeysCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
|
||
}
|
||
for (const key of Object.keys(mongoCompletionCollectionsCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionCollectionsCache.value[key];
|
||
}
|
||
for (const key of Object.keys(mongoCompletionFieldsCache.value)) {
|
||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete mongoCompletionFieldsCache.value[key];
|
||
}
|
||
for (const key of completionTableIndex.keys()) {
|
||
if (key.startsWith(cachePrefix)) completionTableIndex.delete(key);
|
||
}
|
||
for (const key of completionObjectIndex.keys()) {
|
||
if (key.startsWith(cachePrefix)) completionObjectIndex.delete(key);
|
||
}
|
||
for (const key of completionColumnIndex.keys()) {
|
||
if (key.startsWith(cachePrefix)) completionColumnIndex.delete(key);
|
||
}
|
||
for (const key of completionForeignKeyIndex.keys()) {
|
||
if (key.startsWith(cachePrefix)) completionForeignKeyIndex.delete(key);
|
||
}
|
||
for (const key of completionInFlight.keys()) {
|
||
if (key.startsWith(cachePrefix)) completionInFlight.delete(key);
|
||
}
|
||
}
|
||
|
||
async function removeConnections(ids: Iterable<string>) {
|
||
const connectionIds = [...new Set(ids)].filter((id) => connections.value.some((c) => c.id === id));
|
||
if (!connectionIds.length) return;
|
||
|
||
const removedIds = new Set(connectionIds);
|
||
const nextConnections = connections.value.filter((c) => !removedIds.has(c.id));
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
let nextPinnedOrder = pinnedTreeNodeOrder.value;
|
||
for (const id of removedIds) {
|
||
const prefix = `${id}:`;
|
||
nextPinnedOrder = nextPinnedOrder.filter((pinId) => pinId !== id && !pinId.startsWith(prefix));
|
||
}
|
||
setPinnedTreeNodeOrder(nextPinnedOrder);
|
||
persistPinnedTreeNodeIds();
|
||
removeSidebarTableNameFiltersForConnections(removedIds);
|
||
for (const id of removedIds) {
|
||
clearConnectionError(id);
|
||
connectionErrorRevisions.delete(id);
|
||
connectedIds.value.delete(id);
|
||
clearConnectionIdentifierQuote(id);
|
||
clearConnectionHealthCheck(id);
|
||
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
|
||
}
|
||
rebuildTreeNodes();
|
||
persistSidebarLayoutDebounced();
|
||
if (activeConnectionId.value && removedIds.has(activeConnectionId.value)) {
|
||
activeConnectionId.value = null;
|
||
}
|
||
selectedTreeNodeIds.value = selectedTreeNodeIds.value.filter((id) => !removedIds.has(id));
|
||
if (selectedTreeNodeId.value && removedIds.has(selectedTreeNodeId.value)) selectedTreeNodeId.value = null;
|
||
if (treeSelectionAnchorId.value && removedIds.has(treeSelectionAnchorId.value)) treeSelectionAnchorId.value = null;
|
||
for (const id of removedIds) {
|
||
invalidateCompletionCache(id);
|
||
void invalidateObjectDdlCache({ connectionId: id });
|
||
clearLoadedChildrenCache(id);
|
||
void deleteTabResultSnapshotsForOwner(id);
|
||
}
|
||
}
|
||
|
||
async function removeConnection(id: string) {
|
||
await removeConnections([id]);
|
||
}
|
||
|
||
async function updateConnection(config: ConnectionConfig) {
|
||
config = normalizeConnection(config);
|
||
const idx = connections.value.findIndex((c) => c.id === config.id);
|
||
if (idx < 0) return;
|
||
const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config);
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[idx] = config;
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
rebuildTreeNodes();
|
||
if (!runtimeConfigChanged) return;
|
||
connectedIds.value.delete(config.id);
|
||
clearConnectionIdentifierQuote(config.id);
|
||
clearConnectionHealthCheck(config.id);
|
||
invalidateCompletionCache(config.id);
|
||
void invalidateObjectDdlCache({ connectionId: config.id });
|
||
clearLoadedChildrenCache(config.id);
|
||
const node = findConnectionNode(config.id);
|
||
if (node?.isExpanded) {
|
||
await reloadConnectionDatabaseChildren(config.id);
|
||
}
|
||
}
|
||
|
||
async function updateConnectionDatabaseInfo(connectionId: string, databaseInfo: DatabaseConnectionInfo, expectedConfigFingerprint?: string): Promise<void> {
|
||
const normalized = normalizeDatabaseConnectionInfo(databaseInfo);
|
||
if (!normalized) return;
|
||
const current = connections.value.find((connection) => connection.id === connectionId);
|
||
if (!current) return;
|
||
if (expectedConfigFingerprint && connectionConfigFingerprint(current) !== expectedConfigFingerprint) return;
|
||
if (JSON.stringify(current.database_info) === JSON.stringify(normalized)) return;
|
||
|
||
await api.saveConnectionDatabaseInfo(connectionId, normalized);
|
||
const index = connections.value.findIndex((connection) => connection.id === connectionId);
|
||
if (index < 0) return;
|
||
if (expectedConfigFingerprint && connectionConfigFingerprint(connections.value[index]) !== expectedConfigFingerprint) return;
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[index] = { ...nextConnections[index], database_info: normalized };
|
||
connections.value = nextConnections;
|
||
// Database info is reactive connection metadata, not tree structure. Keep
|
||
// navigator node identities stable so an in-flight first expansion can
|
||
// still apply its loaded children after this background refresh completes.
|
||
}
|
||
|
||
async function refreshConnectedDatabaseInfo(connectionId: string, config: ConnectionConfig): Promise<void> {
|
||
const expectedConfigFingerprint = connectionConfigFingerprint(config);
|
||
try {
|
||
const detected = await api.connectionDatabaseInfo(connectionId);
|
||
const normalized = normalizeDatabaseConnectionInfo(detected, configuredDatabaseProductName(config), config.database);
|
||
if (normalized) await updateConnectionDatabaseInfo(connectionId, normalized, expectedConfigFingerprint);
|
||
} catch {
|
||
// Database metadata is optional and must not turn a successful connection into a failure.
|
||
}
|
||
}
|
||
|
||
async function syncMongoLegacyDriverFallback(connectionId: string, previousConfig: ConnectionConfig) {
|
||
if (!isDesktop || previousConfig.db_type !== "mongodb" || previousConfig.driver_profile === MONGO_LEGACY_DRIVER_PROFILE) {
|
||
return;
|
||
}
|
||
|
||
const savedConnections = await api.loadConnections().catch(() => null);
|
||
const savedConfig = savedConnections?.map((connection) => normalizeConnection(connection)).find((connection) => connection.id === connectionId && connection.driver_profile === MONGO_LEGACY_DRIVER_PROFILE);
|
||
if (!savedConfig) return;
|
||
|
||
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
|
||
if (idx < 0) return;
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[idx] = {
|
||
...savedConfig,
|
||
driver_label: savedConfig.driver_label || MONGO_LEGACY_DRIVER_LABEL,
|
||
};
|
||
connections.value = nextConnections;
|
||
rebuildTreeNodes();
|
||
}
|
||
|
||
async function ensureSqlServerLegacyCompatibilityComponentInstalled(config: ConnectionConfig) {
|
||
if (!requiresSqlServerLegacyCompatibilityComponent(config)) return;
|
||
if (await api.isAgentInstalled(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY)) return;
|
||
await api.installAgent(SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY);
|
||
}
|
||
|
||
async function setDefaultDatabase(connectionId: string, database: string) {
|
||
const config = getConfig(connectionId);
|
||
if (config?.db_type === "cloudflare-d1") return;
|
||
if (!config || config.database === database) return;
|
||
await updateConnection({
|
||
...config,
|
||
database,
|
||
});
|
||
}
|
||
|
||
async function clearDefaultDatabase(connectionId: string) {
|
||
const config = getConfig(connectionId);
|
||
if (config?.db_type === "cloudflare-d1") return;
|
||
if (!config || !config.database) return;
|
||
await updateConnection({
|
||
...config,
|
||
database: undefined,
|
||
});
|
||
}
|
||
|
||
function isDefaultDatabase(connectionId: string, database: string): boolean {
|
||
const config = getConfig(connectionId);
|
||
if (config?.db_type === "cloudflare-d1") return database === "main";
|
||
return config?.database === database && database !== "";
|
||
}
|
||
|
||
function getRedisDatabaseAlias(connectionId: string, database: string | number): string | undefined {
|
||
return redisDatabaseAlias(getConfig(connectionId)?.redis_database_aliases, database);
|
||
}
|
||
|
||
async function setRedisDatabaseAlias(connectionId: string, database: string | number, alias?: string) {
|
||
const index = typeof database === "number" ? database : Number(database);
|
||
const configIndex = connections.value.findIndex((connection) => connection.id === connectionId);
|
||
const config = connections.value[configIndex];
|
||
if (!config || config.db_type !== "redis" || !Number.isInteger(index) || index < 0) return;
|
||
|
||
const key = String(index);
|
||
const aliases = { ...(config.redis_database_aliases || {}) };
|
||
const normalizedAlias = alias?.trim() || "";
|
||
if (normalizedAlias) aliases[key] = normalizedAlias;
|
||
else delete aliases[key];
|
||
|
||
const redisDatabaseAliases = normalizeRedisDatabaseAliases(aliases);
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[configIndex] = {
|
||
...config,
|
||
redis_database_aliases: redisDatabaseAliases,
|
||
};
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
|
||
const node = findNode(treeNodes.value, `${connectionId}:db${key}`);
|
||
if (node?.type === "redis-db") {
|
||
node.label = redisDatabaseLabel(index, redisDatabaseAliases, node.totalKeyCount);
|
||
}
|
||
}
|
||
|
||
async function setVisibleDatabases(connectionId: string, databaseNames: string[]) {
|
||
const config = getConfig(connectionId);
|
||
if (!config) return;
|
||
await updateVisibleDatabasesConfig(connectionId, normalizeVisibleDatabaseSelection(databaseNames, databaseNames));
|
||
await reloadConnectionDatabaseChildren(connectionId);
|
||
}
|
||
|
||
async function clearVisibleDatabases(connectionId: string) {
|
||
const config = getConfig(connectionId);
|
||
if (!config || !Array.isArray(config.visible_databases)) return;
|
||
await updateVisibleDatabasesConfig(connectionId, undefined);
|
||
await reloadConnectionDatabaseChildren(connectionId);
|
||
}
|
||
|
||
async function ensureVisibleDatabase(connectionId: string, databaseName: string) {
|
||
const config = getConfig(connectionId);
|
||
if (!config) return;
|
||
const visibleDatabases = appendVisibleDatabaseSelection(config.visible_databases, databaseName);
|
||
if (visibleDatabases === config.visible_databases) return;
|
||
await updateVisibleDatabasesConfig(connectionId, visibleDatabases);
|
||
}
|
||
|
||
async function updateVisibleDatabasesConfig(connectionId: string, visibleDatabases: string[] | undefined) {
|
||
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
|
||
if (idx < 0) return;
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[idx] = {
|
||
...nextConnections[idx],
|
||
visible_databases: visibleDatabases,
|
||
};
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
invalidateCompletionCache(connectionId);
|
||
rebuildTreeNodes();
|
||
}
|
||
|
||
async function setVisibleSchemas(connectionId: string, database: string, schemaNames: string[]) {
|
||
const config = getConfig(connectionId);
|
||
if (!config) return;
|
||
const key = database || "";
|
||
await updateVisibleSchemasConfig(connectionId, key, schemaNames);
|
||
await reloadSchemaChildren(connectionId, database);
|
||
}
|
||
|
||
async function clearVisibleSchemas(connectionId: string, database: string) {
|
||
const config = getConfig(connectionId);
|
||
if (!config || !config.visible_schemas) return;
|
||
const key = database || "";
|
||
await updateVisibleSchemasConfig(connectionId, key, undefined);
|
||
await reloadSchemaChildren(connectionId, database);
|
||
}
|
||
|
||
async function updateVisibleSchemasConfig(connectionId: string, database: string, schemaNames: string[] | undefined) {
|
||
const idx = connections.value.findIndex((connection) => connection.id === connectionId);
|
||
if (idx < 0) return;
|
||
const existing = connections.value[idx].visible_schemas;
|
||
let nextSchemas: Record<string, string[]> | undefined;
|
||
if (schemaNames) {
|
||
nextSchemas = { ...existing, [database]: schemaNames };
|
||
} else if (existing) {
|
||
nextSchemas = { ...existing };
|
||
delete nextSchemas[database];
|
||
if (Object.keys(nextSchemas).length === 0) nextSchemas = undefined;
|
||
}
|
||
const nextConnections = [...connections.value];
|
||
nextConnections[idx] = {
|
||
...nextConnections[idx],
|
||
visible_schemas: nextSchemas,
|
||
};
|
||
await persistConnections(nextConnections);
|
||
connections.value = nextConnections;
|
||
rebuildTreeNodes();
|
||
}
|
||
|
||
async function reloadSchemaChildren(connectionId: string, database?: string) {
|
||
const config = getConfig(connectionId);
|
||
if (!config) return;
|
||
const db = database || config.database || "";
|
||
clearLoadedChildrenCache(connectionId);
|
||
clearLoadedChildrenCache(`${connectionId}:${db}`);
|
||
await loadDatabases(connectionId, { force: true });
|
||
// After saving schema filter, force-refresh database node's schema children
|
||
// to avoid stale children from previously expanded nodes
|
||
if (db) {
|
||
const dbNode = findNode(treeNodes.value, `${connectionId}:${db}`);
|
||
if (dbNode) {
|
||
await loadTreeNodeChildren(dbNode, { force: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
async function reloadConnectionDatabaseChildren(connectionId: string) {
|
||
const config = getConfig(connectionId);
|
||
if (!config) return;
|
||
clearLoadedChildrenCache(connectionId);
|
||
if (config.db_type === "redis") {
|
||
await loadRedisDatabases(connectionId);
|
||
} else if (config.db_type === "etcd") {
|
||
await loadEtcdRoot(connectionId);
|
||
} else if (config.db_type === "zookeeper") {
|
||
await loadZooKeeperRoot(connectionId);
|
||
} else if (config.db_type === "mongodb") {
|
||
await loadMongoDatabases(connectionId);
|
||
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
|
||
// Reload: list indices.
|
||
await loadElasticsearchIndices(connectionId);
|
||
} else if (config.db_type === "milvus") {
|
||
await loadMilvusDatabases(connectionId);
|
||
} else if (config.db_type === "qdrant" || config.db_type === "weaviate" || config.db_type === "chromadb") {
|
||
await loadVectorCollections(connectionId);
|
||
} else if (config.db_type === "mq") {
|
||
await loadMqTenants(connectionId, { force: true });
|
||
} else if (config.db_type === "nacos") {
|
||
await loadNacosNamespaces(connectionId, { force: true });
|
||
} else {
|
||
await loadDatabases(connectionId, { force: true });
|
||
}
|
||
}
|
||
|
||
async function connect(config: ConnectionConfig) {
|
||
config = normalizeConnection(config);
|
||
if (getBlockingDisconnectInFlight(config.id)) await waitForBlockingDisconnectInFlight(config.id);
|
||
const localAttempt = beginLocalConnectionAttempt(config.id);
|
||
try {
|
||
await beforeConnectHandler?.(config);
|
||
if (config.db_type === "sqlserver") {
|
||
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
|
||
}
|
||
ensureLocalConnectionAttemptActive(config.id, localAttempt);
|
||
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
|
||
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
|
||
await syncMongoLegacyDriverFallback(id, config);
|
||
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
|
||
activeConnectionId.value = id;
|
||
connectedIds.value.add(id);
|
||
void refreshConnectedDatabaseInfo(id, { ...config, id });
|
||
await refreshConnectionIdentifierQuote(id, { ...config, id });
|
||
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
|
||
markSuccessfulLocalConnectionAttempt(id, localAttempt);
|
||
markConnectionHealthChecked(id);
|
||
clearConnectionError(config.id);
|
||
if (id !== config.id) clearConnectionError(id);
|
||
|
||
const existing = findConnectionNode(id);
|
||
if (existing) {
|
||
existing.label = config.name;
|
||
existing.type = "connection";
|
||
existing.connectionId = id;
|
||
existing.children = existing.children || [];
|
||
} else {
|
||
treeNodes.value.push({
|
||
id,
|
||
label: config.name,
|
||
type: "connection",
|
||
connectionId: id,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
return id;
|
||
} catch (e) {
|
||
if (isCancelledLocalConnectionAttempt(config.id, localAttempt)) {
|
||
clearConnectionError(config.id);
|
||
throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
|
||
}
|
||
if (isCancelledConnectionAttempt(e) || isSupersededConnectionAttempt(e)) {
|
||
clearConnectionError(config.id);
|
||
} else {
|
||
recordConnectionError(config.id, e);
|
||
}
|
||
throw e;
|
||
} finally {
|
||
finishLocalConnectionAttempt(config.id, localAttempt);
|
||
}
|
||
}
|
||
|
||
async function cancelConnecting(connectionId: string): Promise<boolean> {
|
||
const localAttempt = getLocalConnectionAttempt(connectionId);
|
||
if (localAttempt == null) return false;
|
||
const disconnectRequest = startCancelDisconnectRequest(connectionId, localAttempt);
|
||
const cancelled = cancelLocalConnectionAttempt(connectionId);
|
||
if (!cancelled) return false;
|
||
clearConnectionError(connectionId);
|
||
connectedIds.value.delete(connectionId);
|
||
clearConnectionIdentifierQuote(connectionId);
|
||
clearConnectionHealthCheck(connectionId);
|
||
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
||
invalidateCompletionCache(connectionId);
|
||
await disconnectRequest;
|
||
return true;
|
||
}
|
||
|
||
async function disconnect(connectionId: string) {
|
||
const stateRevision = bumpConnectionStateRevision(connectionId);
|
||
const disconnectRequest = startDisconnectRequest(connectionId);
|
||
cancelLocalConnectionAttempt(connectionId);
|
||
const shouldRemoveOneTimeConnection = getConfig(connectionId)?.one_time === true;
|
||
|
||
connectedIds.value.delete(connectionId);
|
||
clearConnectionIdentifierQuote(connectionId);
|
||
forgetSuccessfulLocalConnectionAttempt(connectionId);
|
||
clearConnectionHealthCheck(connectionId);
|
||
const node = findConnectionNode(connectionId);
|
||
if (node) {
|
||
node.isLoading = false;
|
||
node.isExpanded = false;
|
||
node.children = [];
|
||
}
|
||
clearConnectionRootMetadataLoad(connectionId);
|
||
// Disconnecting only tears down the live session. Keep the schema snapshot so
|
||
// reconnecting can render databases and table names before the remote refresh.
|
||
clearLoadedChildrenCache(connectionId, { deletePersisted: false });
|
||
if (activeConnectionId.value === connectionId) {
|
||
activeConnectionId.value = null;
|
||
}
|
||
invalidateCompletionCache(connectionId);
|
||
invalidateObjectBrowserRowsCache({ connectionId });
|
||
const { useQueryStore } = await import("@/stores/queryStore");
|
||
const queryStore = useQueryStore();
|
||
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
|
||
case "close-tabs":
|
||
queryStore.closeConnectionTabs(connectionId);
|
||
break;
|
||
case "keep-tabs-clear-results":
|
||
queryStore.releaseConnectionTabs(connectionId);
|
||
break;
|
||
case "keep-tabs-keep-results":
|
||
queryStore.rollbackConnectionTransactions(connectionId);
|
||
break;
|
||
}
|
||
await disconnectRequest;
|
||
if (isCurrentConnectionStateRevision(connectionId, stateRevision)) {
|
||
clearConnectionError(connectionId);
|
||
}
|
||
if (shouldRemoveOneTimeConnection && isCurrentConnectionStateRevision(connectionId, stateRevision)) {
|
||
await removeConnection(connectionId);
|
||
}
|
||
}
|
||
|
||
async function closeDatabaseConnection(connectionId: string, database: string) {
|
||
await api.closeDatabaseConnection(connectionId, database);
|
||
const { useQueryStore } = await import("@/stores/queryStore");
|
||
const queryStore = useQueryStore();
|
||
switch (settingsStore.editorSettings.disconnectTabHandlingMode) {
|
||
case "close-tabs":
|
||
queryStore.closeDatabaseTabs(connectionId, database);
|
||
break;
|
||
case "keep-tabs-clear-results":
|
||
queryStore.releaseDatabaseTabs(connectionId, database);
|
||
break;
|
||
case "keep-tabs-keep-results":
|
||
queryStore.rollbackDatabaseTransactions(connectionId, database);
|
||
break;
|
||
}
|
||
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database);
|
||
if (node) {
|
||
node.isExpanded = false;
|
||
node.children = [];
|
||
clearLoadedChildrenCache(node.id);
|
||
}
|
||
invalidateCompletionCache(connectionId, database);
|
||
invalidateObjectBrowserRowsCache({ connectionId, database });
|
||
}
|
||
|
||
async function ensureConnected(connectionId: string) {
|
||
if (connectedIds.value.has(connectionId)) {
|
||
if (hasRecentConnectionHealthCheck(connectionId)) return;
|
||
// Optimistic: verify backend pool is actually healthy
|
||
try {
|
||
await withConnectionHealthTimeout(connectionId, api.checkConnectionHealth(connectionId));
|
||
markConnectionHealthChecked(connectionId);
|
||
return;
|
||
} catch {
|
||
// Backend pool is dead — remove from connectedIds and reconnect
|
||
connectedIds.value.delete(connectionId);
|
||
clearConnectionHealthCheck(connectionId);
|
||
if (activeConnectionId.value === connectionId) activeConnectionId.value = null;
|
||
}
|
||
}
|
||
let config = getConfig(connectionId);
|
||
if (!config) {
|
||
await initFromDisk();
|
||
config = getConfig(connectionId);
|
||
}
|
||
if (!config) {
|
||
const error = new Error("Connection config not found");
|
||
recordConnectionError(connectionId, error);
|
||
throw error;
|
||
}
|
||
if (getBlockingDisconnectInFlight(connectionId)) await waitForBlockingDisconnectInFlight(connectionId);
|
||
const existingConnect = connectInFlight.get(connectionId);
|
||
if (existingConnect) {
|
||
await existingConnect;
|
||
return;
|
||
}
|
||
const localAttempt = beginLocalConnectionAttempt(connectionId);
|
||
const connectPromise = (async () => {
|
||
await beforeConnectHandler?.(config);
|
||
if (config.db_type === "sqlserver") {
|
||
await ensureSqlServerLegacyCompatibilityComponentInstalled(config);
|
||
}
|
||
ensureLocalConnectionAttemptActive(connectionId, localAttempt);
|
||
const id = await withConnectionAttemptTimeout(api.connectDb(config, localAttempt), config);
|
||
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
|
||
await syncMongoLegacyDriverFallback(connectionId, config);
|
||
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
|
||
connectedIds.value.add(connectionId);
|
||
void refreshConnectedDatabaseInfo(connectionId, config);
|
||
await refreshConnectionIdentifierQuote(connectionId, config);
|
||
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
|
||
markConnectionHealthChecked(connectionId);
|
||
activeConnectionId.value = connectionId;
|
||
clearConnectionError(connectionId);
|
||
})();
|
||
connectInFlight.set(connectionId, connectPromise);
|
||
try {
|
||
await connectPromise;
|
||
} catch (e) {
|
||
if (isCancelledLocalConnectionAttempt(connectionId, localAttempt)) {
|
||
clearConnectionError(connectionId);
|
||
throw new Error(CONNECTION_ATTEMPT_CANCELLED_MESSAGE);
|
||
}
|
||
if (isCancelledConnectionAttempt(e)) {
|
||
clearConnectionError(connectionId);
|
||
throw e;
|
||
}
|
||
if (isSupersededConnectionAttempt(e) && connectedIds.value.has(connectionId)) {
|
||
clearConnectionError(connectionId);
|
||
return;
|
||
}
|
||
recordConnectionError(connectionId, e);
|
||
clearConnectionNodeLoading(connectionId);
|
||
throw e;
|
||
} finally {
|
||
if (connectInFlight.get(connectionId) === connectPromise) {
|
||
connectInFlight.delete(connectionId);
|
||
}
|
||
finishLocalConnectionAttempt(connectionId, localAttempt);
|
||
}
|
||
}
|
||
|
||
function setBeforeConnectHandler(handler: BeforeConnectHandler | null) {
|
||
beforeConnectHandler = handler;
|
||
}
|
||
|
||
function sidebarDatabaseStorageRequestKey(connectionId: string, databases: readonly string[]): string {
|
||
return `${connectionId}\0${[...databases].sort().join("\0")}`;
|
||
}
|
||
|
||
async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise<void> {
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(connectionId)) return;
|
||
if (!supportsSidebarDatabaseStorage(getConfig(connectionId))) return;
|
||
const connectionNode = findConnectionNode(connectionId);
|
||
const databases = sidebarDatabaseNames(connectionNode?.children);
|
||
if (!databases.length) return;
|
||
|
||
const requestKey = sidebarDatabaseStorageRequestKey(connectionId, databases);
|
||
const cached = sidebarDatabaseStorageCache.get(requestKey);
|
||
if (!options?.force && cached && cached.expiresAt > Date.now()) {
|
||
applySidebarDatabaseStorage(connectionNode?.children, cached.value);
|
||
return;
|
||
}
|
||
|
||
let request = sidebarDatabaseStorageInFlight.get(requestKey);
|
||
if (!request) {
|
||
request = api.listDatabaseStorage(connectionId, databases);
|
||
sidebarDatabaseStorageInFlight.set(requestKey, request);
|
||
}
|
||
try {
|
||
const storage = await request;
|
||
sidebarDatabaseStorageCache.set(requestKey, {
|
||
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||
value: storage,
|
||
});
|
||
const currentNode = findConnectionNode(connectionId);
|
||
const currentNames = sidebarDatabaseNames(currentNode?.children);
|
||
if (sidebarDatabaseStorageRequestKey(connectionId, currentNames) === requestKey) {
|
||
applySidebarDatabaseStorage(currentNode?.children, storage);
|
||
}
|
||
} catch (error) {
|
||
console.debug("[DBX][sidebar-database-storage:unavailable]", { connectionId, error });
|
||
} finally {
|
||
if (sidebarDatabaseStorageInFlight.get(requestKey) === request) {
|
||
sidebarDatabaseStorageInFlight.delete(requestKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
function sidebarTableStorageRequestKey(scope: SidebarTableStorageScope): string {
|
||
return `${scope.connectionId}\0${scope.database}\0${scope.schema}`;
|
||
}
|
||
|
||
async function loadSidebarTableStorage(scope: SidebarTableStorageScope, options?: { force?: boolean }): Promise<void> {
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return;
|
||
if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return;
|
||
const requestKey = sidebarTableStorageRequestKey(scope);
|
||
const cached = sidebarTableStorageCache.get(requestKey);
|
||
if (!options?.force && cached && cached.expiresAt > Date.now()) {
|
||
applySidebarTableStorage(treeNodes.value, scope, cached.value);
|
||
return;
|
||
}
|
||
|
||
let request = sidebarTableStorageInFlight.get(requestKey);
|
||
if (!request) {
|
||
request = api.listObjectStatistics(scope.connectionId, scope.database, scope.schema);
|
||
sidebarTableStorageInFlight.set(requestKey, request);
|
||
}
|
||
try {
|
||
const statistics = await request;
|
||
sidebarTableStorageCache.set(requestKey, {
|
||
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
|
||
value: statistics,
|
||
});
|
||
applySidebarTableStorage(treeNodes.value, scope, statistics);
|
||
} catch (error) {
|
||
console.debug("[DBX][sidebar-table-storage:unavailable]", { ...scope, error });
|
||
} finally {
|
||
if (sidebarTableStorageInFlight.get(requestKey) === request) {
|
||
sidebarTableStorageInFlight.delete(requestKey);
|
||
}
|
||
}
|
||
}
|
||
|
||
const sidebarDatabaseStorageScope = computed(() => {
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return "";
|
||
return [...connectedIds.value]
|
||
.filter((connectionId) => supportsSidebarDatabaseStorage(getConfig(connectionId)))
|
||
.map((connectionId) => sidebarDatabaseStorageRequestKey(connectionId, sidebarDatabaseNames(findConnectionNode(connectionId)?.children)))
|
||
.sort()
|
||
.join("\n");
|
||
});
|
||
|
||
watch(
|
||
sidebarDatabaseStorageScope,
|
||
() => {
|
||
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return;
|
||
for (const connectionId of connectedIds.value) {
|
||
void loadSidebarDatabaseStorage(connectionId);
|
||
}
|
||
},
|
||
{ flush: "post" },
|
||
);
|
||
|
||
async function loadDatabases(connectionId: string, options?: LoadTreeOptions) {
|
||
const configForScope = getConfig(connectionId);
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
if (!options?.force && !options?.connectedOnly && !searchFilter) {
|
||
const cacheHit = await hydrateConnectionRootFromCache(connectionId, configForScope);
|
||
if (cacheHit) {
|
||
// Render the last known metadata immediately; network validation and refresh
|
||
// continue in the background so opening a connection never waits on them.
|
||
void loadDatabases(connectionId, { ...options, force: true }).catch(() => undefined);
|
||
return;
|
||
}
|
||
}
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "connection-databases",
|
||
connectionId,
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
},
|
||
async () => {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
if (options?.connectedOnly) {
|
||
if (!connectedIds.value.has(connectionId)) return;
|
||
} else {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
}
|
||
if (useCachedChildren(node, options, load)) return;
|
||
|
||
const config = getConfig(connectionId);
|
||
if (config?.db_type === "duckdb") {
|
||
const cacheKey = schemaCacheKey(connectionId, "duckdb-root");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const [databases, schemas] = await Promise.all([withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases"), withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, "main"), "schemas")]);
|
||
const children = withSavedSqlRoot(connectionId, buildDuckDbConnectionTreeNodes(connectionId, databases, schemas), node);
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children);
|
||
} else if (config && connectionUsesVisibleSchemaFilter(config)) {
|
||
const schemaFilterConfig = config;
|
||
const effectiveDb = schemaFilterConfig.database || "";
|
||
const showSystemSchemas = schemaFilterConfig.show_system_schemas === true;
|
||
const cacheKey = schemaCacheKey(connectionId, effectiveDb, config.db_type === "oracle" ? "schemas-v2" : "schemas", showSystemSchemas ? "show-system" : "hide-system");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const schemas = await withMetadataLoadTimeout(connectionId, api.listSchemas(connectionId, effectiveDb, true), "schemas");
|
||
const visibleSchemas = filterSchemaNamesForConnection(schemas, schemaFilterConfig, effectiveDb || "", { showSystemSchemas });
|
||
const schemaNodes: TreeNode[] = sortSidebarNames(visibleSchemas).map((s) => ({
|
||
id: `${connectionId}:${s}:${s}`,
|
||
label: s,
|
||
type: "schema" as const,
|
||
connectionId,
|
||
database: s,
|
||
schema: s,
|
||
isExpanded: false,
|
||
children: [],
|
||
}));
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, withSavedSqlRoot(connectionId, schemaNodes, targetNode));
|
||
await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || schemaNodes);
|
||
} else {
|
||
// Doris / StarRocks multi-catalog: when external catalogs exist,
|
||
// render a catalog grouping layer (catalog → database → tables).
|
||
// When only `internal` is present, fall through to the flat database
|
||
// list (no regression for single-catalog deployments).
|
||
let dorisCatalogs: CatalogInfo[] | null = null;
|
||
if (connectionIsDorisFamilyCatalogCapable(config)) {
|
||
dorisCatalogs = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogs(connectionId), "catalogs").catch((error: unknown) => {
|
||
recordMetadataLoadError(connectionId, error, load);
|
||
return null;
|
||
});
|
||
}
|
||
if (dorisCatalogs && dorisCatalogs.length > 1) {
|
||
const cacheKey = schemaCacheKey(connectionId, "doris-catalogs");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const catalogNodes: TreeNode[] = dorisCatalogs.map((catalog) => ({
|
||
id: dorisCatalogId(connectionId, catalog.name),
|
||
label: catalog.name,
|
||
type: "doris-catalog" as const,
|
||
connectionId,
|
||
catalog: catalog.name,
|
||
catalogType: catalog.catalog_type,
|
||
comment: catalog.comment ?? null,
|
||
isExpanded: false,
|
||
children: [],
|
||
}));
|
||
const children = withSavedSqlRoot(connectionId, catalogNodes, node);
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children);
|
||
} else {
|
||
const cacheKey = schemaCacheKey(connectionId, "databases-v2");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const databases = await withMetadataLoadTimeout(connectionId, api.listDatabases(connectionId), "databases");
|
||
const visibleNames = filterDatabaseNamesForConnection(
|
||
databases.map((database) => database.name),
|
||
config,
|
||
);
|
||
const visibleNameSet = new Set(visibleNames);
|
||
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||
const databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, {
|
||
includeDefaultWhenEmpty: usesTreeSchemaMode(effectiveDbType) || shouldIncludeDefaultDatabaseNode(config, visibleDatabases),
|
||
});
|
||
if (config?.db_type === "sqlserver") {
|
||
const linkedServers = await withMetadataLoadTimeout(connectionId, api.listSqlServerLinkedServers(connectionId), "linked servers").catch(() => []);
|
||
const linkedDatabase = sqlServerLinkedRuntimeDatabase(config);
|
||
databaseNodes.push({
|
||
...sqlServerLinkedRootNode(connectionId, linkedDatabase),
|
||
children: linkedServers.map((server) => ({
|
||
id: sqlServerLinkedServerId(connectionId, server.name),
|
||
label: server.name,
|
||
type: "linked-server",
|
||
connectionId,
|
||
database: linkedDatabase,
|
||
linkedServer: server.name,
|
||
comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
});
|
||
if (linkedServers.length > 0) loadedTreeNodeChildrenIds.value.add(sqlServerLinkedRootId(connectionId));
|
||
}
|
||
const children = withSavedSqlRoot(connectionId, databaseNodes, node);
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
await savePersistedConnectionTreeChildren(cacheKey, targetNode.children || children);
|
||
}
|
||
}
|
||
const liveNode = treeNodeLoadTarget(load);
|
||
if (liveNode) liveNode.isExpanded = true;
|
||
if (options?.force) void loadSidebarDatabaseStorage(connectionId, { force: true });
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
options,
|
||
);
|
||
}
|
||
|
||
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
|
||
if (!connectedIds.value.has(connectionId)) return;
|
||
const config = getConfig(connectionId);
|
||
if (!config || ["redis", "etcd", "zookeeper", "mongodb", "elasticsearch", "easysearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node || node.type !== "connection" || node.isLoading || hasConnectionMetadataChildren(node.children)) return;
|
||
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
|
||
if (metadataLoadCoordinator.has(scope)) return;
|
||
|
||
const wasExpanded = !!node.isExpanded;
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
await loadDatabases(connectionId, { connectedOnly: true });
|
||
} finally {
|
||
const liveNode = treeNodeInSidebarTree(node);
|
||
if (liveNode) liveNode.isExpanded = wasExpanded;
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadRedisDatabases(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const dbs = await withMetadataLoadTimeout(connectionId, api.redisListDatabases(connectionId), "Redis databases");
|
||
const config = getConfig(connectionId);
|
||
const visibleNames = filterVisibleDatabaseNames(
|
||
dbs.map((db) => String(db.db)),
|
||
config?.visible_databases,
|
||
);
|
||
const visibleNameSet = new Set(visibleNames);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
dbs
|
||
.filter((db) => visibleNameSet.has(String(db.db)))
|
||
.map((db) => ({
|
||
id: `${connectionId}:db${db.db}`,
|
||
label: redisDatabaseLabel(db.db, config?.redis_database_aliases, db.keys),
|
||
type: "redis-db" as const,
|
||
connectionId,
|
||
database: String(db.db),
|
||
loadedKeyCount: 0,
|
||
totalKeyCount: db.keys,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadEtcdRoot(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
[
|
||
{
|
||
id: `${connectionId}:etcd`,
|
||
label: kvRootNodeLabel("etcd"),
|
||
type: "etcd-root" as const,
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
{
|
||
id: `${connectionId}:etcd-access-control`,
|
||
label: "用户和角色",
|
||
type: "etcd-access-control" as const,
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
{
|
||
id: `${connectionId}:etcd-dashboard`,
|
||
label: "服务仪表盘",
|
||
type: "etcd-dashboard" as const,
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
],
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadZooKeeperRoot(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
[
|
||
{
|
||
id: `${connectionId}:zookeeper`,
|
||
label: kvRootNodeLabel("zookeeper"),
|
||
type: "zookeeper-root" as const,
|
||
connectionId,
|
||
database: "",
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
],
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadMqTenants(connectionId: string, options?: LoadTreeOptions) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
|
||
const config = getConfig(connectionId);
|
||
if (isFlatMqConnection(config)) {
|
||
// Kafka/RocketMQ have no tenant/namespace concept; RabbitMQ pins a synthetic
|
||
// tenant and exposes virtual hosts as namespaces inside the console. Create a
|
||
// synthetic child that opens the MQ admin console directly when clicked.
|
||
const mqTenant = resolveMqSystemKindFromConnection(config) === "rabbitmq" ? RABBITMQ_MQ_TENANT : "_flat_mq";
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, [
|
||
{
|
||
id: schemaCacheKey(connectionId, "mq-tenant", mqTenant),
|
||
label: "Topics",
|
||
type: "mq-tenant" as const,
|
||
connectionId,
|
||
mqTenant,
|
||
mqInitialTab: "topics",
|
||
},
|
||
]);
|
||
} else {
|
||
const tenants = await withMetadataLoadTimeout(connectionId, api.mqListTenants(connectionId), "message queue tenants");
|
||
const tenantNames = sortSidebarNames(tenants.map((tenant) => tenant.name).filter((name) => !!name.trim()));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
tenantNames.map((tenant) => ({
|
||
id: schemaCacheKey(connectionId, "mq-tenant", tenant),
|
||
label: tenant,
|
||
type: "mq-tenant" as const,
|
||
connectionId,
|
||
mqTenant: tenant,
|
||
})),
|
||
);
|
||
}
|
||
const liveNode = treeNodeLoadTarget(load);
|
||
if (liveNode) liveNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadNacosNamespaces(connectionId: string, options?: LoadTreeOptions) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
|
||
const namespaces = await api.nacosListNamespaces(connectionId);
|
||
const sorted = [...namespaces].sort((left, right) => {
|
||
const leftLabel = left.namespaceShowName || left.namespace || "public";
|
||
const rightLabel = right.namespaceShowName || right.namespace || "public";
|
||
return leftLabel.localeCompare(rightLabel);
|
||
});
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
sorted.map((namespace) => {
|
||
const value = namespace.namespace || "";
|
||
const label = namespace.namespaceShowName || value || "public";
|
||
return {
|
||
id: schemaCacheKey(connectionId, "nacos-namespace", value || "public"),
|
||
label,
|
||
type: "nacos-namespace" as const,
|
||
connectionId,
|
||
nacosNamespace: value,
|
||
nacosNamespaceName: label,
|
||
comment: namespace.namespaceDesc || null,
|
||
objectCount: namespace.configCount,
|
||
};
|
||
}),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
function updateRedisDbKeyStats(connectionId: string, db: number, stats: { loaded?: number; total?: number; totalDelta?: number }) {
|
||
const node = findNode(treeNodes.value, `${connectionId}:db${db}`);
|
||
if (!node || node.type !== "redis-db") return;
|
||
if (stats.loaded != null) node.loadedKeyCount = stats.loaded;
|
||
if (stats.total != null) node.totalKeyCount = stats.total;
|
||
if (stats.totalDelta != null && node.totalKeyCount != null) {
|
||
node.totalKeyCount = Math.max(0, node.totalKeyCount + stats.totalDelta);
|
||
}
|
||
node.label = redisDatabaseLabel(db, getConfig(connectionId)?.redis_database_aliases, node.totalKeyCount);
|
||
}
|
||
|
||
// Re-fetch the authoritative per-db key counts (INFO keyspace, lightweight) and update
|
||
// the sidebar db nodes' counts in place — WITHOUT rebuilding the tree, so already-loaded
|
||
// key trees under expanded db nodes are preserved. Used after a Redis write command so the
|
||
// `dbN (count)` labels reflect the new reality without a manual refresh.
|
||
async function refreshRedisDbKeyCounts(connectionId: string) {
|
||
const connNode = findConnectionNode(connectionId);
|
||
if (!connNode) return;
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
const dbs = await api.redisListDatabases(connectionId);
|
||
for (const db of dbs) {
|
||
updateRedisDbKeyStats(connectionId, db.db, { total: db.keys });
|
||
}
|
||
} catch {
|
||
// Best-effort: a failed count refresh must not disrupt the result view.
|
||
}
|
||
}
|
||
|
||
async function loadMongoDatabases(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const dbs = await withMetadataLoadTimeout(connectionId, api.mongoListDatabases(connectionId), "MongoDB databases");
|
||
const config = getConfig(connectionId);
|
||
const visibleDbs = filterDatabaseNamesForConnection(dbs, config);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
sortSidebarNames(visibleDbs).map((db) => ({
|
||
id: `${connectionId}:${db}`,
|
||
label: db,
|
||
type: "mongo-db" as const,
|
||
connectionId,
|
||
database: db,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Connect an Elasticsearch root without expanding or listing indices.
|
||
* Used when first opening a connection (test/connect) — connectivity uses
|
||
* GET / or the configured check path via ensureConnected/test_connection.
|
||
* Expanding the node lists indices via loadElasticsearchIndices.
|
||
*/
|
||
async function openElasticsearchConnectionTree(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
// Only ensure connectivity (GET / or configured path); do not expand or list indices.
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async function loadElasticsearchIndices(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const indices = await withMetadataLoadTimeout(connectionId, api.elasticsearchListIndices(connectionId), "Elasticsearch indices");
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
sortSidebarNames(indices).map((index) => ({
|
||
id: `${connectionId}:__collection:${index}`,
|
||
label: index,
|
||
type: "elasticsearch-index" as const,
|
||
connectionId,
|
||
database: "default",
|
||
isExpanded: false,
|
||
})),
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadMilvusDatabases(connectionId: string) {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const dbs = await withMetadataLoadTimeout(connectionId, api.documentListDatabases(connectionId), "Milvus databases");
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
withSavedSqlRoot(
|
||
connectionId,
|
||
sortSidebarNames(dbs).map((db) => ({
|
||
id: `${connectionId}:${db}`,
|
||
label: db,
|
||
type: "vector-database" as const,
|
||
connectionId,
|
||
database: db,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
targetNode,
|
||
),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadVectorCollections(connectionId: string, database?: string) {
|
||
const config = getConfig(connectionId);
|
||
const isMilvus = config?.db_type === "milvus";
|
||
const effectiveDb = database || config?.database || "default";
|
||
// Milvus groups collections under a per-database node; other vector stores stay flat under the connection.
|
||
const node = isMilvus && database ? findNode(treeNodes.value, `${connectionId}:${database}`) : findConnectionNode(connectionId);
|
||
if (!node) return;
|
||
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
const collections = await withMetadataLoadTimeout(connectionId, api.vectorListCollections(connectionId, effectiveDb), "vector collections");
|
||
const sorted = [...collections].sort((a, b) => a.name.localeCompare(b.name));
|
||
const collectionChildren = sorted.map((info) => ({
|
||
// Include the database for Milvus so same-named collections across databases don't collide.
|
||
id: `${connectionId}:__vector_collection:${isMilvus ? `${effectiveDb}:${info.id}` : info.id}`,
|
||
label: info.name,
|
||
type: "vector-collection" as const,
|
||
connectionId,
|
||
database: effectiveDb,
|
||
isExpanded: false,
|
||
meta: { dimension: info.dimension, collectionId: info.id } as VectorCollectionMeta,
|
||
}));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, isMilvus && database ? collectionChildren : withSavedSqlRoot(connectionId, collectionChildren, targetNode));
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadMongoCollections(connectionId: string, database: string) {
|
||
const nodeId = `${connectionId}:${database}`;
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (!node) return;
|
||
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const collections = await api.mongoListCollections(connectionId, database);
|
||
const bucketNames = new Set(collections.filter((c) => c.kind === "bucket" && c.bucketName).map((c) => c.bucketName as string));
|
||
const hiddenCollectionNames = new Set([...bucketNames].flatMap((bucketName) => [`${bucketName}.files`, `${bucketName}.chunks`]));
|
||
const collectionEntries = collections.filter((c) => c.kind !== "bucket").filter((c) => !hiddenCollectionNames.has(c.name));
|
||
const collectionChildren = [...collectionEntries]
|
||
.sort((left, right) => compareSidebarNames(left.name, right.name))
|
||
.map((col) => ({
|
||
id: `${nodeId}:${col.name}`,
|
||
label: col.name,
|
||
type: "mongo-collection" as const,
|
||
connectionId,
|
||
database,
|
||
meta: { collectionKind: toMongoCollectionKind(col.kind) },
|
||
isExpanded: false,
|
||
}));
|
||
const children = [
|
||
{
|
||
id: `${nodeId}:__gridfs`,
|
||
label: i18n.global.t("tree.gridfs"),
|
||
type: "mongo-gridfs" as const,
|
||
connectionId,
|
||
database,
|
||
isExpanded: false,
|
||
},
|
||
...collectionChildren,
|
||
];
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadSchemas(connectionId: string, database: string, options?: LoadTreeOptions) {
|
||
const configForScope = getConfig(connectionId);
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "database-schemas",
|
||
connectionId,
|
||
database,
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
},
|
||
async () => {
|
||
const nodeId = `${connectionId}:${database}`;
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const config = getConfig(connectionId);
|
||
const showSystemSchemas = config?.show_system_schemas === true;
|
||
const cacheVersion = ownerAwareMetadataCacheVersion(config, "schemas-v3");
|
||
const cacheKey = schemaCacheKey(connectionId, database, cacheVersion, showSystemSchemas ? "show-system" : "hide-system");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const schemas = sortSidebarSchemaInfos(await withMetadataLoadTimeout(connectionId, api.listSchemaInfos(connectionId, database), "schemas"));
|
||
const visibleSchemaNames = new Set(
|
||
filterSchemaNamesForConnection(
|
||
schemas.map((schema) => schema.name),
|
||
getConfig(connectionId),
|
||
database,
|
||
{ showSystemSchemas },
|
||
),
|
||
);
|
||
const children: TreeNode[] = schemas
|
||
.filter((schema) => visibleSchemaNames.has(schema.name))
|
||
.map((schema) => {
|
||
const s = schema.name;
|
||
return {
|
||
id: `${connectionId}:${database}:${s}`,
|
||
label: s,
|
||
type: "schema" as const,
|
||
connectionId,
|
||
database,
|
||
schema: s,
|
||
comment: schema.comment,
|
||
isExpanded: false,
|
||
children: [],
|
||
};
|
||
});
|
||
if (schemas.length === 0 && connectionShouldDiscoverJdbcSchemas(getConfig(connectionId))) {
|
||
// Generic JDBC drivers vary widely: prefer schema navigation when the
|
||
// driver reports schemas, but keep the legacy flat object tree when it
|
||
// reports none so non-schema engines do not expand into an empty node.
|
||
await loadTables(connectionId, database, undefined, options);
|
||
return;
|
||
}
|
||
if (isPostgresLikeForExtensions(getConfig(connectionId)?.db_type)) {
|
||
children.push(buildExtensionManagementNode(connectionId, database));
|
||
}
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
await savePersistedTreeChildren(cacheKey, children);
|
||
const currentTargetNode = treeNodeLoadTarget(load);
|
||
if (currentTargetNode) currentTargetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
options,
|
||
);
|
||
}
|
||
|
||
async function loadSqlServerDatabaseObjects(connectionId: string, database: string, options?: LoadTreeOptions) {
|
||
const nodeId = `${connectionId}:${database}`;
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const config = getConfig(connectionId);
|
||
const showSystemSchemas = config?.show_system_schemas === true;
|
||
const cacheKey = schemaCacheKey(connectionId, database, simpleObjectDisplay ? "sqlserver-schemas-simple-v4" : "sqlserver-schemas-grouped-v4", showSystemSchemas ? "show-system" : "hide-system");
|
||
if (!options?.force) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const schemas = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), config, database, { showSystemSchemas });
|
||
const children = buildSqlServerDatabaseTreeNodes(connectionId, database, schemas);
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
await savePersistedTreeChildren(cacheKey, children);
|
||
const currentTargetNode = treeNodeLoadTarget(load);
|
||
if (currentTargetNode) currentTargetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadSqlServerLinkedServers(connectionId: string, options?: LoadTreeOptions) {
|
||
const node = findNode(treeNodes.value, sqlServerLinkedRootId(connectionId));
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const config = getConfig(connectionId);
|
||
const database = sqlServerLinkedRuntimeDatabase(config);
|
||
const linkedServers = await api.listSqlServerLinkedServers(connectionId);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
linkedServers.map((server) => ({
|
||
id: sqlServerLinkedServerId(connectionId, server.name),
|
||
label: server.name,
|
||
type: "linked-server" as const,
|
||
connectionId,
|
||
database,
|
||
linkedServer: server.name,
|
||
comment: [server.product, server.provider, server.data_source].filter(Boolean).join(" / ") || null,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadSqlServerLinkedServerCatalogs(node: TreeNode, options?: LoadTreeOptions) {
|
||
if (!node.connectionId || !node.linkedServer) return;
|
||
const connectionId = node.connectionId;
|
||
const server = node.linkedServer;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const catalogs = await api.listSqlServerLinkedServerCatalogs(connectionId, server);
|
||
const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(connectionId));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
catalogs
|
||
.filter((catalog) => catalog.name.trim())
|
||
.map((catalog) => ({
|
||
id: sqlServerLinkedCatalogId(connectionId, server, catalog.name),
|
||
label: catalog.name,
|
||
type: "linked-server-catalog" as const,
|
||
connectionId,
|
||
database,
|
||
linkedServer: server,
|
||
linkedCatalog: catalog.name,
|
||
isExpanded: false,
|
||
children: [],
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadSqlServerLinkedServerSchemas(node: TreeNode, options?: LoadTreeOptions) {
|
||
if (!node.connectionId || !node.linkedServer || !node.linkedCatalog) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(node.connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const schemas = await api.listSqlServerLinkedServerSchemas(node.connectionId, node.linkedServer, node.linkedCatalog);
|
||
const database = node.database || sqlServerLinkedRuntimeDatabase(getConfig(node.connectionId));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
sortSidebarNames(schemas)
|
||
.filter((schema) => schema.trim())
|
||
.map((schema) => {
|
||
const encodedSchema = encodeSqlServerLinkedSchema({
|
||
server: targetNode.linkedServer!,
|
||
catalog: targetNode.linkedCatalog!,
|
||
schema,
|
||
});
|
||
return {
|
||
id: `${targetNode.connectionId}:${database}:${encodedSchema}`,
|
||
label: schema,
|
||
type: "linked-server-schema" as const,
|
||
connectionId: targetNode.connectionId,
|
||
database,
|
||
schema: encodedSchema,
|
||
linkedServer: targetNode.linkedServer,
|
||
linkedCatalog: targetNode.linkedCatalog,
|
||
linkedSchema: schema,
|
||
isExpanded: false,
|
||
children: [],
|
||
};
|
||
}),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(node.connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
// Doris / StarRocks multi-catalog: load the databases under a catalog node.
|
||
async function loadDorisCatalogDatabases(node: TreeNode, options?: LoadTreeOptions) {
|
||
if (!node.connectionId || !node.catalog) return;
|
||
const connectionId = node.connectionId;
|
||
const catalog = node.catalog;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const config = getConfig(connectionId);
|
||
const databases = await withMetadataLoadTimeout(connectionId, api.listDorisCatalogDatabases(connectionId, catalog), "databases");
|
||
const visibleNames = filterDatabaseNamesForConnection(
|
||
databases.map((database) => database.name),
|
||
config,
|
||
);
|
||
const visibleNameSet = new Set(visibleNames);
|
||
const visibleDatabases = databases.filter((database) => visibleNameSet.has(database.name));
|
||
let databaseNodes: TreeNode[];
|
||
if (isInternalDorisCatalog(node.catalogType, catalog)) {
|
||
// The internal catalog's databases are rendered as standard database
|
||
// nodes so they reuse the existing table-loading / table-open paths.
|
||
// Detection is type-based (catalogType=`internal`), so StarRocks
|
||
// `default_catalog` routes here too — its tables carry no catalog.
|
||
databaseNodes = buildDatabaseTreeNodes(connectionId, visibleDatabases, { includeDefaultWhenEmpty: false });
|
||
} else {
|
||
databaseNodes = sortSidebarDatabases(visibleDatabases).flatMap((database) => {
|
||
const name = database.name.trim();
|
||
if (!name) return [];
|
||
return [
|
||
{
|
||
id: dorisCatalogDatabaseId(connectionId, catalog, name),
|
||
label: name,
|
||
type: "database" as const,
|
||
connectionId,
|
||
database: name,
|
||
catalog,
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
];
|
||
});
|
||
}
|
||
if (isSidebarSearchQueryChanged(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, databaseNodes);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
// Doris / StarRocks multi-catalog: load tables under an external-catalog
|
||
// database node. External catalogs only expose tables/views, so a flat list
|
||
// (no routines/sequences/triggers) is rendered.
|
||
async function loadDorisCatalogTables(node: TreeNode, options?: LoadTreeOptions) {
|
||
if (!node.connectionId || !node.database || !node.catalog) return;
|
||
const connectionId = node.connectionId;
|
||
const { database, catalog } = node;
|
||
const configForScope = getConfig(connectionId);
|
||
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "schema-tables",
|
||
connectionId,
|
||
database,
|
||
schema: undefined,
|
||
nodeKind: "database",
|
||
objectTypes: objectTypesForScope,
|
||
searchFilter: activeTreeLoadSearchFilter(options),
|
||
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
|
||
offset: 0,
|
||
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
|
||
},
|
||
async () => {
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
const tableNameFilter = activeTableNameFilterForScope({
|
||
connectionId,
|
||
database,
|
||
nodeKind: "simple-tables",
|
||
catalog,
|
||
});
|
||
const cacheKey = schemaCacheKey(connectionId, `doris-catalog:${catalog}`, database, "objects-simple-v5");
|
||
if (!options?.force && !searchFilter && !tableNameFilter) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
const pageSize = sidebarObjectGroupPageSize();
|
||
const fetchLimit = searchFilter ? pageSize : pageSize + 1;
|
||
const fetchOffset = searchFilter ? undefined : 0;
|
||
const tables = await withMetadataLoadTimeout(connectionId, listTablesWithOptionalTableNameFilter(connectionId, database, "", searchFilter, fetchLimit, fetchOffset, objectTypesForScope, catalog, tableNameFilter), "tables");
|
||
const hasMore = searchFilter ? false : tables.length > pageSize;
|
||
const pageTables = hasMore ? tables.slice(0, pageSize) : tables;
|
||
indexCompletionTables(connectionId, database, undefined, tableInfosToCompletionTables(pageTables, undefined), catalog);
|
||
let children = buildTableTreeNodes({
|
||
nodeId: node.id,
|
||
connectionId,
|
||
database,
|
||
schema: undefined,
|
||
tables: pageTables,
|
||
catalog,
|
||
});
|
||
if (hasMore && !searchFilter) {
|
||
children = [...children, buildLoadMoreNode(node, pageSize, pageSize)];
|
||
}
|
||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||
if (!tableNameFilterRevisionMatches(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
targetNode.objectCount = children.filter((child) => child.type !== "load-more").length;
|
||
setChildren(targetNode, children);
|
||
if (!searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilter) {
|
||
await savePersistedTreeChildren(cacheKey, children);
|
||
}
|
||
const currentTargetNode = treeNodeLoadTarget(load);
|
||
if (currentTargetNode) currentTargetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
options,
|
||
);
|
||
}
|
||
|
||
async function loadTables(connectionId: string, database: string, schema?: string, options?: LoadTreeOptions) {
|
||
const configForScope = getConfig(connectionId);
|
||
const simpleObjectDisplayForScope = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const objectTypesForScope = simpleObjectDisplayForScope ? supportedSidebarObjectTypes(configForScope) : undefined;
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, database, schema);
|
||
const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, database, schema);
|
||
const tableNameFilterForScope = activeTableNameFilterForScope({
|
||
connectionId,
|
||
database,
|
||
schema: effectiveSchemaForScope ?? querySchemaForScope,
|
||
nodeKind: simpleObjectDisplayForScope ? "simple-tables" : "group-tables",
|
||
});
|
||
if (!options?.force && simpleObjectDisplayForScope && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
|
||
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", ownerAwareMetadataCacheVersion(configForScope, "objects-simple-v6"));
|
||
if (await hydrateTreeNodeFromCache(findNode(treeNodes.value, nodeId), cacheKey)) {
|
||
void loadTables(connectionId, database, schema, { ...options, force: true }).catch(() => undefined);
|
||
return;
|
||
}
|
||
}
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "schema-tables",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
nodeKind: schema ? "schema" : "database",
|
||
objectTypes: objectTypesForScope,
|
||
searchFilter: activeTreeLoadSearchFilter(options),
|
||
limit: simpleObjectDisplayForScope ? sidebarObjectGroupPageSize() + 1 : undefined,
|
||
offset: 0,
|
||
sidebarDisplayMode: simpleObjectDisplayForScope ? "simple" : "grouped",
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
|
||
},
|
||
async () => {
|
||
const nodeId = schema ? `${connectionId}:${database}:${schema}` : `${connectionId}:${database}`;
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
const config = getConfig(connectionId);
|
||
const cacheVersion = ownerAwareMetadataCacheVersion(config, simpleObjectDisplay ? "objects-simple-v6" : "objects-grouped-v7");
|
||
const cacheKey = schemaCacheKey(connectionId, database, schema || "", cacheVersion);
|
||
const querySchema = connectionObjectTreeQuerySchema(config, database, schema);
|
||
const effectiveSchema = connectionObjectTreeNodeSchema(config, database, schema);
|
||
const tableNameFilter = activeTableNameFilterForScope({
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
nodeKind: simpleObjectDisplay ? "simple-tables" : "group-tables",
|
||
});
|
||
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
|
||
if (!options?.force && !searchFilter && !tableNameFilter) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const nonTableObjectTypes = simpleObjectDisplay ? supportedSidebarObjectTypes(config).filter((objectType) => objectType !== "TABLE") : [];
|
||
let children: TreeNode[];
|
||
let nextObjectCount: number | undefined;
|
||
if (simpleObjectDisplay) {
|
||
const pageSize = sidebarObjectGroupPageSize();
|
||
const page = await loadPagedSimpleTableChildren({
|
||
nodeId,
|
||
connectionId,
|
||
database,
|
||
querySchema,
|
||
effectiveSchema,
|
||
nonTableObjectTypes,
|
||
offset: 0,
|
||
pageSize,
|
||
searchFilter: searchFilter || undefined,
|
||
force: options?.force,
|
||
});
|
||
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, pageSize), page.loadMoreParent) : page.children;
|
||
nextObjectCount = page.objectCount;
|
||
} else {
|
||
children = buildObjectGroupPlaceholderNodes({
|
||
nodeId,
|
||
connectionId,
|
||
database,
|
||
schema: effectiveSchema,
|
||
objectTypes: supportedSidebarObjectTypes(config),
|
||
});
|
||
if (!schema && isPostgresLikeForExtensions(config?.db_type)) {
|
||
children.push(buildExtensionManagementNode(connectionId, database));
|
||
}
|
||
}
|
||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||
if (!tableNameFilterRevisionMatches(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
if (nextObjectCount !== undefined) targetNode.objectCount = nextObjectCount;
|
||
setChildren(targetNode, children);
|
||
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
|
||
await savePersistedTreeChildren(cacheKey, children);
|
||
}
|
||
const currentTargetNode = treeNodeLoadTarget(load);
|
||
if (!currentTargetNode) return;
|
||
currentTargetNode.isExpanded = true;
|
||
if (simpleObjectDisplay && !searchFilter && !isSidebarTableSearch && nonTableObjectTypes.length > 0) {
|
||
void loadSimpleSupplementalObjectChildren({
|
||
node: currentTargetNode,
|
||
nodeId,
|
||
connectionId,
|
||
database,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes: nonTableObjectTypes,
|
||
cacheKey,
|
||
loadOptions: options,
|
||
load,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
options,
|
||
);
|
||
}
|
||
|
||
async function loadObjectGroupChildren(node: TreeNode, options?: LoadTreeOptions) {
|
||
const configForScope = node.connectionId ? getConfig(node.connectionId) : undefined;
|
||
const objectTypesForScope = objectTypesForGroupNode(node.type);
|
||
const pageSizeForScope = sidebarObjectGroupPageSize();
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
const querySchemaForScope = connectionObjectTreeQuerySchema(configForScope, node.database || "", node.schema);
|
||
const effectiveSchemaForScope = connectionObjectTreeNodeSchema(configForScope, node.database || "", node.schema);
|
||
const tableNameFilterForScope = activeTableNameFilterForScope({
|
||
connectionId: node.connectionId,
|
||
database: node.database,
|
||
schema: effectiveSchemaForScope ?? querySchemaForScope,
|
||
nodeKind: node.type,
|
||
catalog: node.catalog,
|
||
});
|
||
if (!options?.force && !searchFilter && !options?.sidebarTableSearchParentId && !tableNameFilterForScope) {
|
||
if (await hydrateTreeNodeFromCache(node, objectGroupCacheKey(node))) {
|
||
void loadObjectGroupChildren(node, { ...options, force: true }).catch(() => undefined);
|
||
return;
|
||
}
|
||
}
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "object-group",
|
||
connectionId: node.connectionId,
|
||
database: node.database,
|
||
schema: node.schema,
|
||
nodeKind: node.type,
|
||
objectTypes: objectTypesForScope,
|
||
searchFilter: activeTreeLoadSearchFilter(options),
|
||
limit: pageSizeForScope + 1,
|
||
offset: 0,
|
||
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
extra: options?.sidebarTableSearchParentId ? { sidebarTableSearchParentId: options.sidebarTableSearchParentId } : undefined,
|
||
},
|
||
async () => {
|
||
if (!node.connectionId || !hasTreeNodeDatabaseContext(node)) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(node.connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, options, load)) return;
|
||
const objectTypes = objectTypesForGroupNode(node.type);
|
||
const parentNodeId = objectGroupRefreshParentId(node);
|
||
if (!objectTypes || !parentNodeId) return;
|
||
|
||
const config = getConfig(node.connectionId);
|
||
const querySchema = connectionObjectTreeQuerySchema(config, node.database, node.schema);
|
||
const effectiveSchema = connectionObjectTreeNodeSchema(config, node.database, node.schema);
|
||
const cacheKey = objectGroupCacheKey(node);
|
||
const searchFilter = activeTreeLoadSearchFilter(options);
|
||
const tableNameFilter = activeTableNameFilterForScope({
|
||
connectionId: node.connectionId,
|
||
database: node.database,
|
||
schema: node.schema,
|
||
nodeKind: node.type,
|
||
catalog: node.catalog,
|
||
});
|
||
const isSidebarTableSearch = !!options?.sidebarTableSearchParentId;
|
||
if (!options?.force && !searchFilter && !tableNameFilter) {
|
||
const cached = await loadPersistedTreeChildren(node, cacheKey, load);
|
||
if (cached.hit) {
|
||
if (cached.isStale) refreshStaleTreeNode(node);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW");
|
||
let children: TreeNode[];
|
||
let nextObjectCount: number;
|
||
if (wantsOnlyTablesOrViews) {
|
||
const page = await loadPagedTableGroupChildren({
|
||
node,
|
||
parentNodeId,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes,
|
||
offset: 0,
|
||
pageSize: sidebarObjectGroupPageSize(),
|
||
searchFilter: searchFilter || undefined,
|
||
force: options?.force,
|
||
});
|
||
children = page.hasMore && !searchFilter ? appendTableTreeLoadMoreNode(page.children, buildLoadMoreNode(node, page.nextOffset, sidebarObjectGroupPageSize()), page.loadMoreParent) : page.children;
|
||
nextObjectCount = page.objectCount;
|
||
} else {
|
||
const pageSize = sidebarObjectGroupPageSize();
|
||
const page = await loadPagedObjectGroupChildren({
|
||
node,
|
||
parentNodeId,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes,
|
||
offset: 0,
|
||
pageSize,
|
||
searchFilter: searchFilter || undefined,
|
||
force: options?.force,
|
||
});
|
||
children = page.hasMore && !searchFilter ? [...page.children, buildLoadMoreNode(node, page.nextOffset, pageSize)] : page.children;
|
||
nextObjectCount = page.objectCount;
|
||
}
|
||
if (isTreeLoadSearchChanged(searchFilter, options)) return;
|
||
if (!tableNameFilterRevisionMatches(options)) return;
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
targetNode.objectCount = nextObjectCount;
|
||
setChildren(targetNode, children);
|
||
if (!searchFilter && !isSidebarTableSearch && !tableNameFilter) {
|
||
await savePersistedTreeChildren(cacheKey, children);
|
||
}
|
||
const currentTargetNode = treeNodeLoadTarget(load);
|
||
if (currentTargetNode) currentTargetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(node.connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
options,
|
||
);
|
||
}
|
||
|
||
async function loadMoreObjectGroupChildren(node: TreeNode) {
|
||
if (node.type !== "load-more" || !node.loadMore) return;
|
||
const loadMore = node.loadMore;
|
||
const parent = findNode(treeNodes.value, node.loadMore.parentId);
|
||
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
|
||
const parentConnectionId = parent.connectionId;
|
||
const configForScope = getConfig(parentConnectionId);
|
||
const objectTypesForScope = objectTypesForGroupNode(parent.type);
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "object-group-page",
|
||
connectionId: parentConnectionId,
|
||
database: parent.database,
|
||
schema: parent.schema,
|
||
nodeKind: parent.type,
|
||
objectTypes: objectTypesForScope,
|
||
limit: loadMore.pageSize + 1,
|
||
offset: loadMore.offset,
|
||
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
|
||
driverProfile: metadataDriverProfile(configForScope),
|
||
},
|
||
async () => {
|
||
let load = beginTreeNodeLoad(node);
|
||
// Parent writes must honor the parent's generation too — load-more begins on the
|
||
// placeholder node, so a replaced/invalidated parent must reject the merge.
|
||
const parentEpoch = treeNodeLoads.observe(parent.id);
|
||
try {
|
||
await ensureConnected(parentConnectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
|
||
const parentDatabase = parent.database;
|
||
if (!parentDatabase) return;
|
||
const config = getConfig(parentConnectionId);
|
||
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
|
||
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
|
||
const page = await loadPagedSimpleTableChildren({
|
||
nodeId: parent.schema ? `${parentConnectionId}:${parentDatabase}:${parent.schema}` : `${parentConnectionId}:${parentDatabase}`,
|
||
connectionId: parentConnectionId,
|
||
database: parentDatabase,
|
||
querySchema,
|
||
effectiveSchema,
|
||
nonTableObjectTypes: [],
|
||
offset: loadMore.offset,
|
||
pageSize: loadMore.pageSize,
|
||
force: false,
|
||
});
|
||
const targetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (!targetParent || !parentEpoch.isCurrent()) return;
|
||
const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children);
|
||
const mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
|
||
const nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
|
||
targetParent.objectCount = mergedChildren.length;
|
||
setChildren(targetParent, nextChildren);
|
||
await savePersistedTreeChildren(schemaCacheKey(parentConnectionId, parentDatabase, parent.schema || "", ownerAwareMetadataCacheVersion(config, "objects-simple-v6")), nextChildren);
|
||
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
|
||
return;
|
||
}
|
||
const objectTypes = objectTypesForGroupNode(parent.type);
|
||
const parentNodeId = objectGroupRefreshParentId(parent);
|
||
if (!objectTypes || !parentNodeId) return;
|
||
|
||
const config = getConfig(parentConnectionId);
|
||
const parentDatabase = parent.database;
|
||
if (!parentDatabase) return;
|
||
const querySchema = connectionObjectTreeQuerySchema(config, parentDatabase, parent.schema);
|
||
const effectiveSchema = connectionObjectTreeNodeSchema(config, parentDatabase, parent.schema);
|
||
const wantsOnlyTablesOrViews = objectTypes.every((objectType) => objectType === "TABLE" || objectType === "VIEW" || objectType === "MATERIALIZED_VIEW");
|
||
let mergedChildren: TreeNode[];
|
||
let nextChildren: TreeNode[];
|
||
if (wantsOnlyTablesOrViews) {
|
||
const page = await loadPagedTableGroupChildren({
|
||
node: parent,
|
||
parentNodeId,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes,
|
||
offset: loadMore.offset,
|
||
pageSize: loadMore.pageSize,
|
||
force: false,
|
||
});
|
||
const targetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (!targetParent || !parentEpoch.isCurrent()) return;
|
||
const currentChildren = withoutTableTreeLoadMoreNodes(targetParent.children);
|
||
mergedChildren = mergeTableTreePageChildren(currentChildren, page.children, parentConnectionId, parentDatabase);
|
||
nextChildren = page.hasMore ? appendTableTreeLoadMoreNode(mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize), page.loadMoreParent) : mergedChildren;
|
||
} else {
|
||
const page = await loadPagedObjectGroupChildren({
|
||
node: parent,
|
||
parentNodeId,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes,
|
||
offset: loadMore.offset,
|
||
pageSize: loadMore.pageSize,
|
||
force: false,
|
||
});
|
||
const targetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (!targetParent || !parentEpoch.isCurrent()) return;
|
||
const currentChildren = withoutLoadMoreNodes(targetParent.children);
|
||
mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, parentConnectionId, parentDatabase);
|
||
nextChildren = page.hasMore ? [...mergedChildren, buildLoadMoreNode(targetParent, page.nextOffset, loadMore.pageSize)] : mergedChildren;
|
||
targetParent.objectCount = mergedChildren.length;
|
||
setChildren(targetParent, nextChildren);
|
||
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
|
||
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
|
||
return;
|
||
}
|
||
const targetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (!targetParent || !parentEpoch.isCurrent()) return;
|
||
targetParent.objectCount = mergedChildren.length;
|
||
setChildren(targetParent, nextChildren);
|
||
await savePersistedTreeChildren(objectGroupCacheKey(targetParent), nextChildren);
|
||
const currentTargetParent = treeNodeLoadRelatedTarget(load, parent);
|
||
if (currentTargetParent && parentEpoch.isCurrent()) currentTargetParent.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(parentConnectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
async function loadExtensions(connectionId: string, database: string) {
|
||
const node = findNode(treeNodes.value, `${connectionId}:${database}:__extensions`);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
try {
|
||
await ensureConnected(connectionId);
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (useCachedChildren(node, undefined, load)) return;
|
||
const extensions = await withMetadataLoadTimeout(connectionId, api.listExtensions(connectionId, database), "extensions");
|
||
const children: TreeNode[] = extensions.map((ext) => ({
|
||
id: `${node.id}:${ext.schema || ""}:${ext.name}`,
|
||
label: ext.name,
|
||
type: "extension" as const,
|
||
connectionId,
|
||
database,
|
||
schema: ext.schema ?? undefined,
|
||
comment: ext.comment ?? null,
|
||
meta: ext,
|
||
isExpanded: false,
|
||
}));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
targetNode.objectCount = children.length;
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadTableForLocate(target: LocateTableTarget): Promise<boolean> {
|
||
const config = getConfig(target.connectionId);
|
||
if (!config) return false;
|
||
return runTreeMetadataLoad(
|
||
{
|
||
kind: "locate-target",
|
||
connectionId: target.connectionId,
|
||
database: target.database,
|
||
schema: target.schema,
|
||
tableName: target.tableName,
|
||
searchFilter: target.tableName,
|
||
limit: sidebarObjectGroupPageSize() + 1,
|
||
offset: 0,
|
||
sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay,
|
||
driverProfile: metadataDriverProfile(config),
|
||
},
|
||
async () => {
|
||
await ensureConnected(target.connectionId);
|
||
|
||
const querySchema = connectionObjectTreeQuerySchema(config, target.database, target.schema);
|
||
const effectiveSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema);
|
||
const pageSize = sidebarObjectGroupPageSize();
|
||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
let loaded = false;
|
||
|
||
if (simpleObjectDisplay) {
|
||
const parentId = target.schema ? `${target.connectionId}:${target.database}:${target.schema}` : `${target.connectionId}:${target.database}`;
|
||
const parent = findNode(treeNodes.value, parentId);
|
||
if (!parent) return false;
|
||
let load = beginTreeNodeLoad(parent);
|
||
try {
|
||
load = reclaimTreeNodeLoad(load, parent);
|
||
const page = await loadPagedSimpleTableChildren({
|
||
nodeId: parentId,
|
||
connectionId: target.connectionId,
|
||
database: target.database,
|
||
querySchema,
|
||
effectiveSchema,
|
||
nonTableObjectTypes: [],
|
||
offset: 0,
|
||
pageSize,
|
||
searchFilter: target.tableName,
|
||
force: false,
|
||
});
|
||
if (!page.children.length) return false;
|
||
const targetParent = treeNodeLoadTarget(load);
|
||
if (!targetParent) return false;
|
||
const currentChildren = withoutLoadMoreNodes(targetParent.children);
|
||
const loadMoreNodes = (targetParent.children || []).filter((child) => child.type === "load-more");
|
||
const mergedChildren = mergeLocatedTreeChildren(targetParent, currentChildren, page.children, target.connectionId, target.database);
|
||
setChildren(targetParent, [...mergedChildren, ...loadMoreNodes]);
|
||
targetParent.objectCount = Math.max(targetParent.objectCount ?? currentChildren.length, mergedChildren.length);
|
||
targetParent.isExpanded = true;
|
||
return true;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
const matchingGroups = findTreeNodes(treeNodes.value, (node) => {
|
||
return (node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views") && node.connectionId === target.connectionId && sameSidebarObjectName(node.database, target.database) && (!target.schema || sameSidebarObjectName(node.schema, target.schema));
|
||
});
|
||
|
||
for (const group of matchingGroups) {
|
||
const objectTypes = objectTypesForGroupNode(group.type);
|
||
const parentNodeId = objectGroupRefreshParentId(group);
|
||
if (!objectTypes || !parentNodeId) continue;
|
||
|
||
let load = beginTreeNodeLoad(group);
|
||
try {
|
||
load = reclaimTreeNodeLoad(load, group);
|
||
const page = await loadPagedTableGroupChildren({
|
||
node: group,
|
||
parentNodeId,
|
||
querySchema,
|
||
effectiveSchema,
|
||
objectTypes,
|
||
offset: 0,
|
||
pageSize,
|
||
searchFilter: target.tableName,
|
||
force: false,
|
||
});
|
||
if (!page.children.length) continue;
|
||
|
||
const targetGroup = treeNodeLoadTarget(load);
|
||
if (!targetGroup) continue;
|
||
const currentChildren = withoutLoadMoreNodes(targetGroup.children);
|
||
const loadMoreNodes = (targetGroup.children || []).filter((child) => child.type === "load-more");
|
||
const mergedChildren = mergeLocatedTreeChildren(targetGroup, currentChildren, page.children, target.connectionId, target.database);
|
||
setChildren(targetGroup, [...mergedChildren, ...loadMoreNodes]);
|
||
targetGroup.objectCount = Math.max(targetGroup.objectCount ?? currentChildren.length, mergedChildren.length);
|
||
targetGroup.isExpanded = true;
|
||
loaded = true;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
return loaded;
|
||
},
|
||
);
|
||
}
|
||
|
||
async function loadAllObjectGroupChildren(parent: TreeNode) {
|
||
if (!parent.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
|
||
if (!objectTypesForGroupNode(parent.type)) return;
|
||
let load = beginTreeNodeLoad(parent);
|
||
try {
|
||
await ensureConnected(parent.connectionId);
|
||
load = reclaimTreeNodeLoad(load, parent);
|
||
const liveParent = treeNodeLoadTarget(load);
|
||
if (!liveParent) return;
|
||
if (!isTreeNodeChildrenLoaded(liveParent.id)) {
|
||
await loadObjectGroupChildren(liveParent);
|
||
}
|
||
if (!load.isCurrent()) return;
|
||
|
||
let loadMoreNode = findTreeNodes(liveParent.children ?? [], (child) => child.type === "load-more")[0];
|
||
while (loadMoreNode?.loadMore) {
|
||
await loadMoreObjectGroupChildren(loadMoreNode);
|
||
if (!load.isCurrent()) return;
|
||
const currentParent = treeNodeLoadTarget(load);
|
||
if (!currentParent) return;
|
||
loadMoreNode = findTreeNodes(currentParent.children ?? [], (child) => child.type === "load-more")[0];
|
||
}
|
||
const finishedParent = treeNodeLoadTarget(load);
|
||
if (finishedParent) finishedParent.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(parent.connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
function setSidebarTableSearchQuery(parentNodeId: string, query: string) {
|
||
const normalized = query.trim();
|
||
const next = { ...sidebarTableSearchQueries.value };
|
||
if (normalized) {
|
||
next[parentNodeId] = query;
|
||
} else {
|
||
delete next[parentNodeId];
|
||
}
|
||
sidebarTableSearchQueries.value = next;
|
||
}
|
||
|
||
async function refreshSidebarTableSearch(parentNodeId: string) {
|
||
const parent = findNode(treeNodes.value, parentNodeId);
|
||
if (!parent?.connectionId || !hasTreeNodeDatabaseContext(parent)) return;
|
||
|
||
const searchFilter = sidebarTableSearchQueries.value[parentNodeId]?.trim() || "";
|
||
const options: LoadTreeOptions = {
|
||
force: true,
|
||
searchFilter: searchFilter || undefined,
|
||
sidebarTableSearchParentId: parentNodeId,
|
||
expectedSidebarTableSearchQuery: searchFilter,
|
||
};
|
||
|
||
if (parent.type === "group-tables") {
|
||
await loadObjectGroupChildren(parent, options);
|
||
return;
|
||
}
|
||
|
||
if (parent.type === "database" || parent.type === "schema" || parent.type === "linked-server-schema") {
|
||
await loadTables(parent.connectionId, parent.database, parent.schema, options);
|
||
}
|
||
}
|
||
|
||
function normalizedObjectTreeKind(type: string): DatabaseObjectTreeKind {
|
||
return normalizeSidebarObjectKind(type);
|
||
}
|
||
|
||
async function loadTableGroups(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}` : `${connectionId}:${database}:${table}`);
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
let load = beginTreeNodeLoad(node);
|
||
|
||
try {
|
||
const children: TreeNode[] = [
|
||
...tablePartitionGroups(node),
|
||
{
|
||
id: `${parentId}:__columns`,
|
||
label: "tree.columns",
|
||
type: "group-columns",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
];
|
||
|
||
const config = getConfig(connectionId);
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||
const metadataCapabilities = getTableMetadataCapabilities(effectiveDbType);
|
||
const isXugu = effectiveDbType === "xugu";
|
||
const supportsXuguChildMetadata = isXugu && node.type === "table" && (await supportsXuguTableChildMetadata());
|
||
load = reclaimTreeNodeLoad(load, node);
|
||
if (supportsXuguChildMetadata) {
|
||
children.push({
|
||
id: `${parentId}:__constraints`,
|
||
label: "tree.constraints",
|
||
type: "group-constraints",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
if ((node.type === "table" || node.type === "mongo-collection") && !parseSqlServerLinkedSchema(schema)) {
|
||
if (metadataCapabilities.indexes && !isXugu) {
|
||
children.push({
|
||
id: `${parentId}:__indexes`,
|
||
label: "tree.indexes",
|
||
type: "group-indexes",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
}
|
||
if (node.type === "table" && !parseSqlServerLinkedSchema(schema)) {
|
||
if (metadataCapabilities.foreignKeys) {
|
||
children.push({
|
||
id: `${parentId}:__fkeys`,
|
||
label: "tree.foreignKeys",
|
||
type: "group-fkeys",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
if (metadataCapabilities.triggers) {
|
||
children.push({
|
||
id: `${parentId}:__triggers`,
|
||
label: "tree.triggers",
|
||
type: "group-triggers",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
if (isXugu) {
|
||
if (metadataCapabilities.indexes) {
|
||
children.push({
|
||
id: `${parentId}:__indexes`,
|
||
label: "tree.indexes",
|
||
type: "group-indexes",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
});
|
||
}
|
||
if (supportsXuguChildMetadata) {
|
||
children.push(
|
||
{
|
||
id: `${parentId}:__table-partitions`,
|
||
label: "tree.partitions",
|
||
type: "group-table-partitions",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
{
|
||
id: `${parentId}:__table-subpartitions`,
|
||
label: "tree.subpartitions",
|
||
type: "group-table-subpartitions",
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
catalog,
|
||
tableName: table,
|
||
isExpanded: false,
|
||
children: [],
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, children);
|
||
targetNode.isExpanded = true;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadColumns(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__columns` : `${connectionId}:${database}:${table}:__columns`);
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
if (effectiveDatabaseTypeForConnection(getConfig(connectionId)) === "mongodb") {
|
||
const fields = await listMongoCompletionFields(connectionId, database, table);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
fields.map((field) => {
|
||
const column = {
|
||
name: field.name,
|
||
data_type: field.type || "unknown",
|
||
is_nullable: true,
|
||
column_default: null,
|
||
is_primary_key: field.name === "_id",
|
||
extra: "sampled",
|
||
};
|
||
return {
|
||
id: `${parentId}:${field.name}`,
|
||
label: `${field.name} (${column.data_type})`,
|
||
type: "column" as const,
|
||
connectionId,
|
||
database,
|
||
tableName: table,
|
||
meta: column,
|
||
};
|
||
}),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
return;
|
||
}
|
||
const querySchema = metadataQuerySchema(connectionId, database, schema);
|
||
const columns = await api.getColumns(connectionId, database, querySchema, table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
columns.map((col) => ({
|
||
id: `${parentId}:${col.name}`,
|
||
label: `${col.name} (${col.data_type})`,
|
||
type: "column" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: col,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadIndexes(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__indexes` : `${connectionId}:${database}:${table}:__indexes`);
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
|
||
if (!metadataCapabilities.indexes) {
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, []);
|
||
targetNode.isExpanded = true;
|
||
return;
|
||
}
|
||
const querySchema = metadataQuerySchema(connectionId, database, schema);
|
||
const indexes = await api.listIndexes(connectionId, database, querySchema, table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
indexes.map((idx) => ({
|
||
id: `${parentId}:${idx.name}`,
|
||
label: `${idx.name} (${idx.columns.join(", ")})`,
|
||
type: "index" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: idx,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadForeignKeys(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__fkeys` : `${connectionId}:${database}:${table}:__fkeys`);
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
|
||
if (!metadataCapabilities.foreignKeys) {
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, []);
|
||
targetNode.isExpanded = true;
|
||
return;
|
||
}
|
||
const querySchema = metadataQuerySchema(connectionId, database, schema);
|
||
const fkeys = await api.listForeignKeys(connectionId, database, querySchema, table, catalog);
|
||
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`;
|
||
completionForeignKeysCache.value[cacheKey] = fkeys;
|
||
evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX);
|
||
indexCompletionForeignKeys(connectionId, database, table, schema, sqlCompletionForeignKeys(fkeys));
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
fkeys.map((fk) => ({
|
||
id: `${parentId}:${fk.name}`,
|
||
label: `${fk.column} → ${fk.ref_table}.${fk.ref_column}`,
|
||
type: "fkey" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: fk,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadTriggers(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? (schema ? `${connectionId}:${database}:${schema}:${table}:__triggers` : `${connectionId}:${database}:${table}:__triggers`);
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
|
||
if (!metadataCapabilities.triggers) {
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(targetNode, []);
|
||
targetNode.isExpanded = true;
|
||
return;
|
||
}
|
||
const querySchema = metadataQuerySchema(connectionId, database, schema);
|
||
const triggers = await api.listTriggers(connectionId, database, querySchema, table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
triggers.map((tr) => ({
|
||
id: `${parentId}:${tr.name}`,
|
||
label: `${tr.name} (${tr.timing} ${tr.event})`,
|
||
objectName: tr.name,
|
||
type: "trigger" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: tr,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadConstraints(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__constraints`;
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const constraints = await api.listConstraints(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
constraints.map((constraint) => ({
|
||
id: `${parentId}:${constraint.name}`,
|
||
label: `${constraint.name} (${constraint.constraint_type})${constraint.valid ? "" : " · INVALID"}`,
|
||
type: "constraint" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: constraint,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadPartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-partitions`;
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const partitions = await api.listPartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
partitions.map((partition) => ({
|
||
id: `${parentId}:${partition.name}`,
|
||
label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`,
|
||
type: "partition" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: partition,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
async function loadSubpartitions(connectionId: string, database: string, table: string, schema?: string, nodeId?: string, catalog?: string) {
|
||
const parentId = nodeId ?? `${connectionId}:${database}:${schema || ""}:${table}:__table-subpartitions`;
|
||
const node = findNode(treeNodes.value, parentId);
|
||
if (!node) return;
|
||
const load = beginTreeNodeLoad(node);
|
||
try {
|
||
const partitions = await api.listSubpartitions(connectionId, database, metadataQuerySchema(connectionId, database, schema), table, catalog);
|
||
const targetNode = treeNodeLoadTarget(load);
|
||
if (!targetNode) return;
|
||
setChildren(
|
||
targetNode,
|
||
partitions.map((partition) => ({
|
||
id: `${parentId}:${partition.name}`,
|
||
label: `${partition.name} (${partition.partition_type}${partition.value ? `: ${partition.value}` : ""})`,
|
||
type: "subpartition" as const,
|
||
connectionId,
|
||
database,
|
||
schema,
|
||
tableName: table,
|
||
meta: partition,
|
||
})),
|
||
);
|
||
targetNode.isExpanded = true;
|
||
} catch (e) {
|
||
recordMetadataLoadError(connectionId, e, load);
|
||
throw e;
|
||
} finally {
|
||
finishTreeNodeLoad(load);
|
||
}
|
||
}
|
||
|
||
function collectExpandedNodeIds(nodes: TreeNode[], ids = new Set<string>()): Set<string> {
|
||
for (const node of nodes) {
|
||
if (node.isExpanded) ids.add(node.id);
|
||
if (node.children) collectExpandedNodeIds(node.children, ids);
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
async function loadTreeNodeChildren(node: TreeNode, options?: LoadTreeOptions) {
|
||
if (node.type === "connection" && node.connectionId) {
|
||
const config = getConfig(node.connectionId);
|
||
if (config?.db_type === "redis") {
|
||
await loadRedisDatabases(node.connectionId);
|
||
} else if (config?.db_type === "etcd") {
|
||
await loadEtcdRoot(node.connectionId);
|
||
} else if (config?.db_type === "zookeeper") {
|
||
await loadZooKeeperRoot(node.connectionId);
|
||
} else if (config?.db_type === "mongodb") {
|
||
await loadMongoDatabases(node.connectionId);
|
||
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch") {
|
||
await loadElasticsearchIndices(node.connectionId);
|
||
} else if (config?.db_type === "milvus") {
|
||
await loadMilvusDatabases(node.connectionId);
|
||
} else if (config?.db_type === "qdrant" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
|
||
await loadVectorCollections(node.connectionId);
|
||
} else if (config?.db_type === "mq") {
|
||
await loadMqTenants(node.connectionId, options);
|
||
} else if (config?.db_type === "nacos") {
|
||
await loadNacosNamespaces(node.connectionId, options);
|
||
} else {
|
||
await loadDatabases(node.connectionId, options);
|
||
}
|
||
} else if (node.type === "mongo-db" && node.connectionId && node.database) {
|
||
await loadMongoCollections(node.connectionId, node.database);
|
||
} else if (node.type === "vector-database" && node.connectionId && node.database) {
|
||
await loadVectorCollections(node.connectionId, node.database);
|
||
} else if (node.type === "mongo-collection" && node.connectionId && node.database) {
|
||
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id);
|
||
} else if (node.type === "mongo-gridfs") {
|
||
node.isExpanded = true;
|
||
} else if (node.type === "doris-catalog" && node.connectionId) {
|
||
await loadDorisCatalogDatabases(node, options);
|
||
} else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||
if (node.catalog && node.catalog !== "internal") {
|
||
await loadDorisCatalogTables(node, options);
|
||
} else {
|
||
const config = getConfig(node.connectionId);
|
||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||
if (config?.db_type === "sqlserver") {
|
||
await loadSqlServerDatabaseObjects(node.connectionId, node.database, options);
|
||
} else if ((usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) || connectionShouldDiscoverJdbcSchemas(config)) {
|
||
await loadSchemas(node.connectionId, node.database, options);
|
||
} else {
|
||
await loadTables(node.connectionId, node.database, undefined, options);
|
||
}
|
||
}
|
||
} else if (node.type === "schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
|
||
await loadTables(node.connectionId, node.database, node.schema, options);
|
||
} else if (node.type === "linked-server-root" && node.connectionId) {
|
||
await loadSqlServerLinkedServers(node.connectionId, options);
|
||
} else if (node.type === "linked-server" && node.connectionId) {
|
||
await loadSqlServerLinkedServerCatalogs(node, options);
|
||
} else if (node.type === "linked-server-catalog" && node.connectionId) {
|
||
await loadSqlServerLinkedServerSchemas(node, options);
|
||
} else if (node.type === "linked-server-schema" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.schema) {
|
||
await loadTables(node.connectionId, node.database, node.schema, options);
|
||
} else if ((node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||
await loadTableGroups(node.connectionId, node.database, node.label, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-columns" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadColumns(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-indexes" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadIndexes(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-fkeys" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadForeignKeys(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-triggers" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadTriggers(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-constraints" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadConstraints(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-table-partitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadPartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (node.type === "group-table-subpartitions" && node.connectionId && hasTreeNodeDatabaseContext(node) && node.tableName) {
|
||
await loadSubpartitions(node.connectionId, node.database, node.tableName, node.schema, node.id, node.catalog);
|
||
} else if (objectTypesForGroupNode(node.type)) {
|
||
await loadObjectGroupChildren(node, options);
|
||
} else if (node.type === "group-partitions") {
|
||
node.isExpanded = true;
|
||
} else if (node.type === "group-extensions" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||
await loadExtensions(node.connectionId, node.database || "");
|
||
}
|
||
}
|
||
|
||
async function restoreExpandedChildren(node: TreeNode, expandedIds: Set<string>, options?: LoadTreeOptions, isCurrent: () => boolean = () => true) {
|
||
if (!isCurrent()) return;
|
||
if (!node.children) return;
|
||
for (const child of node.children) {
|
||
if (!isCurrent()) return;
|
||
if (!expandedIds.has(child.id)) continue;
|
||
await loadTreeNodeChildren(child, options);
|
||
if (!isCurrent()) return;
|
||
await restoreExpandedChildren(child, expandedIds, options, isCurrent);
|
||
}
|
||
}
|
||
|
||
async function refreshTreeNode(node: TreeNode) {
|
||
invalidateMetadataCachesForNode(node);
|
||
if (objectTypesForGroupNode(node.type)) {
|
||
clearLoadedChildrenCache(node.id, { deletePersisted: false });
|
||
await loadObjectGroupChildren(node, { force: true });
|
||
return;
|
||
}
|
||
|
||
const parentId = objectGroupRefreshParentId(node);
|
||
const parentNode = parentId ? findNode(treeNodes.value, parentId) : null;
|
||
if (parentNode) {
|
||
await refreshTreeNode(parentNode);
|
||
return;
|
||
}
|
||
|
||
if (node.connectionId && !connectedIds.value.has(node.connectionId)) return;
|
||
const expandedIds = collectExpandedNodeIds([node]);
|
||
expandedIds.add(node.id);
|
||
const previousChildren = node.children;
|
||
const previousHiddenChildren = node.hiddenChildren;
|
||
const previousObjectCount = node.objectCount;
|
||
const previousExpanded = node.isExpanded;
|
||
const previousLoadedIds = [...loadedTreeNodeChildrenIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
|
||
const previousConfirmedEmptyIds = [...confirmedEmptyTreeNodeIds.value].filter((id) => id === node.id || id.startsWith(`${node.id}:`));
|
||
const connectionRevision = node.connectionId ? connectionStateRevision(node.connectionId) : undefined;
|
||
const refreshGeneration = ++nextTreeRefreshGeneration;
|
||
activeTreeRefreshGenerations.set(node.id, refreshGeneration);
|
||
const ownsRefreshGeneration = () => activeTreeRefreshGenerations.get(node.id) === refreshGeneration;
|
||
const isCurrentRefresh = () => ownsRefreshGeneration() && (!node.connectionId || connectionStateRevision(node.connectionId) === connectionRevision);
|
||
try {
|
||
await clearPersistedTreeCacheForNode(node);
|
||
if (!isCurrentRefresh()) return;
|
||
clearLoadedChildrenCache(node.id);
|
||
if (node.type !== "connection-group") {
|
||
node.children = [];
|
||
}
|
||
await loadTreeNodeChildren(node, { force: true });
|
||
if (isCurrentRefresh()) {
|
||
await restoreExpandedChildren(node, expandedIds, { force: true }, isCurrentRefresh);
|
||
}
|
||
} catch (error) {
|
||
// A stale failure must never overwrite a newer successful (including empty) result.
|
||
if (isCurrentRefresh()) {
|
||
const target = treeNodeInSidebarTree(node);
|
||
if (target) {
|
||
target.children = previousChildren;
|
||
target.hiddenChildren = previousHiddenChildren;
|
||
target.objectCount = previousObjectCount;
|
||
target.isExpanded = previousExpanded;
|
||
clearLoadedChildrenCache(target.id, { deletePersisted: false });
|
||
for (const id of previousLoadedIds) loadedTreeNodeChildrenIds.value.add(id);
|
||
for (const id of previousConfirmedEmptyIds) confirmedEmptyTreeNodeIds.value.add(id);
|
||
}
|
||
}
|
||
throw error;
|
||
} finally {
|
||
if (ownsRefreshGeneration()) {
|
||
activeTreeRefreshGenerations.delete(node.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refreshTreeNodeForTableNameFilter(node: TreeNode, scopeKey: string, revision: number) {
|
||
invalidateMetadataCachesForNode(node);
|
||
if (objectTypesForGroupNode(node.type)) {
|
||
clearLoadedChildrenCache(node.id);
|
||
await loadObjectGroupChildren(node, {
|
||
force: true,
|
||
tableNameFilterScopeKey: scopeKey,
|
||
expectedTableNameFilterRevision: revision,
|
||
});
|
||
return;
|
||
}
|
||
await refreshTreeNode(node);
|
||
}
|
||
|
||
async function refreshDatabaseTreeNode(connectionId: string, database: string, catalog?: string) {
|
||
const node = findDatabaseTreeNode(treeNodes.value, connectionId, database, catalog);
|
||
if (node) {
|
||
await refreshTreeNode(node);
|
||
return;
|
||
}
|
||
if (catalog) {
|
||
const catalogNode = findNode(treeNodes.value, dorisCatalogId(connectionId, catalog));
|
||
if (catalogNode) {
|
||
await refreshTreeNode(catalogNode);
|
||
return;
|
||
}
|
||
}
|
||
await loadDatabases(connectionId, { force: true });
|
||
}
|
||
|
||
async function refreshObjectListTreeNode(connectionId: string, database: string, schema?: string, catalog?: string) {
|
||
const match = { connectionId, database, schema };
|
||
invalidateMetadataCaches(match);
|
||
void invalidateObjectDdlCache(match);
|
||
const shouldRefreshSchemaNode = !!schema && !catalog;
|
||
const node = shouldRefreshSchemaNode ? findNode(treeNodes.value, `${connectionId}:${database}:${schema}`) : null;
|
||
if (node) {
|
||
await refreshTreeNode(node);
|
||
return;
|
||
}
|
||
await refreshDatabaseTreeNode(connectionId, database, catalog);
|
||
}
|
||
|
||
function isSchemaAwareDatabase(connectionId: string): boolean {
|
||
return isSchemaAware(getConfig(connectionId)?.db_type);
|
||
}
|
||
|
||
function isPostgresLikeForExtensions(dbType?: string): boolean {
|
||
return dbType === "postgres" || dbType === "gaussdb" || dbType === "kwdb" || dbType === "opengauss" || dbType === "highgo" || dbType === "uxdb" || dbType === "vastbase" || dbType === "kingbase";
|
||
}
|
||
|
||
function metadataQuerySchema(connectionId: string, database: string, schema?: string): string {
|
||
return connectionObjectTreeQuerySchema(getConfig(connectionId), database, schema);
|
||
}
|
||
|
||
const COMPLETION_CACHE_MAX = 50;
|
||
|
||
function evictOldestCacheEntries(cache: Record<string, unknown>, max: number) {
|
||
const keys = Object.keys(cache);
|
||
if (keys.length <= max) return;
|
||
const toRemove = keys.slice(0, keys.length - max);
|
||
for (const key of toRemove) {
|
||
delete cache[key];
|
||
}
|
||
}
|
||
|
||
function completionScopeKey(connectionId: string, database: string, schema?: string): string {
|
||
return `${connectionId}:${database}:${schema?.toLowerCase() ?? ""}`;
|
||
}
|
||
|
||
function completionTableScopeKey(connectionId: string, database: string, schema?: string, catalog?: string): string {
|
||
return `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:${schema?.toLowerCase() ?? ""}`;
|
||
}
|
||
|
||
function completionColumnsKey(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): string {
|
||
if (getConfig(connectionId)?.db_type === "oracle") {
|
||
const normalizedTable = context?.tableQuoted === false ? table.toUpperCase() : table;
|
||
const normalizedSchema = schema && context?.schemaQuoted === false ? schema.toUpperCase() : (schema ?? "");
|
||
return `${connectionId}:${database}:${catalog ?? ""}:${normalizedSchema}:${normalizedTable}`;
|
||
}
|
||
return `${completionTableScopeKey(connectionId, database, schema, catalog)}:${table.toLowerCase()}`;
|
||
}
|
||
|
||
function completionForeignKeysKey(connectionId: string, database: string, table: string, schema?: string): string {
|
||
return `${completionScopeKey(connectionId, database, schema)}:${table.toLowerCase()}:fkeys`;
|
||
}
|
||
|
||
function completionTableCacheKeyMatches(key: string, connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): boolean {
|
||
const normalizedKey = key.toLowerCase();
|
||
const prefix = `${connectionId}:${database}:`.toLowerCase();
|
||
if (!normalizedKey.startsWith(prefix)) return false;
|
||
const tableToken = `:${tableName.toLowerCase()}`;
|
||
const tableOffset = normalizedKey.lastIndexOf(tableToken);
|
||
if (tableOffset < prefix.length) return false;
|
||
const trailing = normalizedKey.slice(tableOffset + tableToken.length);
|
||
if (trailing && !trailing.startsWith(":")) return false;
|
||
const normalizedSchema = schema?.trim().toLowerCase();
|
||
const normalizedCatalog = catalog?.trim().toLowerCase();
|
||
const scope = normalizedKey.slice(prefix.length, tableOffset);
|
||
if (normalizedCatalog) {
|
||
const catalogScope = `${normalizedCatalog}:${normalizedSchema ?? ""}`;
|
||
return scope === catalogScope || (!!normalizedSchema && scope === normalizedSchema);
|
||
}
|
||
if (!normalizedSchema) return true;
|
||
return scope === normalizedSchema || scope.endsWith(`:${normalizedSchema}`);
|
||
}
|
||
|
||
function invalidateCompletionTableCache(connectionId: string, database: string, tableName: string, schema?: string, catalog?: string): number {
|
||
const matches = (key: string) => completionTableCacheKeyMatches(key, connectionId, database, tableName, schema, catalog);
|
||
let removed = 0;
|
||
for (const cache of [completionColumnsCache.value, completionForeignKeysCache.value]) {
|
||
for (const key of Object.keys(cache)) {
|
||
if (!matches(key)) continue;
|
||
delete cache[key];
|
||
removed++;
|
||
}
|
||
}
|
||
for (const cache of [completionColumnIndex, completionForeignKeyIndex, completionInFlight]) {
|
||
for (const key of cache.keys()) {
|
||
if (!matches(key)) continue;
|
||
cache.delete(key);
|
||
removed++;
|
||
}
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
function touchCompletionIndex<T>(index: Map<string, { touched: number } & T>, key: string, value: T, max = COMPLETION_CACHE_MAX) {
|
||
index.set(key, { ...value, touched: Date.now() });
|
||
if (index.size <= max) return;
|
||
const oldest = [...index.entries()].sort(([, a], [, b]) => a.touched - b.touched).slice(0, index.size - max);
|
||
for (const [oldKey] of oldest) index.delete(oldKey);
|
||
}
|
||
|
||
function completionLimiterScope(connectionId: string, database = ""): string {
|
||
return `${connectionId}:${database}`;
|
||
}
|
||
|
||
function withCompletionInFlight<T>(key: string, load: () => Promise<T>, limit?: { scope: string; kind: string }): Promise<T> {
|
||
const existing = completionInFlight.get(key) as Promise<T> | undefined;
|
||
if (existing) return existing;
|
||
const promise = (limit ? completionMetadataLimiter.run(limit.scope, limit.kind, load) : load()).finally(() => {
|
||
if (completionInFlight.get(key) === promise) completionInFlight.delete(key);
|
||
});
|
||
completionInFlight.set(key, promise);
|
||
return promise;
|
||
}
|
||
|
||
function completionAssistantRequestKey(request: CompletionAssistantRequest): string {
|
||
return JSON.stringify({
|
||
connection_id: request.connection_id,
|
||
database: request.database,
|
||
schema: request.schema ?? "",
|
||
object_kinds: [...(request.object_kinds ?? [])].sort(),
|
||
mask: request.mask ?? "",
|
||
case_sensitive: !!request.case_sensitive,
|
||
global_search: !!request.global_search,
|
||
max_results: request.max_results ?? null,
|
||
search_in_comments: !!request.search_in_comments,
|
||
search_in_definitions: !!request.search_in_definitions,
|
||
parent_schema: request.parent_schema ?? "",
|
||
parent_name: request.parent_name ?? "",
|
||
match_mode: request.match_mode ?? "prefix",
|
||
});
|
||
}
|
||
|
||
async function completionAssistantSearch(request: CompletionAssistantRequest) {
|
||
return withCompletionInFlight(`assistant:${completionAssistantRequestKey(request)}`, async () => {
|
||
await ensureConnected(request.connection_id);
|
||
return api.completionAssistantSearch(request);
|
||
});
|
||
}
|
||
|
||
const ORACLE_SYSTEM_COMPLETION_SCHEMAS = new Set(["SYS", "SYSTEM", "SYSMAN", "DBSNMP", "OUTLN", "XDB", "MDSYS", "CTXSYS", "WMSYS"]);
|
||
const FILTERED_ROUTINE_COMPLETION_DATABASES = new Set<DatabaseType>(["mysql", "postgres", "sqlserver", "oracle"]);
|
||
|
||
function completionPreferredSchema(connectionId: string, preferredSchema?: string): string | undefined {
|
||
return preferredSchema?.trim() || getConfig(connectionId)?.username?.trim() || undefined;
|
||
}
|
||
|
||
function completionCandidateSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number {
|
||
if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 2400;
|
||
if (schema?.toUpperCase() === "PUBLIC") return 1200;
|
||
if (schema && ORACLE_SYSTEM_COMPLETION_SCHEMAS.has(schema.toUpperCase())) return -1200;
|
||
return 0;
|
||
}
|
||
|
||
function completionCandidateApplyName(name: string, schema: string | null | undefined, preferredSchema?: string): string {
|
||
if (!schema || schema.toUpperCase() === "PUBLIC" || (preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase())) return name;
|
||
return `${schema}.${name}`;
|
||
}
|
||
|
||
function completionAssistantTables(candidates: CompletionAssistantCandidate[], preferredSchema?: string, withOracleMetadata = false): SqlCompletionTable[] {
|
||
return candidates
|
||
.filter((candidate) => candidate.kind === "table" || candidate.kind === "view")
|
||
.map((candidate) => {
|
||
const table: SqlCompletionTable = {
|
||
name: candidate.name,
|
||
schema: candidate.schema ?? undefined,
|
||
type: sqlObjectNavigationTypeFromTableType(candidate.data_type || candidate.kind),
|
||
};
|
||
if (!withOracleMetadata) return table;
|
||
return {
|
||
...table,
|
||
detail: candidate.schema ? `${candidate.schema} · ${(candidate.data_type || candidate.kind).toLowerCase()}` : candidate.kind,
|
||
applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema),
|
||
boost: completionCandidateSchemaBoost(candidate.schema, preferredSchema),
|
||
};
|
||
});
|
||
}
|
||
|
||
function completionAssistantObjects(candidates: CompletionAssistantCandidate[], preferredSchema?: string, oracleMetadata = false): SqlCompletionObject[] {
|
||
return candidates
|
||
.map((candidate): SqlCompletionObject | null => {
|
||
const candidateType = candidate.data_type?.toUpperCase();
|
||
const type = candidate.kind === "procedure" ? "procedure" : candidate.kind === "function" ? "function" : candidate.kind === "object" && candidateType === "PACKAGE" ? "package" : null;
|
||
if (!type) return null;
|
||
const dataType = candidate.data_type && !["FUNCTION", "PROCEDURE", "PACKAGE"].includes(candidateType ?? "") ? candidate.data_type : undefined;
|
||
return {
|
||
name: candidate.name,
|
||
schema: candidate.schema ?? undefined,
|
||
type,
|
||
parentSchema: candidate.parent_schema ?? undefined,
|
||
parentName: candidate.parent_name ?? undefined,
|
||
dataType,
|
||
signature: candidate.signature ?? undefined,
|
||
comment: candidate.comment ?? null,
|
||
applyName: completionCandidateApplyName(candidate.name, candidate.schema, preferredSchema),
|
||
boost: oracleMetadata ? completionCandidateSchemaBoost(candidate.schema, preferredSchema) : completionRoutineSchemaBoost(candidate.schema, preferredSchema),
|
||
};
|
||
})
|
||
.filter((object): object is SqlCompletionObject => object != null);
|
||
}
|
||
|
||
function completionRoutineSchemaBoost(schema: string | null | undefined, preferredSchema?: string): number {
|
||
if (schema && preferredSchema && schema.toLowerCase() === preferredSchema.toLowerCase()) return 1000;
|
||
if (schema?.toUpperCase() === "PUBLIC") return 600;
|
||
return 0;
|
||
}
|
||
|
||
function completionAssistantIdentifierMatches(candidate: string, requested: string, quoted?: boolean): boolean {
|
||
return quoted ? candidate === requested : candidate.toLowerCase() === requested.toLowerCase();
|
||
}
|
||
|
||
function completionAssistantColumns(candidates: CompletionAssistantCandidate[], table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] {
|
||
const requestedSchema = schema?.trim();
|
||
return candidates
|
||
.filter((candidate) => {
|
||
if (candidate.kind !== "column") return false;
|
||
const parentName = candidate.parent_name?.trim();
|
||
if (parentName && !completionAssistantIdentifierMatches(parentName, table, context?.tableQuoted)) return false;
|
||
const parentSchema = candidate.parent_schema?.trim() || candidate.schema?.trim();
|
||
if (requestedSchema && parentSchema && !completionAssistantIdentifierMatches(parentSchema, requestedSchema, context?.schemaQuoted)) return false;
|
||
return true;
|
||
})
|
||
.map((candidate) => ({
|
||
name: candidate.name,
|
||
table: candidate.parent_name ?? table,
|
||
schema: candidate.parent_schema ?? candidate.schema ?? schema,
|
||
dataType: candidate.data_type ?? undefined,
|
||
comment: candidate.comment ?? null,
|
||
}));
|
||
}
|
||
|
||
async function listCompletionAssistantTables(connectionId: string, database: string, filter: string, limit?: number, schema?: string, globalSearch = false, currentSchema?: string): Promise<SqlCompletionTable[]> {
|
||
const oracleAssistant = getConfig(connectionId)?.db_type === "oracle";
|
||
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, globalSearch ? currentSchema : (schema ?? currentSchema)) : schema?.trim() || undefined;
|
||
const objectKinds: CompletionAssistantObjectKind[] = ["table", "view"];
|
||
const response = await completionAssistantSearch({
|
||
connection_id: connectionId,
|
||
database,
|
||
schema: preferredSchema ?? null,
|
||
object_kinds: objectKinds,
|
||
mask: filter.trim(),
|
||
max_results: limit ?? 200,
|
||
global_search: globalSearch,
|
||
parent_schema: globalSearch ? null : (schema ?? null),
|
||
match_mode: "prefix",
|
||
});
|
||
const tables = completionAssistantTables(response.candidates, preferredSchema, oracleAssistant);
|
||
indexCompletionTables(connectionId, database, schema, tables);
|
||
return tables;
|
||
}
|
||
|
||
async function listCompletionAssistantObjects(
|
||
connectionId: string,
|
||
database: string,
|
||
filter: string,
|
||
limit: number | undefined,
|
||
schema: string | undefined,
|
||
parentName: string | undefined,
|
||
globalSearch: boolean,
|
||
currentSchema: string | undefined,
|
||
objectKinds: CompletionAssistantObjectKind[],
|
||
): Promise<SqlCompletionObject[]> {
|
||
const databaseType = getConfig(connectionId)?.db_type;
|
||
const oracleAssistant = databaseType === "oracle";
|
||
const requestedSchema = schema?.trim() || currentSchema?.trim() || undefined;
|
||
const preferredSchema = oracleAssistant ? completionPreferredSchema(connectionId, currentSchema) : requestedSchema || (databaseType === "postgres" ? "public" : databaseType === "mysql" ? database : undefined);
|
||
const response = await completionAssistantSearch({
|
||
connection_id: connectionId,
|
||
database,
|
||
schema: oracleAssistant ? (preferredSchema ?? null) : (requestedSchema ?? null),
|
||
object_kinds: objectKinds,
|
||
mask: filter.trim(),
|
||
max_results: limit ?? 200,
|
||
global_search: globalSearch,
|
||
parent_schema: globalSearch ? null : (schema ?? null),
|
||
parent_name: parentName ?? null,
|
||
match_mode: "prefix",
|
||
});
|
||
const objects = completionAssistantObjects(response.candidates, preferredSchema, oracleAssistant).map((object) => ({
|
||
...object,
|
||
applyName: databaseType === "sqlserver" && object.schema ? `${object.schema}.${object.name}` : object.applyName,
|
||
}));
|
||
indexCompletionObjects(connectionId, database, schema, objects);
|
||
return objects;
|
||
}
|
||
|
||
async function listCompletionAssistantColumns(connectionId: string, database: string, table: string, schema?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): Promise<SqlCompletionColumn[]> {
|
||
const response = await completionAssistantSearch({
|
||
connection_id: connectionId,
|
||
database,
|
||
schema: schema ?? null,
|
||
object_kinds: ["column"],
|
||
mask: "",
|
||
max_results: 500,
|
||
parent_schema: schema ?? null,
|
||
parent_name: table,
|
||
match_mode: "prefix",
|
||
});
|
||
const columns = completionAssistantColumns(response.candidates, table, schema, context);
|
||
if (columns.length > 0) indexCompletionColumns(connectionId, database, table, schema, columns);
|
||
return columns;
|
||
}
|
||
|
||
function completionNameSegments(name: string): string[] {
|
||
return name
|
||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||
.split(/[\s_.:-]+/)
|
||
.map((segment) => segment.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function completionNameAcronym(name: string): string {
|
||
return completionNameSegments(name)
|
||
.map((segment) => segment[0])
|
||
.join("");
|
||
}
|
||
|
||
function orderedSubsequenceScore(text: string, filter: string): number {
|
||
let index = 0;
|
||
let gaps = 0;
|
||
for (const ch of filter) {
|
||
const found = text.indexOf(ch, index);
|
||
if (found < 0) return -1;
|
||
gaps += found - index;
|
||
index = found + 1;
|
||
}
|
||
return 1_000 - gaps - text.length;
|
||
}
|
||
|
||
function tableMatchScore(table: SqlCompletionTable, filter: string, preferredSchema?: string): number {
|
||
const text = table.name.toLowerCase();
|
||
const schema = table.schema?.toLowerCase();
|
||
const normalized = filter.trim().toLowerCase();
|
||
let score = schema && preferredSchema && schema === preferredSchema.toLowerCase() ? 10_000 : 0;
|
||
if (!normalized) return score;
|
||
if (text === normalized) return score + 9_000 - text.length;
|
||
if (text.startsWith(normalized)) return score + 7_500 - text.length;
|
||
const segments = completionNameSegments(table.name);
|
||
if (segments.some((segment) => segment.startsWith(normalized))) return score + 7_200 - text.length;
|
||
const acronym = completionNameAcronym(table.name);
|
||
if (acronym === normalized) return score + 7_100 - text.length;
|
||
if (acronym.startsWith(normalized)) return score + 6_900 - text.length;
|
||
if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length;
|
||
if (text.includes(normalized)) return score + 4_000 - text.length;
|
||
const subsequenceScore = orderedSubsequenceScore(text, normalized);
|
||
return subsequenceScore < 0 ? -1 : score + subsequenceScore;
|
||
}
|
||
|
||
function objectMatchScore(object: SqlCompletionObject, filter: string, preferredSchema?: string): number {
|
||
const tableLike: SqlCompletionTable = { name: object.name, schema: object.schema };
|
||
return tableMatchScore(tableLike, filter, preferredSchema);
|
||
}
|
||
|
||
function indexCompletionTables(connectionId: string, database: string, schema: string | undefined, tables: SqlCompletionTable[], catalog?: string) {
|
||
const groups = new Map<string, SqlCompletionTable[]>();
|
||
for (const table of tables) {
|
||
const tableSchema = table.schema ?? schema;
|
||
const tableCatalog = table.catalog ?? catalog;
|
||
const key = completionTableScopeKey(connectionId, database, tableSchema, tableCatalog);
|
||
const list = groups.get(key) ?? [];
|
||
list.push({ ...table, catalog: tableCatalog, schema: tableSchema });
|
||
groups.set(key, list);
|
||
}
|
||
for (const [key, group] of groups) {
|
||
const previous = completionTableIndex.get(key)?.tables ?? [];
|
||
touchCompletionIndex(completionTableIndex, key, {
|
||
tables: dedupeCompletionTables([...previous, ...group]),
|
||
});
|
||
}
|
||
}
|
||
|
||
function indexCompletionObjects(connectionId: string, database: string, schema: string | undefined, objects: SqlCompletionObject[]) {
|
||
const groups = new Map<string, SqlCompletionObject[]>();
|
||
for (const object of objects) {
|
||
const objectSchema = object.schema ?? schema;
|
||
const key = completionScopeKey(connectionId, database, objectSchema);
|
||
const list = groups.get(key) ?? [];
|
||
list.push({ ...object, schema: objectSchema });
|
||
groups.set(key, list);
|
||
}
|
||
for (const [key, group] of groups) {
|
||
const previous = completionObjectIndex.get(key)?.objects ?? [];
|
||
touchCompletionIndex(completionObjectIndex, key, {
|
||
objects: dedupeCompletionObjects([...previous, ...group]),
|
||
});
|
||
}
|
||
}
|
||
|
||
function indexCompletionColumns(connectionId: string, database: string, table: string, schema: string | undefined, columns: SqlCompletionColumn[], catalog?: string) {
|
||
touchCompletionIndex(completionColumnIndex, completionColumnsKey(connectionId, database, table, schema, catalog), {
|
||
columns,
|
||
});
|
||
}
|
||
|
||
function sqlCompletionForeignKeys(foreignKeys: ForeignKeyInfo[]): SqlCompletionForeignKey[] {
|
||
return foreignKeys.map((foreignKey) => ({
|
||
name: foreignKey.name,
|
||
column: foreignKey.column,
|
||
ref_schema: foreignKey.ref_schema,
|
||
ref_table: foreignKey.ref_table,
|
||
ref_column: foreignKey.ref_column,
|
||
}));
|
||
}
|
||
|
||
function indexCompletionForeignKeys(connectionId: string, database: string, table: string, schema: string | undefined, foreignKeys: SqlCompletionForeignKey[]) {
|
||
touchCompletionIndex(completionForeignKeyIndex, completionForeignKeysKey(connectionId, database, table, schema), {
|
||
foreignKeys,
|
||
});
|
||
}
|
||
|
||
function lookupLocalCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, catalog?: string): SqlCompletionTable[] {
|
||
const scopePrefix = `${connectionId}:${database}:${catalog?.toLowerCase() ?? ""}:`;
|
||
const allScopes = [...completionTableIndex.entries()].filter(([key]) => key.startsWith(scopePrefix)).map(([, entry]) => entry);
|
||
const preferred = schema ? completionTableIndex.get(completionTableScopeKey(connectionId, database, schema, catalog)) : undefined;
|
||
const scopes = schema ? (preferred ? [preferred] : []) : allScopes;
|
||
const treeTables = completionTablesFromTree(treeNodes.value, connectionId, database, schema, catalog);
|
||
const ranked = scopes
|
||
.flatMap((entry) => entry?.tables ?? [])
|
||
.concat(treeTables)
|
||
.map((table) => ({ table, score: tableMatchScore(table, filter, schema) }))
|
||
.filter((entry) => entry.score >= 0)
|
||
.sort((a, b) => b.score - a.score || a.table.name.localeCompare(b.table.name));
|
||
return dedupeCompletionTables(ranked.map((entry) => entry.table)).slice(0, limit ?? 200);
|
||
}
|
||
|
||
function lookupLocalCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string): SqlCompletionObject[] {
|
||
const allScopes = [...completionObjectIndex.entries()].filter(([key]) => key.startsWith(`${connectionId}:${database}:`)).map(([, entry]) => entry);
|
||
const preferred = schema ? completionObjectIndex.get(completionScopeKey(connectionId, database, schema)) : undefined;
|
||
const scopes = schema ? (preferred ? [preferred] : []) : allScopes;
|
||
const ranked = scopes
|
||
.flatMap((entry) => entry?.objects ?? [])
|
||
.map((object) => ({ object, score: objectMatchScore(object, filter, schema) }))
|
||
.filter((entry) => entry.score >= 0)
|
||
.sort((a, b) => b.score - a.score || a.object.name.localeCompare(b.object.name));
|
||
return dedupeCompletionObjects(ranked.map((entry) => entry.object)).slice(0, limit ?? 200);
|
||
}
|
||
|
||
function lookupLocalCompletionSchemas(connectionId: string, database: string, filter = "", limit = 50): string[] {
|
||
const schemas = dedupeCompletionQualifierNames([...(schemaListCache.value[`${connectionId}:${database}`] ?? []), ...completionSchemasFromTree(treeNodes.value, connectionId, database)]);
|
||
const normalized = filter.trim().toLowerCase();
|
||
return schemas
|
||
.filter((schema) => fuzzyTextMatch(schema, normalized))
|
||
.sort((a, b) => tableMatchScore({ name: b }, normalized) - tableMatchScore({ name: a }, normalized))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function lookupLocalCompletionDatabases(connectionId: string, filter = "", limit = 50): string[] {
|
||
const databases = completionDatabasesCache.value[connectionId] ?? databaseNamesFromTree(connectionId);
|
||
const normalized = filter.trim().toLowerCase();
|
||
return databases
|
||
.filter((database) => fuzzyTextMatch(database, normalized))
|
||
.sort((a, b) => tableMatchScore({ name: b }, normalized) - tableMatchScore({ name: a }, normalized))
|
||
.slice(0, limit);
|
||
}
|
||
|
||
function dedupeCompletionQualifierNames(names: string[]): string[] {
|
||
const seen = new Set<string>();
|
||
const result: string[] = [];
|
||
for (const name of names) {
|
||
const normalized = name.trim();
|
||
if (!normalized) continue;
|
||
const key = normalized.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(normalized);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function lookupLocalCompletionColumns(connectionId: string, database: string, table: string, schema?: string, catalog?: string, context?: { tableQuoted?: boolean; schemaQuoted?: boolean }): SqlCompletionColumn[] {
|
||
return completionColumnIndex.get(completionColumnsKey(connectionId, database, table, schema, catalog, context))?.columns ?? [];
|
||
}
|
||
|
||
function lookupLocalCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): SqlCompletionForeignKey[] {
|
||
return completionForeignKeyIndex.get(completionForeignKeysKey(connectionId, database, table, schema))?.foreignKeys ?? [];
|
||
}
|
||
|
||
function databaseNamesFromTree(connectionId: string): string[] {
|
||
const node = findConnectionNode(connectionId);
|
||
if (!node?.children) return [];
|
||
const seen = new Set<string>();
|
||
const names: string[] = [];
|
||
for (const child of node.children) {
|
||
if (child.type !== "database" || !child.database) continue;
|
||
const key = child.database.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
names.push(child.database);
|
||
}
|
||
return names;
|
||
}
|
||
|
||
async function listCompletionDatabases(connectionId: string): Promise<string[]> {
|
||
if (completionDatabasesCache.value[connectionId]) {
|
||
return completionDatabasesCache.value[connectionId];
|
||
}
|
||
return withCompletionInFlight(
|
||
`${connectionId}:completion-databases`,
|
||
async () => {
|
||
await ensureConnected(connectionId);
|
||
const config = getConfig(connectionId);
|
||
const databases = await api.listDatabases(connectionId);
|
||
completionDatabasesCache.value[connectionId] = filterDatabaseNamesForConnection(
|
||
databases.map((database) => database.name),
|
||
config,
|
||
);
|
||
evictOldestCacheEntries(completionDatabasesCache.value, COMPLETION_CACHE_MAX);
|
||
return completionDatabasesCache.value[connectionId];
|
||
},
|
||
{ scope: completionLimiterScope(connectionId), kind: "databases" },
|
||
);
|
||
}
|
||
|
||
async function getSqlServerCompletionContext(connectionId: string, database: string): Promise<SqlServerCompletionContext> {
|
||
const cacheKey = `${connectionId}:${database}`;
|
||
if (sqlServerCompletionContextCache.value[cacheKey]) {
|
||
return sqlServerCompletionContextCache.value[cacheKey];
|
||
}
|
||
return withCompletionInFlight(`${cacheKey}:sqlserver-completion-context`, async () => {
|
||
await ensureConnected(connectionId);
|
||
const context = await api.getSqlServerCompletionContext(connectionId, database);
|
||
sqlServerCompletionContextCache.value[cacheKey] = context;
|
||
evictOldestCacheEntries(sqlServerCompletionContextCache.value, COMPLETION_CACHE_MAX);
|
||
return context;
|
||
});
|
||
}
|
||
|
||
async function listCompletionSchemas(connectionId: string, database: string): Promise<string[]> {
|
||
const cacheKey = `${connectionId}:${database}`;
|
||
if (schemaListCache.value[cacheKey]) {
|
||
return schemaListCache.value[cacheKey];
|
||
}
|
||
return withCompletionInFlight(`${cacheKey}:schemas`, async () => {
|
||
const schemas = await api.listSchemas(connectionId, database);
|
||
schemaListCache.value[cacheKey] = schemas;
|
||
evictOldestCacheEntries(schemaListCache.value, COMPLETION_CACHE_MAX);
|
||
return schemas;
|
||
});
|
||
}
|
||
|
||
async function listElasticsearchCompletionIndices(connectionId: string, database: string): Promise<string[]> {
|
||
const cacheKey = `${connectionId}:${database}`;
|
||
if (elasticsearchCompletionIndicesCache.value[cacheKey]) {
|
||
return elasticsearchCompletionIndicesCache.value[cacheKey];
|
||
}
|
||
await ensureConnected(connectionId);
|
||
const indices = await api.elasticsearchListIndices(connectionId);
|
||
elasticsearchCompletionIndicesCache.value[cacheKey] = indices;
|
||
evictOldestCacheEntries(elasticsearchCompletionIndicesCache.value, COMPLETION_CACHE_MAX);
|
||
return elasticsearchCompletionIndicesCache.value[cacheKey];
|
||
}
|
||
|
||
// Upper bound on cached key names per db, to keep completion memory bounded
|
||
// (Redis can hold far more keys than we ever want resident for autocomplete).
|
||
const REDIS_COMPLETION_KEYS_MAX = 1000;
|
||
|
||
async function listRedisCompletionKeys(connectionId: string, database: string): Promise<string[]> {
|
||
if (!database) return [];
|
||
const cacheKey = `${connectionId}:${database}`;
|
||
const cached = redisCompletionKeysCache.value[cacheKey];
|
||
if (cached) return cached;
|
||
return withCompletionInFlight(`${cacheKey}:redis-keys`, async () => {
|
||
await ensureConnected(connectionId);
|
||
const pageSize = getConfig(connectionId)?.redis_scan_page_size ?? REDIS_SCAN_PAGE_SIZE_DEFAULT;
|
||
// Bounded multi-round SCAN: trade coverage for latency/memory safety.
|
||
const result = await api.redisScanKeysBatch(connectionId, Number(database), 0, "*", pageSize, 6, false);
|
||
const keys = result.keys.map((key) => key.key_display).slice(0, REDIS_COMPLETION_KEYS_MAX);
|
||
redisCompletionKeysCache.value[cacheKey] = keys;
|
||
evictOldestCacheEntries(redisCompletionKeysCache.value, COMPLETION_CACHE_MAX);
|
||
return keys;
|
||
});
|
||
}
|
||
|
||
async function listMongoCompletionCollections(connectionId: string, database: string): Promise<string[]> {
|
||
if (!database) return [];
|
||
const cacheKey = `${connectionId}:${database}`;
|
||
const cached = mongoCompletionCollectionsCache.value[cacheKey];
|
||
if (cached) return cached;
|
||
return withCompletionInFlight(`${cacheKey}:mongo-collections`, async () => {
|
||
await ensureConnected(connectionId);
|
||
const collections = sortSidebarNames((await api.mongoListCollections(connectionId, database)).map((c) => c.name));
|
||
mongoCompletionCollectionsCache.value[cacheKey] = collections;
|
||
evictOldestCacheEntries(mongoCompletionCollectionsCache.value, COMPLETION_CACHE_MAX);
|
||
return collections;
|
||
});
|
||
}
|
||
|
||
async function listMongoCompletionFields(connectionId: string, database: string, collection: string): Promise<MongoCompletionField[]> {
|
||
if (!database || !collection) return [];
|
||
const cacheKey = `${connectionId}:${database}:${collection}`;
|
||
const cached = mongoCompletionFieldsCache.value[cacheKey];
|
||
if (cached) return cached;
|
||
return withCompletionInFlight(`${cacheKey}:mongo-fields`, async () => {
|
||
await ensureConnected(connectionId);
|
||
const result = await api.mongoFindDocuments(connectionId, database, collection, 0, 20, "{}");
|
||
const fields = inferMongoCompletionFields(result.documents ?? []);
|
||
mongoCompletionFieldsCache.value[cacheKey] = fields;
|
||
evictOldestCacheEntries(mongoCompletionFieldsCache.value, COMPLETION_CACHE_MAX);
|
||
return fields;
|
||
});
|
||
}
|
||
|
||
function listCompletionTableMetadata(connectionId: string, database: string, schema: string, filter?: string, limit?: number, catalog?: string): Promise<TableInfo[]> {
|
||
if (catalog) return api.listTables(connectionId, database, schema, filter, limit, undefined, undefined, catalog);
|
||
return api.listTables(connectionId, database, schema, filter, limit);
|
||
}
|
||
|
||
async function listCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string): Promise<SqlCompletionTable[]> {
|
||
const trimmedFilter = filter.trim();
|
||
const normalizedFilter = trimmedFilter.toLowerCase();
|
||
// Remote queries (Dameng/Oracle) are case-sensitive, so the cache key must
|
||
// preserve original casing — otherwise "TEST" and "test" collide and the
|
||
// second lookup returns the first's stale results. Local lookups below stay
|
||
// case-insensitive because tableMatchScore normalizes internally.
|
||
const relaxedFilter = relaxedCompletionTableFilter(trimmedFilter);
|
||
const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${trimmedFilter}:${limit ?? ""}:${schema ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}`;
|
||
if (completionTablesCache.value[cacheKey]) {
|
||
return completionTablesCache.value[cacheKey];
|
||
}
|
||
|
||
return withCompletionInFlight(
|
||
`${cacheKey}:tables`,
|
||
async () => {
|
||
await ensureConnected(connectionId);
|
||
|
||
if (isSchemaAwareDatabase(connectionId)) {
|
||
if (normalizedFilter || limit) {
|
||
let results: SqlCompletionTable[] = [];
|
||
try {
|
||
results = await listCompletionAssistantTables(connectionId, database, trimmedFilter, limit, schema, globalSearch, currentSchema);
|
||
} catch {
|
||
if (schema) {
|
||
const tables = await listCompletionTableMetadata(connectionId, database, schema, trimmedFilter, limit, catalog);
|
||
results = tables.map((table) => ({
|
||
name: table.name,
|
||
catalog,
|
||
schema,
|
||
type: sqlObjectNavigationTypeFromTableType(table.table_type),
|
||
}));
|
||
} else {
|
||
results = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog);
|
||
}
|
||
}
|
||
if (results.length === 0 && relaxedFilter) {
|
||
if (globalSearch) {
|
||
try {
|
||
results = await listCompletionAssistantTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), schema, true, currentSchema);
|
||
} catch {
|
||
results = [];
|
||
}
|
||
} else if (schema) {
|
||
try {
|
||
const tables = await listCompletionTableMetadata(connectionId, database, schema, relaxedFilter, expandedCompletionLimit(limit), catalog);
|
||
results = tables.map((table) => ({
|
||
name: table.name,
|
||
catalog,
|
||
schema,
|
||
type: sqlObjectNavigationTypeFromTableType(table.table_type),
|
||
}));
|
||
} catch {
|
||
results = [];
|
||
}
|
||
} else {
|
||
results = lookupLocalCompletionTables(connectionId, database, relaxedFilter, expandedCompletionLimit(limit), undefined, catalog);
|
||
}
|
||
}
|
||
const limitedTables = limit ? dedupeCompletionTables(results).slice(0, limit) : results;
|
||
completionTablesCache.value[cacheKey] = limitedTables;
|
||
indexCompletionTables(connectionId, database, undefined, limitedTables, catalog);
|
||
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
||
return completionTablesCache.value[cacheKey];
|
||
}
|
||
|
||
if (schema) {
|
||
const tables = await listCompletionTableMetadata(connectionId, database, schema, undefined, undefined, catalog);
|
||
completionTablesCache.value[cacheKey] = tables.map((table) => ({
|
||
name: table.name,
|
||
catalog,
|
||
schema,
|
||
type: sqlObjectNavigationTypeFromTableType(table.table_type),
|
||
}));
|
||
} else {
|
||
completionTablesCache.value[cacheKey] = lookupLocalCompletionTables(connectionId, database, normalizedFilter, limit, undefined, catalog);
|
||
}
|
||
indexCompletionTables(connectionId, database, undefined, completionTablesCache.value[cacheKey], catalog);
|
||
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
||
return completionTablesCache.value[cacheKey];
|
||
}
|
||
|
||
const querySchema = catalog ? "" : database;
|
||
let tables = await listCompletionTableMetadata(connectionId, database, querySchema, trimmedFilter, limit, catalog);
|
||
if (tables.length === 0 && relaxedFilter) {
|
||
tables = await listCompletionTableMetadata(connectionId, database, querySchema, relaxedFilter, expandedCompletionLimit(limit), catalog);
|
||
}
|
||
completionTablesCache.value[cacheKey] = tables.map((table) => ({
|
||
name: table.name,
|
||
catalog,
|
||
type: sqlObjectNavigationTypeFromTableType(table.table_type),
|
||
}));
|
||
completionTablesCache.value[cacheKey] = limit ? completionTablesCache.value[cacheKey].slice(0, limit) : completionTablesCache.value[cacheKey];
|
||
indexCompletionTables(connectionId, database, schema, completionTablesCache.value[cacheKey], catalog);
|
||
evictOldestCacheEntries(completionTablesCache.value, COMPLETION_CACHE_MAX);
|
||
return completionTablesCache.value[cacheKey];
|
||
},
|
||
{ scope: completionLimiterScope(connectionId, database), kind: "tables" },
|
||
);
|
||
}
|
||
|
||
function relaxedCompletionTableFilter(filter: string): string | undefined {
|
||
if (filter.length < 3) return undefined;
|
||
return filter.slice(0, 2);
|
||
}
|
||
|
||
function expandedCompletionLimit(limit?: number): number | undefined {
|
||
if (!limit) return limit;
|
||
return Math.min(Math.max(limit * 3, limit), 1000);
|
||
}
|
||
|
||
function dedupeCompletionTables(tables: SqlCompletionTable[]): SqlCompletionTable[] {
|
||
const indexByKey = new Map<string, number>();
|
||
const deduped: SqlCompletionTable[] = [];
|
||
for (const table of tables) {
|
||
const key = `${table.catalog ?? ""}.${table.schema ?? ""}.${table.name}`.toLowerCase();
|
||
const existingIndex = indexByKey.get(key);
|
||
if (existingIndex != null) {
|
||
const existing = deduped[existingIndex];
|
||
// Loaded tree metadata can distinguish materialized views even when an older completion endpoint only reports VIEW.
|
||
deduped[existingIndex] = { ...table, ...existing, type: mergeSqlObjectNavigationType(existing.type, table.type) };
|
||
continue;
|
||
}
|
||
indexByKey.set(key, deduped.length);
|
||
deduped.push(table);
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
async function listCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string, objectKinds: CompletionAssistantObjectKind[] = ["routine"]): Promise<SqlCompletionObject[]> {
|
||
const normalizedFilter = filter.trim().toLowerCase();
|
||
const databaseType = getConfig(connectionId)?.db_type;
|
||
const filteredRoutineAssistant = !!databaseType && FILTERED_ROUTINE_COMPLETION_DATABASES.has(databaseType) && (!!normalizedFilter || typeof limit === "number" || !!parentName || globalSearch);
|
||
const cacheKey = filteredRoutineAssistant ? `${connectionId}:${database}:${schema ?? ""}:${parentName ?? ""}:${normalizedFilter}:${limit ?? ""}:${globalSearch ? "global" : "scoped"}:${currentSchema ?? ""}:${[...objectKinds].sort().join(",")}` : `${connectionId}:${database}:${schema ?? ""}`;
|
||
if (!completionObjectsCache.value[cacheKey]) {
|
||
await withCompletionInFlight(
|
||
`${cacheKey}:objects`,
|
||
async () => {
|
||
await ensureConnected(connectionId);
|
||
if (filteredRoutineAssistant) {
|
||
try {
|
||
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(await listCompletionAssistantObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema, objectKinds));
|
||
} catch {
|
||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database);
|
||
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
|
||
}
|
||
} else {
|
||
const objects = isSchemaAwareDatabase(connectionId) ? await listSchemaAwareCompletionObjects(connectionId, database, schema) : await api.listCompletionObjects(connectionId, database, schema || database);
|
||
completionObjectsCache.value[cacheKey] = dedupeCompletionObjects(objects.map(toSqlCompletionObject).filter((object): object is SqlCompletionObject => object != null));
|
||
}
|
||
indexCompletionObjects(connectionId, database, schema, completionObjectsCache.value[cacheKey]);
|
||
evictOldestCacheEntries(completionObjectsCache.value, COMPLETION_CACHE_MAX);
|
||
},
|
||
{ scope: completionLimiterScope(connectionId, database), kind: "objects" },
|
||
);
|
||
}
|
||
|
||
const objects = completionObjectsCache.value[cacheKey];
|
||
const filtered = normalizedFilter ? objects.filter((object) => fuzzyCompletionObjectMatch(object, normalizedFilter)) : objects;
|
||
return typeof limit === "number" ? filtered.slice(0, limit) : filtered;
|
||
}
|
||
|
||
async function listSchemaAwareCompletionObjects(connectionId: string, database: string, schema?: string): Promise<ObjectInfo[]> {
|
||
const schemas = schema ? [schema] : await listCompletionSchemas(connectionId, database);
|
||
const batchSize = COMPLETION_METADATA_CONCURRENCY;
|
||
const results: ObjectInfo[] = [];
|
||
for (let i = 0; i < schemas.length; i += batchSize) {
|
||
const batch = schemas.slice(i, i + batchSize);
|
||
const groups = await Promise.all(
|
||
batch.map(async (s) => {
|
||
try {
|
||
return await api.listCompletionObjects(connectionId, database, s);
|
||
} catch {
|
||
return [] as ObjectInfo[];
|
||
}
|
||
}),
|
||
);
|
||
for (const group of groups) results.push(...group);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function toSqlCompletionObject(object: ObjectInfo): SqlCompletionObject | null {
|
||
const objectType = object.object_type.toUpperCase();
|
||
const type = objectType.includes("PROCEDURE") ? "procedure" : objectType.includes("FUNCTION") ? "function" : objectType.includes("TRIGGER") ? "trigger" : objectType.includes("PACKAGE") ? "package" : null;
|
||
if (!type) return null;
|
||
return {
|
||
name: object.name,
|
||
schema: object.schema ?? undefined,
|
||
type,
|
||
parentSchema: object.parent_schema ?? undefined,
|
||
parentName: object.parent_name ?? undefined,
|
||
signature: object.signature ?? undefined,
|
||
comment: object.comment ?? null,
|
||
};
|
||
}
|
||
|
||
function fuzzyCompletionObjectMatch(object: SqlCompletionObject, filter: string): boolean {
|
||
return fuzzyTextMatch(object.name, filter) || (!!object.schema && fuzzyTextMatch(object.schema, filter)) || (!!object.parentName && fuzzyTextMatch(object.parentName, filter)) || (!!object.parentSchema && fuzzyTextMatch(`${object.parentSchema}.${object.parentName ?? ""}`, filter));
|
||
}
|
||
|
||
function fuzzyTextMatch(value: string, filter: string): boolean {
|
||
if (!filter) return true;
|
||
const text = value.toLowerCase();
|
||
if (text.includes(filter)) return true;
|
||
let index = 0;
|
||
for (const ch of filter) {
|
||
index = text.indexOf(ch, index);
|
||
if (index < 0) return false;
|
||
index++;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function dedupeCompletionObjects(objects: SqlCompletionObject[]): SqlCompletionObject[] {
|
||
const seen = new Set<string>();
|
||
const deduped: SqlCompletionObject[] = [];
|
||
for (const object of objects) {
|
||
const key = `${object.type}:${object.schema ?? ""}:${object.name}:${object.parentName ?? ""}:${object.signature?.trim() ?? ""}`.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
deduped.push(object);
|
||
}
|
||
return deduped;
|
||
}
|
||
|
||
async function listCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||
const config = getConfig(connectionId);
|
||
const oracleIdentifier = config?.db_type === "oracle";
|
||
const uppercaseUnquotedIdentifier = oracleIdentifier || config?.db_type === "saphana";
|
||
const completionTable = uppercaseUnquotedIdentifier && context?.tableQuoted === false ? table.toUpperCase() : table;
|
||
const rawCompletionSchema = schema?.trim() || undefined;
|
||
const completionSchema = uppercaseUnquotedIdentifier && rawCompletionSchema && context?.schemaQuoted === false ? rawCompletionSchema.toUpperCase() : rawCompletionSchema;
|
||
const usesOracleCurrentSchema = config?.db_type === "oracle" && !completionSchema;
|
||
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(config) && !completionSchema && !usesOracleCurrentSchema) {
|
||
return [];
|
||
}
|
||
const sessionCacheScope = usesOracleCurrentSchema && context?.clientSessionId ? `:${context.clientSessionId}:${context.version ?? 0}` : "";
|
||
const cacheKey = `${connectionId}:${database}:${catalog ?? ""}:${completionSchema || ""}:${completionTable}${sessionCacheScope}`;
|
||
if (!completionColumnsCache.value[cacheKey]) {
|
||
await withCompletionInFlight(
|
||
`${cacheKey}:columns`,
|
||
async () => {
|
||
await ensureConnected(connectionId);
|
||
if (!usesOracleCurrentSchema && !catalog) {
|
||
try {
|
||
const assistantColumns = await listCompletionAssistantColumns(connectionId, database, completionTable, completionSchema, context);
|
||
if (assistantColumns.length > 0) {
|
||
completionColumnsCache.value[cacheKey] = assistantColumns.map((column) => ({
|
||
name: column.name,
|
||
data_type: column.dataType ?? "",
|
||
is_nullable: column.isNullable ?? true,
|
||
column_default: null,
|
||
is_primary_key: false,
|
||
extra: null,
|
||
comment: column.comment ?? null,
|
||
numeric_precision: null,
|
||
numeric_scale: null,
|
||
character_maximum_length: null,
|
||
}));
|
||
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
|
||
return;
|
||
}
|
||
} catch {
|
||
// Fall back to the existing metadata path below.
|
||
}
|
||
}
|
||
const querySchema = usesOracleCurrentSchema ? "" : metadataQuerySchema(connectionId, database, completionSchema);
|
||
completionColumnsCache.value[cacheKey] = await api.getColumns(connectionId, database, querySchema, completionTable, catalog, usesOracleCurrentSchema ? context?.clientSessionId : undefined);
|
||
evictOldestCacheEntries(completionColumnsCache.value, COMPLETION_CACHE_MAX);
|
||
},
|
||
{ scope: completionLimiterScope(connectionId, database), kind: "columns" },
|
||
);
|
||
}
|
||
|
||
const columns = completionColumnsCache.value[cacheKey].map((column) => ({
|
||
name: column.name,
|
||
table: completionTable,
|
||
schema: completionSchema,
|
||
dataType: column.data_type,
|
||
isNullable: column.is_nullable,
|
||
comment: column.comment,
|
||
}));
|
||
if (!usesOracleCurrentSchema) indexCompletionColumns(connectionId, database, completionTable, completionSchema, columns, catalog);
|
||
return columns;
|
||
}
|
||
|
||
async function listCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionForeignKey[]> {
|
||
if (isSchemaAwareDatabase(connectionId) && !connectionUsesDatabaseObjectTreeMode(getConfig(connectionId)) && !schema) {
|
||
return [];
|
||
}
|
||
const metadataCapabilities = getTableMetadataCapabilities(effectiveDatabaseTypeForConnection(getConfig(connectionId)));
|
||
if (!metadataCapabilities.foreignKeys) return [];
|
||
|
||
const cacheKey = `${connectionId}:${database}:${schema || ""}:${table}`;
|
||
if (!completionForeignKeysCache.value[cacheKey]) {
|
||
await withCompletionInFlight(
|
||
`${cacheKey}:fkeys`,
|
||
async () => {
|
||
await ensureConnected(connectionId);
|
||
const querySchema = metadataQuerySchema(connectionId, database, schema);
|
||
completionForeignKeysCache.value[cacheKey] = await api.listForeignKeys(connectionId, database, querySchema, table);
|
||
evictOldestCacheEntries(completionForeignKeysCache.value, COMPLETION_CACHE_MAX);
|
||
},
|
||
{ scope: completionLimiterScope(connectionId, database), kind: "foreignKeys" },
|
||
);
|
||
}
|
||
|
||
const foreignKeys = sqlCompletionForeignKeys(completionForeignKeysCache.value[cacheKey]);
|
||
indexCompletionForeignKeys(connectionId, database, table, schema, foreignKeys);
|
||
return foreignKeys;
|
||
}
|
||
|
||
function refreshCompletionTables(connectionId: string, database: string, filter = "", limit?: number, schema?: string, globalSearch = false, currentSchema?: string, catalog?: string): Promise<SqlCompletionTable[]> {
|
||
return listCompletionTables(connectionId, database, filter, limit, schema, globalSearch, currentSchema, catalog);
|
||
}
|
||
|
||
function refreshCompletionObjects(connectionId: string, database: string, filter = "", limit?: number, schema?: string, parentName?: string, globalSearch = false, currentSchema?: string): Promise<SqlCompletionObject[]> {
|
||
return listCompletionObjects(connectionId, database, filter, limit, schema, parentName, globalSearch, currentSchema);
|
||
}
|
||
|
||
function refreshCompletionSchemas(connectionId: string, database: string): Promise<string[]> {
|
||
return listCompletionSchemas(connectionId, database);
|
||
}
|
||
|
||
function refreshCompletionDatabases(connectionId: string): Promise<string[]> {
|
||
return listCompletionDatabases(connectionId);
|
||
}
|
||
|
||
function refreshCompletionColumns(connectionId: string, database: string, table: string, schema?: string, context?: { clientSessionId?: string; version?: number; tableQuoted?: boolean; schemaQuoted?: boolean }, catalog?: string): Promise<SqlCompletionColumn[]> {
|
||
return listCompletionColumns(connectionId, database, table, schema, context, catalog);
|
||
}
|
||
|
||
function refreshCompletionForeignKeys(connectionId: string, database: string, table: string, schema?: string): Promise<SqlCompletionForeignKey[]> {
|
||
return listCompletionForeignKeys(connectionId, database, table, schema);
|
||
}
|
||
|
||
function findNode(nodes: TreeNode[], id: string): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.id === id) return node;
|
||
if (node.children) {
|
||
const found = findNode(node.children, id);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 查连接根节点:沿 connection-group 层级下钻但不穿透连接的整棵子树
|
||
* (原通用 DFS 找第 N 个连接前要完整遍历前 N-1 个连接的数千个表/列节点)。
|
||
* 不能用"同层优先"版 findNode 代替通用 DFS——节点 id 并非全树唯一
|
||
* (如数据库 "a:b" 与数据库 "a" 下 schema "b" 同为 connectionId:a:b,
|
||
* 见 pinnedItems 对 colliding node IDs 的处理),改变遍历顺序会让深层
|
||
* 调用选中错误节点;连接根节点的 id 就是 connectionId,且只出现在
|
||
* 顶层或连接组内,无歧义。 */
|
||
function findConnectionNode(connectionId: string, nodes: TreeNode[] = treeNodes.value): TreeNode | null {
|
||
for (const node of nodes) {
|
||
if (node.id === connectionId && node.type !== "connection-group") return node;
|
||
if (node.type === "connection-group" && node.children) {
|
||
const found = findConnectionNode(connectionId, node.children);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function persistConnections(nextConnections: ConnectionConfig[] = connections.value) {
|
||
await api.saveConnections(nextConnections.filter((connection) => connection.one_time !== true));
|
||
}
|
||
|
||
function persistSidebarLayoutDebounced() {
|
||
if (layoutPersistTimer) clearTimeout(layoutPersistTimer);
|
||
layoutPersistTimer = setTimeout(() => {
|
||
api.saveSidebarLayout(sidebarLayout.value).catch(() => {});
|
||
layoutPersistTimer = null;
|
||
}, 300);
|
||
}
|
||
|
||
function rebuildTreeNodes() {
|
||
const existingNodesMap = new Map<string, TreeNode>();
|
||
const collectExisting = (nodes: TreeNode[]) => {
|
||
for (const node of nodes) {
|
||
existingNodesMap.set(node.id, node);
|
||
if (node.children) collectExisting(node.children);
|
||
}
|
||
};
|
||
collectExisting(treeNodes.value);
|
||
|
||
const freshNodes = buildTreeNodesFromLayout(sidebarLayout.value, connections.value, pinnedTreeNodeIds.value);
|
||
const mergeState = (nodes: TreeNode[]): TreeNode[] =>
|
||
nodes.map((node) => {
|
||
const existing = existingNodesMap.get(node.id);
|
||
if (node.type === "connection-group") {
|
||
return inheritNaturalTreeNodeOrder(node, { ...node, children: mergeState(node.children || []) });
|
||
}
|
||
if (existing && node.type === "connection") {
|
||
return inheritNaturalTreeNodeOrder(node, {
|
||
...existing,
|
||
label: node.label,
|
||
pinned: node.pinned,
|
||
children: withSavedSqlRoot(node.connectionId!, existing.children || [], existing),
|
||
});
|
||
}
|
||
if (node.type === "connection" && node.connectionId) {
|
||
return inheritNaturalTreeNodeOrder(node, { ...node, children: withSavedSqlRoot(node.connectionId, node.children || []) });
|
||
}
|
||
return node;
|
||
});
|
||
const mergedNodes = mergeState(freshNodes);
|
||
const migratedPins = migrateLegacyPinnedTreeNodeOrder(mergedNodes, pinnedTreeNodeOrder.value);
|
||
if (migratedPins.changed) {
|
||
setPinnedTreeNodeOrder(migratedPins.order);
|
||
persistPinnedTreeNodeIds();
|
||
}
|
||
syncPinnedTreeState(mergedNodes);
|
||
treeNodes.value = mergedNodes;
|
||
}
|
||
|
||
function updateLayoutAndRebuild(nextLayout: SidebarLayout) {
|
||
sidebarLayout.value = nextLayout;
|
||
rebuildTreeNodes();
|
||
persistSidebarLayoutDebounced();
|
||
}
|
||
|
||
function collapseAllTreeNodes() {
|
||
updateLayoutAndRebuild(collapseAllGroupsOp(sidebarLayout.value));
|
||
collapseExpandedTreeNodes(treeNodes.value);
|
||
}
|
||
|
||
async function refreshAllTree() {
|
||
const expandedIds = collectExpandedNodeIds(treeNodes.value);
|
||
const refreshExpandedNodes = async (nodes: TreeNode[]) => {
|
||
for (const node of nodes) {
|
||
if (node.type === "connection-group") {
|
||
if (node.children) await refreshExpandedNodes(node.children);
|
||
continue;
|
||
}
|
||
if (!expandedIds.has(node.id)) continue;
|
||
if (node.connectionId && !connectedIds.value.has(node.connectionId)) continue;
|
||
clearLoadedChildrenCache(node.id);
|
||
node.children = [];
|
||
await loadTreeNodeChildren(node, { force: true });
|
||
await restoreExpandedChildren(node, expandedIds, { force: true });
|
||
}
|
||
};
|
||
await refreshExpandedNodes(treeNodes.value);
|
||
}
|
||
|
||
async function refreshSidebarObjectPagination() {
|
||
const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple";
|
||
const isDirectObjectParent = (node: TreeNode) => {
|
||
if (!node.children || node.children.length === 0) return false;
|
||
return node.children.some(
|
||
(child) => child.type === "table" || child.type === "view" || child.type === "materialized_view" || child.type === "procedure" || child.type === "function" || child.type === "sequence" || child.type === "package" || child.type === "package-body" || child.type === "load-more",
|
||
);
|
||
};
|
||
const refreshNodes = async (nodes: TreeNode[]) => {
|
||
for (const node of nodes) {
|
||
if (node.type === "connection-group") {
|
||
if (node.children) await refreshNodes(node.children);
|
||
continue;
|
||
}
|
||
if (objectTypesForGroupNode(node.type)) {
|
||
if (node.connectionId && connectedIds.value.has(node.connectionId)) {
|
||
clearLoadedChildrenCache(node.id);
|
||
if (node.isExpanded) {
|
||
await loadObjectGroupChildren(node, { force: true });
|
||
} else if (node.children) {
|
||
node.children = [];
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (simpleObjectDisplay && (node.type === "database" || node.type === "schema" || node.type === "linked-server-schema")) {
|
||
if (isDirectObjectParent(node)) {
|
||
if (node.connectionId && connectedIds.value.has(node.connectionId)) {
|
||
clearLoadedChildrenCache(node.id);
|
||
if (node.isExpanded) {
|
||
await refreshTreeNode(node);
|
||
} else {
|
||
node.children = [];
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
if (node.children) await refreshNodes(node.children);
|
||
continue;
|
||
}
|
||
if (node.children) await refreshNodes(node.children);
|
||
}
|
||
};
|
||
await refreshNodes(treeNodes.value);
|
||
}
|
||
|
||
async function exportConnectionsToFile(passphrase: string) {
|
||
const { encryptConfig } = await import("@/lib/backend/configCrypto");
|
||
const tunnelProfileStore = useTunnelProfileStore();
|
||
await tunnelProfileStore.init();
|
||
const exportData = { connections: connections.value, layout: sidebarLayout.value, tunnelProfiles: tunnelProfileStore.profiles };
|
||
const json = JSON.stringify(exportData);
|
||
const payload = await encryptConfig(json, passphrase);
|
||
const content = JSON.stringify(payload, null, 2);
|
||
|
||
if (isTauriRuntime()) {
|
||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||
const path = await save({
|
||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||
defaultPath: "dbx-connections.json",
|
||
});
|
||
if (!path) return;
|
||
await writeTextFile(path, content);
|
||
} else {
|
||
const blob = new Blob([content], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = "dbx-connections.json";
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
function bytesToBase64(bytes: Uint8Array) {
|
||
let binary = "";
|
||
const chunkSize = 0x8000;
|
||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||
binary += String.fromCharCode(...bytes.slice(i, i + chunkSize));
|
||
}
|
||
return btoa(binary);
|
||
}
|
||
|
||
function siblingCredentialsPath(path: string) {
|
||
const fileName = path.split(/[\\/]/).pop() || "";
|
||
const credentialsFile = fileName.startsWith("data-sources-") ? fileName.replace(/^data-sources/, "credentials-config") : "credentials-config.json";
|
||
return path.replace(/[^\\/]+$/, credentialsFile);
|
||
}
|
||
|
||
async function readDbeaverImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
|
||
let dataSources: string;
|
||
let credentialsBase64 = "";
|
||
|
||
if (isTauriRuntime()) {
|
||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||
const { readTextFile, readFile } = await import("@tauri-apps/plugin-fs");
|
||
const path = await open({
|
||
filters: [{ name: "DBeaver Data Sources", extensions: ["json"] }],
|
||
multiple: false,
|
||
});
|
||
if (!path) return null;
|
||
const dataSourcesPath = path as string;
|
||
dataSources = await readTextFile(dataSourcesPath);
|
||
try {
|
||
credentialsBase64 = bytesToBase64(await readFile(siblingCredentialsPath(dataSourcesPath)));
|
||
} catch {
|
||
credentialsBase64 = "";
|
||
}
|
||
} else {
|
||
const files = await new Promise<FileList>((resolve, reject) => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = ".json";
|
||
input.multiple = true;
|
||
input.onchange = () => {
|
||
if (!input.files?.length) {
|
||
reject(new Error("No file selected"));
|
||
return;
|
||
}
|
||
resolve(input.files);
|
||
};
|
||
input.click();
|
||
});
|
||
const fileList = Array.from(files);
|
||
const dataSourcesFile = fileList.find((file) => /^data-sources.*\.json$/i.test(file.name)) || fileList.find((file) => !/^credentials-config.*\.json$/i.test(file.name));
|
||
const credentialsFile = fileList.find((file) => /^credentials-config.*\.json$/i.test(file.name));
|
||
if (!dataSourcesFile) throw new Error("Select DBeaver data-sources.json");
|
||
dataSources = await dataSourcesFile.text();
|
||
if (credentialsFile) {
|
||
credentialsBase64 = bytesToBase64(new Uint8Array(await credentialsFile.arrayBuffer()));
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: JSON.stringify({ format: "dbeaver-import", dataSources, credentialsBase64 }),
|
||
encrypted: false,
|
||
};
|
||
}
|
||
|
||
async function readDataGripImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
|
||
let dataSources: string;
|
||
let dataSourcesLocal = "";
|
||
let dbForestConfig = "";
|
||
|
||
if (isTauriRuntime()) {
|
||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||
const { matchDataGripImportFiles } = await import("@/lib/imports/datagripImport");
|
||
const paths = await open({
|
||
multiple: true,
|
||
filters: [{ name: "DataGrip configuration files", extensions: ["xml"] }],
|
||
title: i18n.global.t("configExport.importDatagripDialogTitle"),
|
||
});
|
||
if (!paths || paths.length === 0) return null;
|
||
// Tauri's fs scope authorizes only the exact paths picked in the dialog,
|
||
// so every file read below must be explicitly selected — sibling files in
|
||
// the same directory (e.g. dataSources.local.xml) are NOT readable.
|
||
let picked: { dataSources: string; local?: string; forest?: string };
|
||
try {
|
||
picked = matchDataGripImportFiles(Array.isArray(paths) ? paths : [paths]);
|
||
} catch (error) {
|
||
if ((error as Error & { code?: string })?.code === "DATAGRIP_IMPORT_MISSING_DATASOURCES") {
|
||
throw new Error(i18n.global.t("configExport.importDatagripSelectFiles"));
|
||
}
|
||
throw error;
|
||
}
|
||
dataSources = await readTextFile(picked.dataSources);
|
||
if (picked.local) {
|
||
dataSourcesLocal = await readTextFile(picked.local);
|
||
} else {
|
||
console.warn("[DataGrip Import] dataSources.local.xml not selected; usernames will fall back to defaults");
|
||
}
|
||
if (picked.forest) {
|
||
dbForestConfig = await readTextFile(picked.forest);
|
||
} else {
|
||
console.warn("[DataGrip Import] db-forest-config.xml not selected; legacy group tree skipped");
|
||
}
|
||
} else {
|
||
const files = await new Promise<FileList>((resolve, reject) => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = ".xml";
|
||
input.multiple = true;
|
||
input.onchange = () => {
|
||
if (!input.files?.length) {
|
||
reject(new Error("No file selected"));
|
||
return;
|
||
}
|
||
resolve(input.files);
|
||
};
|
||
input.click();
|
||
});
|
||
const fileList = Array.from(files);
|
||
const dsFile = fileList.find((f) => /^dataSources\.xml$/i.test(f.name));
|
||
const localFile = fileList.find((f) => /^dataSources\.local\.xml$/i.test(f.name));
|
||
const forestFile = fileList.find((f) => /^db-forest-config\.xml$/i.test(f.name));
|
||
if (!dsFile) throw new Error("Select dataSources.xml");
|
||
dataSources = await dsFile.text();
|
||
if (localFile) {
|
||
dataSourcesLocal = await localFile.text();
|
||
}
|
||
if (forestFile) {
|
||
dbForestConfig = await forestFile.text();
|
||
}
|
||
}
|
||
|
||
return {
|
||
content: JSON.stringify({ format: "datagrip-import", dataSources, dataSourcesLocal, dbForestConfig }),
|
||
encrypted: false,
|
||
};
|
||
}
|
||
|
||
async function readImportFile(source: ImportSource = "dbx"): Promise<{ content: string; encrypted: boolean } | null> {
|
||
if (source === "dbeaver") return readDbeaverImportFile();
|
||
if (source === "datagrip") return readDataGripImportFile();
|
||
|
||
let content: string;
|
||
|
||
if (isTauriRuntime()) {
|
||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||
const path = await open({
|
||
filters: source === "navicat" ? [{ name: "Navicat Connection Export", extensions: ["ncx", "xml"] }] : [{ name: "DBX JSON", extensions: ["json"] }],
|
||
multiple: false,
|
||
});
|
||
if (!path) return null;
|
||
content = await readTextFile(path as string);
|
||
} else {
|
||
content = await new Promise<string>((resolve, reject) => {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = source === "navicat" ? ".ncx,.xml" : ".json";
|
||
input.onchange = () => {
|
||
const file = input.files?.[0];
|
||
if (!file) {
|
||
reject(new Error("No file selected"));
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(reader.result as string);
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsText(file);
|
||
};
|
||
input.click();
|
||
});
|
||
}
|
||
|
||
if (content.trimStart().startsWith("<")) {
|
||
return { content, encrypted: false };
|
||
}
|
||
|
||
const { isEncryptedConfig } = await import("@/lib/backend/configCrypto");
|
||
const parsed = JSON.parse(content);
|
||
return { content, encrypted: isEncryptedConfig(parsed) };
|
||
}
|
||
|
||
async function importConnectionsFromFile(content: string, passphrase: string | null): Promise<{ count: number; layout?: SidebarLayout }> {
|
||
let imported: ConnectionConfig[] = [];
|
||
let importedLayout: SidebarLayout | undefined;
|
||
let importedTunnelProfiles: TunnelProfile[] = [];
|
||
|
||
if (!passphrase && content.trimStart().startsWith("<")) {
|
||
const { parseNavicatConnections } = await import("@/lib/imports/navicatImport");
|
||
imported = await parseNavicatConnections(content);
|
||
} else if (!passphrase) {
|
||
const { isDbeaverImportPayload, parseDbeaverImport } = await import("@/lib/imports/dbeaverImport");
|
||
const { isDataGripImportPayload, parseDataGripImport } = await import("@/lib/imports/datagripImport");
|
||
if (isDataGripImportPayload(content)) {
|
||
const payload = JSON.parse(content) as {
|
||
format: "datagrip-import";
|
||
dataSources: string;
|
||
dataSourcesLocal?: string;
|
||
dbForestConfig?: string;
|
||
};
|
||
pendingDataGripPayload = payload;
|
||
const result = parseDataGripImport(payload);
|
||
imported = result.connections;
|
||
importedLayout = result.layout;
|
||
} else if (isDbeaverImportPayload(content)) {
|
||
const result = await parseDbeaverImport(content);
|
||
imported = result.connections;
|
||
importedLayout = result.layout;
|
||
} else {
|
||
const parsed = JSON.parse(content);
|
||
|
||
if (Array.isArray(parsed)) {
|
||
imported = parsed;
|
||
} else if (parsed.format === "dbx-config" && Array.isArray(parsed.connections)) {
|
||
imported = parsed.connections;
|
||
} else if (parsed.connections && Array.isArray(parsed.connections)) {
|
||
imported = parsed.connections;
|
||
if (parsed.layout?.groups && parsed.layout?.order) {
|
||
importedLayout = parsed.layout;
|
||
}
|
||
if (Array.isArray(parsed.tunnelProfiles)) {
|
||
importedTunnelProfiles = parsed.tunnelProfiles;
|
||
}
|
||
} else {
|
||
imported = [];
|
||
}
|
||
}
|
||
} else {
|
||
const parsed = JSON.parse(content);
|
||
|
||
if (passphrase) {
|
||
const { decryptConfig } = await import("@/lib/backend/configCrypto");
|
||
const json = await decryptConfig(parsed, passphrase);
|
||
const decrypted = JSON.parse(json);
|
||
if (Array.isArray(decrypted)) {
|
||
imported = decrypted;
|
||
} else if (decrypted.connections) {
|
||
imported = decrypted.connections;
|
||
if (decrypted.layout?.groups && decrypted.layout?.order) {
|
||
importedLayout = decrypted.layout;
|
||
}
|
||
if (Array.isArray(decrypted.tunnelProfiles)) {
|
||
importedTunnelProfiles = decrypted.tunnelProfiles;
|
||
}
|
||
} else {
|
||
imported = [];
|
||
}
|
||
}
|
||
}
|
||
|
||
// Profiles keep their original ids: imported connections reference them
|
||
// via transport_layers[].profile_id, so regenerating ids would break the
|
||
// links. Same-id profiles are overwritten with the imported copy.
|
||
if (importedTunnelProfiles.length) {
|
||
const tunnelProfileStore = useTunnelProfileStore();
|
||
await tunnelProfileStore.init();
|
||
const merged = [...tunnelProfileStore.profiles];
|
||
for (const profile of importedTunnelProfiles) {
|
||
if (!profile || typeof profile.id !== "string" || !profile.id) continue;
|
||
const index = merged.findIndex((existing) => existing.id === profile.id);
|
||
if (index >= 0) merged[index] = profile;
|
||
else merged.push(profile);
|
||
}
|
||
await tunnelProfileStore.saveProfiles(merged);
|
||
}
|
||
|
||
let count = 0;
|
||
const importedConnectionIdMap = new Map<string, string>();
|
||
for (const config of imported) {
|
||
const duplicate = connections.value.find((c) => c.name === config.name && c.host === config.host && c.port === config.port);
|
||
if (!duplicate) {
|
||
const importedId = config.id;
|
||
config.id = uuid();
|
||
if (typeof importedId === "string") importedConnectionIdMap.set(importedId, config.id);
|
||
const normalized = normalizeConnection(config);
|
||
await addConnection(normalized);
|
||
count++;
|
||
} else if (typeof config.id === "string") {
|
||
importedConnectionIdMap.set(config.id, duplicate.id);
|
||
}
|
||
}
|
||
if (importedLayout) {
|
||
importedLayout = remapSidebarLayoutConnectionIds(importedLayout, importedConnectionIdMap);
|
||
}
|
||
return { count, layout: importedLayout };
|
||
}
|
||
|
||
/** Read macOS Keychain passwords for DataGrip connections and update them. */
|
||
async function applyDataGripKeychainPasswords(): Promise<number> {
|
||
const payload = pendingDataGripPayload;
|
||
pendingDataGripPayload = null;
|
||
if (!payload) return 0;
|
||
|
||
try {
|
||
const { getDataGripUuidMap, datagripKeychainService } = await import("@/lib/imports/datagripImport");
|
||
// dedupKey → DataGrip UUID
|
||
const uuidMap = getDataGripUuidMap(payload);
|
||
if (uuidMap.size === 0) return 0;
|
||
|
||
// Build service names for batch Keychain read
|
||
const dedupKeyToService = new Map<string, string>();
|
||
const services: string[] = [];
|
||
for (const [dedupKey, dgUuid] of uuidMap) {
|
||
const service = datagripKeychainService(dgUuid);
|
||
dedupKeyToService.set(dedupKey, service);
|
||
services.push(service);
|
||
}
|
||
|
||
// Call Tauri command to read Keychain
|
||
const results: [string, string][] = await api.readKeychainPasswords(services);
|
||
|
||
// Build service → password map
|
||
const passwordByService = new Map<string, string>();
|
||
for (const [service, password] of results) {
|
||
if (password) passwordByService.set(service, password);
|
||
}
|
||
|
||
// Update connections that have passwords (match by name/host/port)
|
||
let filled = 0;
|
||
const updated = connections.value.map((conn) => {
|
||
const dedupKey = [conn.name, conn.host, conn.port, conn.database || ""].join("\u0000");
|
||
const service = dedupKeyToService.get(dedupKey);
|
||
if (!service) return conn;
|
||
const password = passwordByService.get(service);
|
||
if (password) {
|
||
filled++;
|
||
return { ...conn, password };
|
||
}
|
||
return conn;
|
||
});
|
||
|
||
if (filled > 0) {
|
||
connections.value = updated;
|
||
await persistConnections();
|
||
}
|
||
return filled;
|
||
} catch (e) {
|
||
console.warn("[DataGrip Import] Keychain read failed:", e);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function applySidebarLayout(layout: SidebarLayout) {
|
||
const reconciledLayout = reconcileLayout(
|
||
connections.value.map((c) => c.id),
|
||
layout,
|
||
);
|
||
updateLayoutAndRebuild(reconciledLayout);
|
||
}
|
||
|
||
async function initFromDisk() {
|
||
if (!initFromDiskPromise) {
|
||
initFromDiskPromise = (async () => {
|
||
const [pinnedOrder, saved] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init()]);
|
||
setPinnedTreeNodeOrder(pinnedOrder);
|
||
connections.value = saved.map(normalizeConnection);
|
||
const savedLayout = await api.loadSidebarLayout();
|
||
const currentLayout = sidebarLayout.value.groups.length || sidebarLayout.value.order.length ? sidebarLayout.value : null;
|
||
sidebarLayout.value = reconcileLayout(
|
||
connections.value.map((c) => c.id),
|
||
savedLayout ?? currentLayout,
|
||
);
|
||
rebuildTreeNodes();
|
||
})().finally(() => {
|
||
initFromDiskPromise = null;
|
||
});
|
||
}
|
||
await initFromDiskPromise;
|
||
}
|
||
|
||
function addEphemeralConnection(config: ConnectionConfig) {
|
||
const normalized = normalizeConnection(config);
|
||
if (!connections.value.find((c) => c.id === normalized.id)) {
|
||
connections.value.push(normalized);
|
||
}
|
||
connectedIds.value.add(normalized.id);
|
||
markConnectionHealthChecked(normalized.id);
|
||
clearConnectionError(normalized.id);
|
||
}
|
||
|
||
function cancelTreeNodeLoad(nodeId: string): void {
|
||
// Supersede any in-flight loader for this node so a collapse issued while
|
||
// the load is still running (or a loader that never resolves) cannot
|
||
// reclaim ownership after connection recovery or re-expand the node.
|
||
treeNodeLoads.cancelPrefix(nodeId);
|
||
const node = findNode(treeNodes.value, nodeId);
|
||
if (node) node.isLoading = false;
|
||
}
|
||
|
||
return {
|
||
connections,
|
||
activeConnectionId,
|
||
selectedTreeNodeId,
|
||
selectedTreeNodeIds,
|
||
selectedTreeNodeIdsSet,
|
||
treeSelectionAnchorId,
|
||
connectionMultiSelectActive,
|
||
treeClipboard,
|
||
treeNodes,
|
||
removePinnedTreeNodes,
|
||
replacePinnedTreeNode,
|
||
removeTreeNode,
|
||
refreshAllTree,
|
||
collapseAllTreeNodes,
|
||
refreshSidebarObjectPagination,
|
||
refreshTreeNode,
|
||
refreshDatabaseTreeNode,
|
||
refreshObjectListTreeNode,
|
||
connectedIds,
|
||
connectingIds,
|
||
connectionErrors,
|
||
setConnectionError,
|
||
clearConnectionError,
|
||
recordConnectionError,
|
||
markConnectionLost,
|
||
recordConnectionLostError,
|
||
sidebarLayout,
|
||
connectionGroupPaths,
|
||
getConfig,
|
||
connectionIdentifierQuote,
|
||
isTreeNodePinned,
|
||
orderByPinnedTreeNodes,
|
||
toggleTreeNodePin,
|
||
beginPinnedTreeNodeReorder,
|
||
endPinnedTreeNodeReorder,
|
||
isPinnedTreeNodeReorderTarget,
|
||
canReorderPinnedTreeNodes,
|
||
reorderPinnedTreeNodes,
|
||
addConnection,
|
||
copyConnectionsToTreeClipboard,
|
||
pasteConnectionClipboard,
|
||
addEphemeralConnection,
|
||
updateConnection,
|
||
updateConnectionDatabaseInfo,
|
||
setDefaultDatabase,
|
||
clearDefaultDatabase,
|
||
isDefaultDatabase,
|
||
getRedisDatabaseAlias,
|
||
setRedisDatabaseAlias,
|
||
setVisibleDatabases,
|
||
clearVisibleDatabases,
|
||
ensureVisibleDatabase,
|
||
setVisibleSchemas,
|
||
clearVisibleSchemas,
|
||
removeConnection,
|
||
removeConnections,
|
||
editingConnectionId,
|
||
newConnectionGroupId,
|
||
startEditing,
|
||
stopEditing,
|
||
startCreatingConnectionInGroup,
|
||
stopCreatingConnectionInGroup,
|
||
connect,
|
||
cancelConnecting,
|
||
disconnect,
|
||
closeDatabaseConnection,
|
||
ensureConnected,
|
||
loadConnectedConnectionRootForSidebarSearch,
|
||
isTreeNodeChildrenLoaded,
|
||
canUseLoadedTreeNodeToggle,
|
||
releaseCollapsedTreeNodeChildren,
|
||
cancelTreeNodeLoad,
|
||
setBeforeConnectHandler,
|
||
initFromDisk,
|
||
loadDatabases,
|
||
loadSidebarDatabaseStorage,
|
||
loadSidebarTableStorage,
|
||
loadRedisDatabases,
|
||
refreshRedisDbKeyCounts,
|
||
loadEtcdRoot,
|
||
loadZooKeeperRoot,
|
||
loadMqTenants,
|
||
loadNacosNamespaces,
|
||
updateRedisDbKeyStats,
|
||
loadMongoDatabases,
|
||
loadMilvusDatabases,
|
||
openElasticsearchConnectionTree,
|
||
loadElasticsearchIndices,
|
||
loadVectorCollections,
|
||
loadMongoCollections,
|
||
loadSchemas,
|
||
loadSqlServerDatabaseObjects,
|
||
loadSqlServerLinkedServers,
|
||
loadSqlServerLinkedServerCatalogs,
|
||
loadSqlServerLinkedServerSchemas,
|
||
loadDorisCatalogDatabases,
|
||
loadDorisCatalogTables,
|
||
loadTables,
|
||
loadTableForLocate,
|
||
loadObjectGroupChildren,
|
||
loadMoreObjectGroupChildren,
|
||
loadAllObjectGroupChildren,
|
||
loadTableGroups,
|
||
loadTreeNodeChildren,
|
||
loadColumns,
|
||
loadIndexes,
|
||
loadForeignKeys,
|
||
loadTriggers,
|
||
loadConstraints,
|
||
loadPartitions,
|
||
loadSubpartitions,
|
||
listCompletionTables,
|
||
listCompletionObjects,
|
||
listCompletionColumns,
|
||
listCompletionForeignKeys,
|
||
listCompletionSchemas,
|
||
listCompletionDatabases,
|
||
getSqlServerCompletionContext,
|
||
lookupLocalCompletionTables,
|
||
lookupLocalCompletionObjects,
|
||
lookupLocalCompletionColumns,
|
||
lookupLocalCompletionForeignKeys,
|
||
lookupLocalCompletionSchemas,
|
||
lookupLocalCompletionDatabases,
|
||
refreshCompletionTables,
|
||
refreshCompletionObjects,
|
||
refreshCompletionColumns,
|
||
refreshCompletionForeignKeys,
|
||
refreshCompletionSchemas,
|
||
refreshCompletionDatabases,
|
||
listElasticsearchCompletionIndices,
|
||
listRedisCompletionKeys,
|
||
listMongoCompletionCollections,
|
||
listMongoCompletionFields,
|
||
invalidateCompletionCache,
|
||
invalidateCompletionTableCache,
|
||
invalidateMetadataCache,
|
||
exportConnectionsToFile,
|
||
readImportFile,
|
||
importConnectionsFromFile,
|
||
applyDataGripKeychainPasswords,
|
||
applySidebarLayout,
|
||
transferSource,
|
||
schemaDiffSource,
|
||
dataCompareSource,
|
||
sqlFileSource,
|
||
diagramSource,
|
||
tableImportSource,
|
||
tableDataGenerateSource,
|
||
fieldLineageSource,
|
||
databaseSearchSource,
|
||
databaseExportSource,
|
||
sidebarSearchQuery,
|
||
sidebarTableSearchQueries,
|
||
sidebarTableNameFilters,
|
||
tableNameFilterScopeKey,
|
||
tableNameFilterForScope,
|
||
setSidebarTableNameFilter,
|
||
refreshTreeNodeForTableNameFilter,
|
||
setSidebarTableSearchQuery,
|
||
refreshSidebarTableSearch,
|
||
loadSidebarTableSearchIndex,
|
||
refreshSidebarTableSearchIndex,
|
||
createConnectionGroup(name: string, parentGroupId?: string | null) {
|
||
const result = createGroupOp(sidebarLayout.value, name, parentGroupId);
|
||
updateLayoutAndRebuild(result.layout);
|
||
return result.groupId;
|
||
},
|
||
renameConnectionGroup(groupId: string, name: string) {
|
||
updateLayoutAndRebuild(renameGroupOp(sidebarLayout.value, groupId, name));
|
||
},
|
||
deleteConnectionGroup(groupId: string) {
|
||
updateLayoutAndRebuild(deleteGroupOp(sidebarLayout.value, groupId));
|
||
},
|
||
toggleConnectionGroupCollapsed(groupId: string) {
|
||
updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId));
|
||
},
|
||
moveConnectionToGroup(connectionId: string, groupId: string | null) {
|
||
updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId));
|
||
},
|
||
groupIdForConnection(connectionId: string): string | null {
|
||
return findConnectionLocation(sidebarLayout.value, connectionId)?.groupId ?? null;
|
||
},
|
||
reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) {
|
||
updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position));
|
||
},
|
||
reorderSidebarEntries(draggedIds: string[], targetId: string, position: DropPosition) {
|
||
// Apply each dragged entry in turn so a multi-selection moves together,
|
||
// not just the single grabbed row (issue #681).
|
||
let layout = sidebarLayout.value;
|
||
let changed = false;
|
||
for (const id of draggedIds) {
|
||
if (id === targetId) continue;
|
||
layout = reorderEntryOp(layout, id, targetId, position);
|
||
changed = true;
|
||
}
|
||
if (changed) updateLayoutAndRebuild(layout);
|
||
},
|
||
};
|
||
});
|