mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(sidebar): locate database and workspace tabs in navigation
Closes #11253
This commit is contained in:
+1
-1
@@ -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);
|
||||
|
||||
@@ -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<typeof store.getConfig>): 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<ActiveTabS
|
||||
(node.type === "group-tables" || node.type === "group-dolt-system-tables" || node.type === "group-views" || node.type === "group-materialized-views") &&
|
||||
node.connectionId === target.connectionId &&
|
||||
sameTreeName(node.database, target.database) &&
|
||||
(node.catalog || undefined) === (target.catalog || undefined) &&
|
||||
(!target.schema || sameTreeName(node.schema, target.schema))
|
||||
) {
|
||||
matches.push(node);
|
||||
|
||||
@@ -467,6 +467,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Müntəzəm ifadə ilə axtarış",
|
||||
regexSearchTooltip: "Yan paneldəki adları, şərhləri və ləqəbləri JavaScript müntəzəm ifadələri ilə uyğunlaşdırın.",
|
||||
clearFilter: "Süzgəci təmizlə",
|
||||
locateTargetNotFound: "Element yan paneldə mövcud deyil. O, silinmiş və ya gizlədilmiş ola bilər.",
|
||||
locateActiveTab: "Yan paneldə tap",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -750,6 +750,7 @@ export default {
|
||||
regexSearch: "Regex search",
|
||||
regexSearchTooltip: "Match sidebar names, comments, and aliases with JavaScript regular expressions.",
|
||||
clearFilter: "Clear filter",
|
||||
locateTargetNotFound: "The item is not available in the sidebar. It may have been removed or hidden.",
|
||||
locateActiveTab: "Locate in sidebar",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -744,6 +744,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Búsqueda con regex",
|
||||
regexSearchTooltip: "Coincide con nombres, comentarios y alias mediante expresiones regulares de JavaScript.",
|
||||
clearFilter: "Limpiar filtro",
|
||||
locateTargetNotFound: "El elemento no está disponible en la barra lateral. Puede que se haya eliminado u ocultado.",
|
||||
locateActiveTab: "Localizar en la barra lateral",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -644,6 +644,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Pencarian regex",
|
||||
regexSearchTooltip: "Cocokkan nama, komentar, dan alias di bilah sisi dengan ekspresi reguler JavaScript.",
|
||||
clearFilter: "Bersihkan filter",
|
||||
locateTargetNotFound: "Item tidak tersedia di bilah sisi. Item mungkin telah dihapus atau disembunyikan.",
|
||||
locateActiveTab: "Cari di bilah sisi",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -733,6 +733,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Ricerca regex",
|
||||
regexSearchTooltip: "Trova nomi, commenti e alias con espressioni regolari JavaScript.",
|
||||
clearFilter: "Cancella filtro",
|
||||
locateTargetNotFound: "L’elemento non è disponibile nella barra laterale. Potrebbe essere stato rimosso o nascosto.",
|
||||
locateActiveTab: "Trova nella barra laterale",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -734,6 +734,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "正規表現検索",
|
||||
regexSearchTooltip: "JavaScript 正規表現でサイドバーの名前、コメント、エイリアスを照合します。",
|
||||
clearFilter: "フィルターをクリア",
|
||||
locateTargetNotFound: "サイドバーに該当する項目が見つかりません。削除または非表示にされている可能性があります。",
|
||||
locateActiveTab: "サイドバーで表示",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -736,6 +736,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "정규식 검색",
|
||||
regexSearchTooltip: "JavaScript 정규식으로 사이드바 이름, 주석, 별칭을 검색합니다.",
|
||||
clearFilter: "필터 지우기",
|
||||
locateTargetNotFound: "사이드바에서 해당 항목을 찾을 수 없습니다. 삭제되었거나 숨겨져 있을 수 있습니다.",
|
||||
locateActiveTab: "사이드바에서 찾기",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -734,6 +734,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Busca por regex",
|
||||
regexSearchTooltip: "Correspondência de nomes, comentários e aliases com expressões regulares JavaScript.",
|
||||
clearFilter: "Limpar filtro",
|
||||
locateTargetNotFound: "O item não está disponível na barra lateral. Ele pode ter sido removido ou ocultado.",
|
||||
locateActiveTab: "Localizar na barra lateral",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -716,6 +716,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Поиск по регулярному выражению",
|
||||
regexSearchTooltip: "Сопоставлять имена, комментарии и псевдонимы в боковой панели с помощью регулярных выражений JavaScript.",
|
||||
clearFilter: "Очистить фильтр",
|
||||
locateTargetNotFound: "Элемент недоступен на боковой панели. Возможно, он был удалён или скрыт.",
|
||||
locateActiveTab: "Найти в боковой панели",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -467,6 +467,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "Regex araması",
|
||||
regexSearchTooltip: "Kenar çubuğu adlarını, açıklamaları ve takma adları JavaScript düzenli ifadeleriyle eşleştirin.",
|
||||
clearFilter: "Filtreyi temizle",
|
||||
locateTargetNotFound: "Öğe kenar çubuğunda bulunamıyor. Silinmiş veya gizlenmiş olabilir.",
|
||||
locateActiveTab: "Kenar çubuğunda bul",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -687,6 +687,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "正则搜索",
|
||||
regexSearchTooltip: "使用 JavaScript 正则表达式匹配侧边栏名称、备注和别名。",
|
||||
clearFilter: "清除筛选",
|
||||
locateTargetNotFound: "左侧导航中未找到对应项目,该项目可能已被删除或隐藏。",
|
||||
locateActiveTab: "在侧边栏中定位",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -739,6 +739,7 @@ export default withEnglishFallback({
|
||||
regexSearch: "正規表示式搜尋",
|
||||
regexSearchTooltip: "使用 JavaScript 正規表示式比對側邊欄名稱、備註與別名。",
|
||||
clearFilter: "清除篩選",
|
||||
locateTargetNotFound: "側邊欄中找不到對應項目,該項目可能已被刪除或隱藏。",
|
||||
locateActiveTab: "在側邊欄中定位",
|
||||
},
|
||||
savedSql: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDatabaseSavedSqlRootNode, decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, savedSqlFilesForDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
|
||||
import { expandSidebarLocatePath, findNodePathForTarget, findSidebarNodeForTarget } from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import { flattenTree } from "@/composables/useFlatTree";
|
||||
import type { SavedSqlFile, TreeNode } from "@/types/database";
|
||||
|
||||
function file(input: Partial<SavedSqlFile> & Pick<SavedSqlFile, "id" | "name" | "connectionId" | "database">): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user