diff --git a/apps/desktop/src/components/layout/__tests__/ContentArea.locateSidebar.component.spec.ts b/apps/desktop/src/components/layout/__tests__/ContentArea.locateSidebar.component.spec.ts index 9d5b0c87d4..9c9d0cc4ef 100644 --- a/apps/desktop/src/components/layout/__tests__/ContentArea.locateSidebar.component.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/ContentArea.locateSidebar.component.spec.ts @@ -125,7 +125,7 @@ describe("Object browser locate event through ContentArea and the workspace", () isExecuting: false, tableMeta: { ...mocks.target, columns: [], primaryKeys: [] }, }); - expect(activeTabSidebarTarget(onLocateTab.mock.calls[0]![0])).toEqual({ type: "table", connectionId: "browser-connection", database: "browser_database", schema: "row_schema", tableName: "orders.with.dots" }); + expect(activeTabSidebarTarget(onLocateTab.mock.calls[0]![0])).toEqual({ type: "table", connectionId: "browser-connection", database: "browser_database", catalog: "external_catalog", schema: "row_schema", tableName: "orders.with.dots" }); expect(onOpenObjectTable).not.toHaveBeenCalled(); expect(queryStore.tabs).toEqual([tab]); expect(queryStore.activeTabId).toBe(tab.id); diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 0668201caa..79ac8a7bd6 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -43,6 +43,7 @@ import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/datab import { connectionObjectTreeNodeSchema, connectionShouldDiscoverJdbcSchemas, connectionUsesConnectionRootSchemaMode, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { activeTabSidebarTarget, + expandSidebarLocatePath, findSidebarConnectionNode, findSidebarNodeForActiveTab, findSidebarNodeForTarget, @@ -1600,6 +1601,14 @@ async function locateActiveTabInSidebar() { } async function locateTabInSidebar(tab: QueryTab | undefined | null, align: SidebarNodeScrollAlign = "center") { + try { + await locateTabInSidebarTarget(tab, align); + } catch (error) { + toast(error instanceof Error ? error.message : String(error), 5000); + } +} + +async function locateTabInSidebarTarget(tab: QueryTab | undefined | null, align: SidebarNodeScrollAlign) { if (!tab) return; const tabTarget = activeTabSidebarTarget(tab); @@ -1623,8 +1632,12 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb const tabTableCandidate = locatesSavedSql || cursorCandidate ? null : tableLocateCandidateFromTarget(tabTarget, config); const locateTableCandidate = cursorCandidate ?? tabTableCandidate; const fallbackTarget = locatesSavedSql ? tabTarget : (queryContextTargetFromCandidate(tab, cursorCandidate) ?? tabTarget); - const initialTarget = locateTableCandidate ? tableTargetFromCandidate(locateTableCandidate) : fallbackTarget; + let initialTarget = locateTableCandidate ? tableTargetFromCandidate(locateTableCandidate) : fallbackTarget; if (!initialTarget) return; + if (initialTarget.type === "query-context" && connectionUsesConnectionRootSchemaMode(config)) { + const schema = initialTarget.schema || initialTarget.database; + initialTarget = { ...initialTarget, database: schema, schema }; + } // Ensure the tree is loaded deep enough to contain the preferred target. // Saved SQL rows live below their database's runtime Queries node. Loading @@ -1638,7 +1651,7 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb database: savedSqlFile.database, } : initialTarget; - await ensureTreeLoadedForTarget(treeLoadTarget); + await ensureTreeLoadedForTarget(treeLoadTarget, { requireDatabaseChildren: locatesSavedSql }); // Clear any active search filter so the node is visible if (isRootListPartial.value) { @@ -1672,18 +1685,13 @@ async function locateTabInSidebar(tab: QueryTab | undefined | null, align: Sideb nodePath = findNodePathForTarget(fallbackTarget, store.treeNodes); } - if (!nodePath) return; - - for (const ancestor of nodePath) { - // Only flip the arrow when this node's own children are already loaded - // (e.g. by ensureTreeLoadedForTarget above). Forcing isExpanded on a - // table/collection whose column/index groups were never fetched shows an - // "expanded" arrow with no content underneath (issue #5850). - if (!ancestor.isExpanded && store.canUseLoadedTreeNodeToggle(ancestor)) { - ancestor.isExpanded = true; - } + if (!nodePath) { + toast(t("sidebar.locateTargetNotFound"), 5000); + return; } + expandSidebarLocatePath(nodePath, store.canUseLoadedTreeNodeToggle); + // 表分组行同样是投影出的合成节点(不登记已加载子节点,上面的守卫会跳过), // 折叠状态存在布局里,必须经布局 op 展开,否则下次投影又把它折叠回去。 for (const node of nodePath) { @@ -1721,17 +1729,20 @@ function tableTargetFromCandidate(candidate: QueryCursorTableCandidate): ActiveT type: "table", connectionId: candidate.connectionId, database: candidate.database, + catalog: candidate.catalog, schema: candidate.schema, tableName: candidate.tableName, }; } function tableLocateCandidateFromTarget(target: ActiveTabSidebarTarget | null, config: ReturnType): QueryCursorTableCandidate | null { + if (target?.type === "hbase-table") return { connectionId: target.connectionId, database: target.namespace, tableName: target.tableName }; if (target?.type !== "table") return null; const database = connectionUsesConnectionRootSchemaMode(config) && target.schema ? target.schema : target.database; return { connectionId: target.connectionId, database, + catalog: target.catalog, schema: target.schema, tableName: target.tableName, }; @@ -1742,7 +1753,7 @@ function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: Qu return findLoadedTableTargetForCandidate(store.treeNodes, candidate); } -async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) { +async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean; requireDatabaseChildren?: boolean }) { if (target.type === "saved-sql-file" || target.type === "etcd-root" || target.type === "etcd-dashboard" || target.type === "etcd-access-control" || target.type === "zookeeper-root" || target.type === "consul-root") return; const connId = target.connectionId; if (!connId) return; @@ -1757,20 +1768,27 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: const force = opts?.force ?? false; const loadOptions = force ? { force: true } : undefined; + if (target.type === "connection") return; + // Ensure databases are loaded under the connection const connNode = findSidebarConnectionNode(store.treeNodes, connId); if (connNode && (force || !connNode.children || connNode.children.length === 0)) { try { if (config.db_type === "redis") { - await store.loadRedisDatabases(connId); + await store.loadRedisDatabases(connId, { showAll: true }); } else if (config.db_type === "mongodb") { await store.loadMongoDatabases(connId); } else if (config.db_type === "dynamodb") { await store.loadDynamoDbTables(connId); } else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr" || config.db_type === "couchdb") { await store.loadElasticsearchIndices(connId); - } else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") { + } else if (config.db_type === "milvus") { + // Milvus collections belong below a database, even during a forced locate retry. + await store.loadMilvusDatabases(connId); + } else if (config.db_type === "qdrant" || config.db_type === "weaviate" || config.db_type === "chromadb") { await store.loadVectorCollections(connId); + } else if (config.db_type === "mqtt") { + await store.loadMqttTopics(connId); } else if (config.db_type === "mq") { await store.loadMqTenants(connId, loadOptions); } else if (config.db_type === "nacos") { @@ -1786,7 +1804,22 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: if (config.db_type === "mq" || config.db_type === "nacos" || config.db_type === "consul") return; if (!("database" in target) || !target.database) return; - const usesExactCatalogScope = target.type === "query-context"; + if (target.type === "redis-db") { + if (!findNodePathForTarget(target, store.treeNodes)) await store.loadRedisDatabases(connId, { showAll: true }); + return; + } + if (target.type === "mongo-collection" || target.type === "mongo-gridfs") { + if (force || !findNodePathForTarget(target, store.treeNodes)) await store.loadMongoCollections(connId, target.database); + return; + } + if (target.type === "vector-collection") { + if (force || !findNodePathForTarget(target, store.treeNodes)) await store.loadVectorCollections(connId, target.database); + return; + } + // Locating a database only needs its ancestors, not its entire object list. + if (!opts?.requireDatabaseChildren && target.type === "query-context" && !target.schema && findNodePathForTarget(target, store.treeNodes)) return; + + const usesExactCatalogScope = target.type === "query-context" || target.type === "table"; const targetCatalog = usesExactCatalogScope ? target.catalog : undefined; if (usesExactCatalogScope) { const catalogNode = findDorisCatalogNode(store.treeNodes, connId, targetCatalog); @@ -1802,11 +1835,12 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: // Find the database node const targetSchema = "schema" in target ? target.schema : undefined; const effectiveDbType = effectiveDatabaseTypeForConnection(config); - if (target.type === "table" && connectionUsesConnectionRootSchemaMode(config)) { + if ((target.type === "table" || target.type === "query-context") && connectionUsesConnectionRootSchemaMode(config)) { const schemaName = targetSchema || target.database; if (!schemaName) return; const schemaNode = findSchemaNode(store.treeNodes, connId, schemaName, schemaName); if (!schemaNode) return; + if (target.type === "query-context") return; if (force || !schemaNode.children || schemaNode.children.length === 0) { await store.loadTables(connId, schemaNode.database || schemaName, schemaNode.schema ?? schemaName, loadOptions); } @@ -1816,7 +1850,14 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: const dbNode = findDatabaseNode(store.treeNodes, connId, target.database, targetCatalog, usesExactCatalogScope); if (!dbNode) return; - const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0; + if (targetCatalog) { + if (!opts?.requireDatabaseChildren && target.type === "query-context" && !target.schema) return; + if (force || (opts?.requireDatabaseChildren ? !store.canUseLoadedTreeNodeToggle(dbNode) : !dbNode.children?.length)) await store.loadDorisCatalogTables(dbNode, loadOptions); + if (target.type === "table") await ensureTableObjectGroupsLoaded(target, loadOptions); + return; + } + // Runtime Queries children do not mean that the database metadata was loaded. + const databaseChildrenLoaded = opts?.requireDatabaseChildren ? store.canUseLoadedTreeNodeToggle(dbNode) : !!dbNode.children && dbNode.children.length > 0; const usesSchemaTree = (usesTreeSchemaMode(effectiveDbType) && !connectionUsesDatabaseObjectTreeMode(config)) || connectionShouldDiscoverJdbcSchemas(config); const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesSchemaTree; if (!force && databaseChildrenLoaded && !shouldLoadSchemaTables) return; @@ -1871,6 +1912,7 @@ function findTableObjectGroupNodes(nodes: TreeNode[], target: Extract & Pick): SavedSqlFile { @@ -121,3 +123,32 @@ describe("database saved SQL tree", () => { expect(hiveRoot).toMatchObject({ catalog: "hive", children: [{ catalog: "hive", savedSqlId: "hive" }] }); }); }); + +describe("saved SQL locate expansion", () => { + it.each([undefined, "external"])("reveals a saved file through a collapsed runtime Queries node (catalog: %s)", (catalog) => { + const saved = file({ id: "saved", name: "report.sql", connectionId: "conn", database: "app", catalog }); + const database: TreeNode = { id: "db", type: "database", label: "app", connectionId: "conn", database: "app", catalog, isExpanded: false }; + const queries = buildDatabaseSavedSqlRootNode(database, [saved])!; + database.children = [queries]; + const connection: TreeNode = { id: "conn", type: "connection", label: "Connection", connectionId: "conn", isExpanded: false, children: [database] }; + const tree = [connection]; + const target = { type: "saved-sql-file" as const, savedSqlId: saved.id }; + const path = findNodePathForTarget(target, tree)!; + expect(findSidebarNodeForTarget(target, flattenTree(tree))).toBeNull(); + // Database/connection metadata has loaded; Queries is synthesized locally + // and intentionally has no metadata load marker. + const loaded = new Set([connection.id, database.id]); + expandSidebarLocatePath(path, (node) => loaded.has(node.id)); + expect(findSidebarNodeForTarget(target, flattenTree(tree))?.node.savedSqlId).toBe(saved.id); + expect(queries.isExpanded).toBe(true); + expect(path[path.length - 1]!.isExpanded).not.toBe(true); + }); + + it("keeps unloaded object leaves collapsed when revealing their parent path", () => { + const table: TreeNode = { id: "table", type: "table", label: "orders", isExpanded: false }; + const database: TreeNode = { id: "db", type: "database", label: "app", isExpanded: false, children: [table] }; + expandSidebarLocatePath([database, table], (node) => node.id === database.id); + expect(flattenTree([database]).map((row) => row.id)).toEqual(["db", "table"]); + expect(table.isExpanded).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts index aa2f51f1db..8b5616ecd2 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { activeTabSidebarTarget, matchesTarget } from "@/lib/sidebar/sidebarActiveTabTarget"; +import { findLoadedTableTargetForCandidate, queryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; import type { QueryTab, TreeNode } from "@/types/database"; describe("sidebar active-tab targets", () => { @@ -51,3 +52,67 @@ describe("sidebar active-tab targets", () => { expect(matchesTarget({ id: "dm-1:SERVICE_DB", label: "SERVICE_DB", type: "database", connectionId: "dm-1", database: "SERVICE_DB" } as TreeNode, target!)).toBe(false); }); }); + +describe("locating workspace tabs", () => { + it("uses the object browser namespace instead of the tab display title", () => { + const target = activeTabSidebarTarget({ mode: "objects", connectionId: "conn", database: "db", title: "renamed", objectBrowser: { schema: "public", catalog: "external" } } as QueryTab); + expect(target).toEqual({ type: "query-context", connectionId: "conn", database: "db", schema: "public", catalog: "external" }); + expect(matchesTarget({ type: "schema", connectionId: "conn", database: "db", schema: "public", label: "public", catalog: "external" } as TreeNode, target!)).toBe(true); + }); + + it.each(["database", "mongo-db", "vector-database"] as const)("locates an object browser in a %s tree", (type) => { + const target = activeTabSidebarTarget({ mode: "objects", connectionId: "conn", database: "db" } as QueryTab); + expect(matchesTarget({ type, connectionId: "conn", database: "db", label: "db" } as TreeNode, target!)).toBe(true); + }); + + it("locates Redis by database number, independent of its alias and key count", () => { + const target = activeTabSidebarTarget({ mode: "redis", connectionId: "conn", database: "0" } as QueryTab); + expect(matchesTarget({ type: "redis-db", connectionId: "conn", database: "0", label: "Cache (123)" } as TreeNode, target!)).toBe(true); + expect(matchesTarget({ type: "redis-db", connectionId: "conn", database: "1", label: "Cache (123)" } as TreeNode, target!)).toBe(false); + }); + + it("locates structure tabs by their object identity and catalog", () => { + const target = activeTabSidebarTarget({ mode: "structure", connectionId: "conn", database: "db", catalog: "external", structureTableName: "orders", title: "renamed" } as QueryTab); + const node = { type: "table", connectionId: "conn", database: "db", catalog: "external", label: "orders" } as TreeNode; + expect(matchesTarget(node, target!)).toBe(true); + expect(matchesTarget({ ...node, catalog: "other" }, target!)).toBe(false); + }); + + it.each(["mq", "redis-dashboard", "nacos-dashboard", "plugin-workbench"] as const)("locates the connection for %s without an object target", (mode) => { + expect(activeTabSidebarTarget({ mode, connectionId: "conn" } as QueryTab)).toEqual({ type: "connection", connectionId: "conn" }); + expect(activeTabSidebarTarget({ mode, connectionId: "" } as QueryTab)).toBeNull(); + }); +}); + +describe("table locate candidates", () => { + it("keeps catalog scope from the query through loaded-node resolution", () => { + const candidate = queryCursorTableCandidate({ mode: "query", connectionId: "conn", catalog: "hive", database: "db", sql: "SELECT * FROM orders" } as QueryTab, "doris"); + expect(candidate?.catalog).toBe("hive"); + const wrong = { type: "table", connectionId: "conn", database: "db", catalog: "iceberg", label: "orders" } as TreeNode; + expect(findLoadedTableTargetForCandidate([wrong], candidate!)).toBeNull(); + expect(findLoadedTableTargetForCandidate([wrong, { ...wrong, catalog: "hive" }], candidate!)?.type).toBe("table"); + }); + + it("accepts the database as the effective schema for schema-less table nodes", () => { + const target = findLoadedTableTargetForCandidate([{ type: "table", connectionId: "conn", database: "db", label: "orders" } as TreeNode], { connectionId: "conn", database: "db", schema: "db", tableName: "orders" }); + expect(target?.type).toBe("table"); + }); +}); + +describe("management page sidebar targets", () => { + it.each([ + ["users", "user-admin"], + ["xugu-users", "xugu-user-admin"], + ["dameng-users", "dameng-users"], + ["dameng-roles", "dameng-roles"], + ["dameng-jobs", "dameng-job-admin"], + ] as const)("locates %s at its dedicated %s node", (mode, nodeType) => { + const target = activeTabSidebarTarget({ mode, connectionId: "conn" } as QueryTab); + expect(target).toEqual({ type: "management", connectionId: "conn", nodeType }); + const node = { id: "management", type: nodeType, connectionId: "conn", label: "translated label" } as TreeNode; + expect(matchesTarget(node, target!)).toBe(true); + expect(matchesTarget({ ...node, type: "connection" }, target!)).toBe(false); + expect(matchesTarget({ ...node, connectionId: "other" }, target!)).toBe(false); + expect(activeTabSidebarTarget({ mode, connectionId: "" } as QueryTab)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts index 73b604b79c..3054059975 100644 --- a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts +++ b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts @@ -7,13 +7,28 @@ export type ActiveTabSidebarTarget = type: "connection"; connectionId: string; } + | { + type: "management"; + connectionId: string; + nodeType: "user-admin" | "xugu-user-admin" | "dameng-users" | "dameng-roles" | "dameng-job-admin"; + } | { type: "table"; + catalog?: string; connectionId: string; database: string; schema?: string; tableName: string; } + | { + type: "redis-db"; + connectionId: string; + database: string; + } + | { + type: "mqtt-topic"; + connectionId: string; + } | { type: "mongo-collection"; connectionId: string; @@ -110,10 +125,46 @@ export function findSidebarConnectionNode(nodes: readonly TreeNode[], connection export function activeTabSidebarTarget(tab: QueryTab | undefined | null): ActiveTabSidebarTarget | null { if (!tab) return null; + const managementNodeTypes = { + users: "user-admin", + "xugu-users": "xugu-user-admin", + "dameng-users": "dameng-users", + "dameng-roles": "dameng-roles", + "dameng-jobs": "dameng-job-admin", + } as const; + if (Object.prototype.hasOwnProperty.call(managementNodeTypes, tab.mode)) { + if (!tab.connectionId) return null; + return { type: "management", connectionId: tab.connectionId, nodeType: managementNodeTypes[tab.mode as keyof typeof managementNodeTypes] }; + } + if (tab.mode === "databases") { return { type: "connection", connectionId: tab.connectionId }; } + if (tab.mode === "objects" || tab.mode === "database-search" || (tab.mode === "structure" && !tab.structureTableName)) { + if (!tab.connectionId) return null; + if (!tab.database) return { type: "connection", connectionId: tab.connectionId }; + return { + type: "query-context", + connectionId: tab.connectionId, + database: tab.database, + catalog: tab.objectBrowser?.catalog ?? tab.catalog, + schema: tab.objectBrowser?.schema ?? tab.schema, + }; + } + + if (tab.mode === "structure") { + return { type: "table", connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema, tableName: tab.structureTableName! }; + } + + if (tab.mode === "redis") { + return { type: "redis-db", connectionId: tab.connectionId, database: tab.database }; + } + + if (tab.mode === "mqtt") { + return { type: "mqtt-topic", connectionId: tab.connectionId }; + } + if (tab.mode === "data") { const tableMeta = tableMetaForDataTab(tab); const tableName = tableMeta?.tableName || tab.title; @@ -122,6 +173,7 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active type: "table", connectionId: tab.connectionId, database: tableMeta?.database ?? tab.database, + ...(tab.catalog ? { catalog: tab.catalog } : {}), schema: tableMeta?.schema ?? tab.schema, tableName, }; @@ -229,7 +281,8 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active } if (tab.mode === "query") { - if (!tab.connectionId || !tab.database) return null; + if (!tab.connectionId) return null; + if (!tab.database) return { type: "connection", connectionId: tab.connectionId }; return { type: "query-context", connectionId: tab.connectionId, @@ -239,7 +292,9 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active }; } - return null; + // Connection workspaces (dashboards, MQ without a tenant, and plugins) + // have no more specific object identity. + return tab.connectionId ? { type: "connection", connectionId: tab.connectionId } : null; } function schemaMatches(node: TreeNode, schema: string | undefined): boolean { @@ -258,6 +313,17 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b return node.type === "connection" && node.connectionId === target.connectionId; } + if (target.type === "management") { + return node.type === target.nodeType && node.connectionId === target.connectionId; + } + + if (target.type === "redis-db") { + return node.type === "redis-db" && node.connectionId === target.connectionId && node.database === target.database; + } + if (target.type === "mqtt-topic") { + return node.type === "mqtt-topic" && node.connectionId === target.connectionId; + } + if (target.type === "mongo-collection") { if (node.type === "elasticsearch-index") { return node.connectionId === target.connectionId && node.label === target.collectionName; @@ -286,7 +352,7 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b if (target.schema) { return node.type === "schema" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.schema; } - return node.type === "database" && node.connectionId === target.connectionId && node.label === target.database; + return (node.type === "database" || node.type === "mongo-db" || node.type === "redis-db" || node.type === "vector-database") && node.connectionId === target.connectionId && (node.database ?? node.label) === target.database; } if (target.type === "etcd-root") { @@ -327,7 +393,14 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b return node.type === "saved-sql-file" && node.savedSqlId === target.savedSqlId; } - return (node.type === "table" || node.type === "view" || node.type === "materialized_view") && node.connectionId === target.connectionId && node.database === target.database && schemaMatches(node, target.schema) && node.label === target.tableName; + return ( + (node.type === "table" || node.type === "view" || node.type === "materialized_view") && + (node.catalog || undefined) === (target.catalog || undefined) && + node.connectionId === target.connectionId && + node.database === target.database && + schemaMatches(node, target.schema) && + node.label === target.tableName + ); } export function findSidebarNodeForActiveTab(tab: QueryTab | undefined | null, flatNodes: readonly FlatTreeNode[]): FlatTreeNode | null { @@ -399,3 +472,14 @@ function findPath(nodes: readonly TreeNode[], predicate: (node: TreeNode) => boo } return null; } + +/** Expand loaded ancestors, including runtime Queries containers without metadata load markers. */ +export function expandSidebarLocatePath(path: readonly TreeNode[], canToggleLoaded: (node: TreeNode) => boolean): void { + for (let index = 0; index < path.length; index++) { + const node = path[index]!; + const next = path[index + 1]; + const runtimeSqlAncestor = (node.type === "saved-sql-root" || node.type === "saved-sql-folder") && !!next && !!node.children?.some((child) => child.id === next.id); + // Keep unloaded table/collection leaves collapsed: their columns were not fetched. + if (!node.isExpanded && (runtimeSqlAncestor || canToggleLoaded(node))) node.isExpanded = true; + } +} diff --git a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts index 2a8ad849ba..db06dd889f 100644 --- a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts +++ b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts @@ -6,6 +6,7 @@ import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; import type { DatabaseType, QueryTab, TreeNode } from "@/types/database"; export interface QueryCursorTableCandidate { + catalog?: string; connectionId: string; database: string; schema?: string; @@ -44,7 +45,7 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null; const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length; - return queryTableCandidateAtSqlPosition({ + const candidate = queryTableCandidateAtSqlPosition({ connectionId: tab.connectionId, database: tab.database, schema: tab.schema, @@ -52,6 +53,7 @@ export function queryCursorTableCandidate(tab: QueryTab | undefined | null, data sql: tab.sql, position: cursor, }); + return candidate && tab.catalog ? { ...candidate, catalog: tab.catalog } : candidate; } export function queryTableCandidateAtSqlPosition(input: QueryTableCandidateAtPositionInput): QueryCursorTableCandidate | null { @@ -158,8 +160,9 @@ function sameIdentifier(left: string | undefined, right: string | undefined): bo function nodeMatchesCandidate(node: TreeNode, candidate: QueryCursorTableCandidate): boolean { if (node.type !== "table" && node.type !== "view" && node.type !== "materialized_view") return false; if (node.connectionId !== candidate.connectionId) return false; + if ((node.catalog || undefined) !== (candidate.catalog || undefined)) return false; if (!sameIdentifier(node.database, candidate.database)) return false; - if (candidate.schema && !sameIdentifier(node.schema, candidate.schema)) return false; + if (candidate.schema && !sameIdentifier(node.schema || node.database, candidate.schema)) return false; return sameIdentifier(node.label, candidate.tableName); } @@ -170,6 +173,7 @@ export function findLoadedTableTargetForCandidate(nodes: readonly TreeNode[], ca type: "table", connectionId: candidate.connectionId, database: node.database || candidate.database, + ...(node.catalog ? { catalog: node.catalog } : {}), schema: node.schema || candidate.schema, tableName: node.label, }; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 25205ac8f3..715e9b03f8 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -318,6 +318,7 @@ function isFlatMqConnection(config: ConnectionConfig | undefined): boolean { type ImportSource = "dbx" | "navicat" | "dbeaver" | "datagrip"; interface LocateTableTarget { + catalog?: string; connectionId: string; database: string; schema?: string; @@ -7017,10 +7018,29 @@ export const useConnectionStore = defineStore("connection", () => { offset: 0, sidebarDisplayMode: useSettingsStore().editorSettings.sidebarObjectDisplay, driverProfile: metadataDriverProfile(config), + extra: target.catalog ? { catalog: target.catalog } : undefined, }, async () => { await ensureConnected(target.connectionId); + if (target.catalog) { + const parent = findDatabaseTreeNode(treeNodes.value, target.connectionId, target.database, target.catalog); + if (!parent) return false; + let load = beginTreeNodeLoad(parent); + try { + load = reclaimTreeNodeLoad(load, parent); + const tables = await withMetadataLoadTimeout(target.connectionId, listTablesWithOptionalTableNameFilter(target.connectionId, target.database, "", target.tableName, undefined, undefined, undefined, target.catalog), "tables"); + const current = treeNodeLoadTarget(load); + if (!current) return false; + const children = buildTableTreeNodes({ nodeId: current.id, connectionId: target.connectionId, database: target.database, catalog: target.catalog, tables }); + setChildren(current, mergeLocatedTreeChildren(current, current.children ?? [], children, target.connectionId, target.database)); + current.isExpanded = true; + return children.length > 0; + } finally { + finishTreeNodeLoad(load); + } + } + const pageSize = sidebarObjectGroupPageSize(); const simpleObjectDisplay = useSettingsStore().editorSettings.sidebarObjectDisplay === "simple"; // A query tab can reach locate without a schema (a toolbar "new query" tab,