diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 67bb8e1e35..446288ad79 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -5,12 +5,13 @@ import { Search, X, ListFilter, Crosshair, Server, Database, FolderTree, Table2, import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; -import type { QueryTab, TreeNode, TreeNodeType } from "@/types/database"; +import type { TreeNode, TreeNodeType } from "@/types/database"; import { filterSidebarSearchRootsByConnectionState, filterSidebarTree } from "@/lib/sidebarSearchTree"; import { isCancelSearchShortcut } from "@/lib/keyboardShortcuts"; import { usesTreeSchemaMode } from "@/lib/databaseFeatureSupport"; -import { connectionUsesDatabaseObjectTreeMode } from "@/lib/jdbcDialect"; -import { findSidebarNodeForActiveTab, findNodePathForActiveTab, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection } from "@/lib/sidebarActiveTabTarget"; +import { connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/jdbcDialect"; +import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget } from "@/lib/sidebarActiveTabTarget"; +import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/queryCursorTableTarget"; import { SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, scrollTopForExpandedTreeNode, shouldAutoScrollExpandedTreeNode, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree"; import { sidebarTreeContextKey } from "@/lib/sidebarTreeContext"; import TreeItem from "./TreeItem.vue"; @@ -211,8 +212,14 @@ async function locateActiveTabInSidebar() { } } - // Ensure the tree is loaded deep enough to contain the target node - await ensureTreeLoadedForTab(tab); + const config = connId ? store.getConfig(connId) : undefined; + const cursorCandidate = queryCursorTableCandidate(tab, effectiveDatabaseTypeForConnection(config)); + const fallbackTarget = queryContextTargetFromCandidate(tab, cursorCandidate) ?? activeTabSidebarTarget(tab); + const initialTarget = cursorCandidate ? tableTargetFromCandidate(cursorCandidate) : fallbackTarget; + if (!initialTarget) return; + + // Ensure the tree is loaded deep enough to contain the preferred target. + await ensureTreeLoadedForTarget(initialTarget); // Clear any active search filter so the node is visible if (isFiltering.value) { @@ -221,15 +228,24 @@ async function locateActiveTabInSidebar() { clearSearchScopeFilter(); } - let nodePath = findNodePathForActiveTab(tab, store.treeNodes); + let target = resolveLoadedLocateTarget(initialTarget, cursorCandidate); + let nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null; if (!nodePath) { // The first load may have served a stale schema cache whose async refresh // replaced the database node before its tables finished loading, so the // table isn't in the tree yet. Force a synchronous reload and retry once so // locate reaches the table, not just the database (issue #715). - await ensureTreeLoadedForTab(tab, { force: true }); - nodePath = findNodePathForActiveTab(tab, store.treeNodes); + await ensureTreeLoadedForTarget(initialTarget, { force: true }); + target = resolveLoadedLocateTarget(initialTarget, cursorCandidate); + nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null; } + + if (!nodePath && cursorCandidate && fallbackTarget) { + await ensureTreeLoadedForTarget(fallbackTarget); + target = fallbackTarget; + nodePath = findNodePathForTarget(fallbackTarget, store.treeNodes); + } + if (!nodePath) return; for (const ancestor of nodePath) { @@ -240,7 +256,7 @@ async function locateActiveTabInSidebar() { await nextTick(); - const match = findSidebarNodeForActiveTab(tab, flatNodes.value); + const match = target ? findSidebarNodeForTarget(target, flatNodes.value) : null; if (!match) return; store.selectedTreeNodeId = match.id; @@ -255,8 +271,24 @@ async function locateActiveTabInSidebar() { await scrollToSidebarNode(match.id); } -async function ensureTreeLoadedForTab(tab: QueryTab, opts?: { force?: boolean }) { - const connId = tab.connectionId; +function tableTargetFromCandidate(candidate: QueryCursorTableCandidate): ActiveTabSidebarTarget { + return { + type: "table", + connectionId: candidate.connectionId, + database: candidate.database, + schema: candidate.schema, + tableName: candidate.tableName, + }; +} + +function resolveLoadedLocateTarget(target: ActiveTabSidebarTarget, candidate: QueryCursorTableCandidate | null): ActiveTabSidebarTarget | null { + if (!candidate) return target; + return findLoadedTableTargetForCandidate(store.treeNodes, candidate); +} + +async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: { force?: boolean }) { + if (target.type === "saved-sql-file" || target.type === "etcd-root") return; + const connId = target.connectionId; if (!connId) return; const config = store.getConfig(connId); @@ -287,37 +319,73 @@ async function ensureTreeLoadedForTab(tab: QueryTab, opts?: { force?: boolean }) } } - if (!tab.database) return; + if (!("database" in target) || !target.database) return; // Find the database node - const dbNode = findDatabaseNode(store.treeNodes, connId, tab.database); + const dbNode = findDatabaseNode(store.treeNodes, connId, target.database); if (!dbNode) return; - if (!force && dbNode.children && dbNode.children.length > 0) return; + const targetSchema = "schema" in target ? target.schema : undefined; + const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0; + const shouldLoadSchemaTables = target.type === "table" && !!targetSchema && usesTreeSchemaMode(config.db_type) && !connectionUsesDatabaseObjectTreeMode(config); + if (!force && databaseChildrenLoaded && !shouldLoadSchemaTables) return; // Load database contents try { if (config.db_type === "sqlserver") { - await store.loadSqlServerDatabaseObjects(connId, tab.database, loadOptions); + if (force || !databaseChildrenLoaded) { + await store.loadSqlServerDatabaseObjects(connId, target.database, loadOptions); + } } else if (usesTreeSchemaMode(config.db_type) && !connectionUsesDatabaseObjectTreeMode(config)) { - await store.loadSchemas(connId, tab.database, loadOptions); + if (force || !databaseChildrenLoaded) { + await store.loadSchemas(connId, target.database, loadOptions); + } // If we have a schema, also load tables under that schema - if (tab.schema) { - const schemaNode = findSchemaNode(store.treeNodes, connId, tab.database, tab.schema); + if (targetSchema) { + const schemaNode = findSchemaNode(store.treeNodes, connId, target.database, targetSchema); if (schemaNode && (force || !schemaNode.children || schemaNode.children.length === 0)) { - await store.loadTables(connId, tab.database, tab.schema, loadOptions); + await store.loadTables(connId, target.database, targetSchema, loadOptions); } } } else { - await store.loadTables(connId, tab.database, undefined, loadOptions); + await store.loadTables(connId, target.database, undefined, loadOptions); + } + + if (target.type === "table") { + await ensureTableObjectGroupsLoaded(target, loadOptions); } } catch { // Node just won't have children loaded } } +async function ensureTableObjectGroupsLoaded(target: Extract, options?: { force?: boolean }) { + const groups = findTableObjectGroupNodes(store.treeNodes, target); + for (const group of groups) { + if (!options?.force && group.children && group.children.length > 0) continue; + await store.loadObjectGroupChildren(group, options); + } +} + +function findTableObjectGroupNodes(nodes: TreeNode[], target: Extract): TreeNode[] { + const matches: TreeNode[] = []; + for (const node of nodes) { + if ((node.type === "group-tables" || node.type === "group-views") && node.connectionId === target.connectionId && sameTreeName(node.database, target.database) && (!target.schema || sameTreeName(node.schema, target.schema))) { + matches.push(node); + } + if (node.children) { + matches.push(...findTableObjectGroupNodes(node.children, target)); + } + } + return matches; +} + +function sameTreeName(left: string | undefined, right: string | undefined): boolean { + return (left || "").toLowerCase() === (right || "").toLowerCase(); +} + function findDatabaseNode(nodes: TreeNode[], connId: string, database: string): TreeNode | null { for (const node of nodes) { - if (node.type === "database" && node.connectionId === connId && node.database === database) { + if (node.type === "database" && node.connectionId === connId && sameTreeName(node.database, database)) { return node; } if (node.children) { @@ -330,7 +398,7 @@ function findDatabaseNode(nodes: TreeNode[], connId: string, database: string): function findSchemaNode(nodes: TreeNode[], connId: string, database: string, schema: string): TreeNode | null { for (const node of nodes) { - if (node.type === "schema" && node.connectionId === connId && node.database === database && node.label === schema) { + if (node.type === "schema" && node.connectionId === connId && sameTreeName(node.database, database) && sameTreeName(node.label, schema)) { return node; } if (node.children) { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index e36ef3dfbd..ad4e07bbee 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -3382,12 +3382,22 @@ function treeItemMenuItems(): ContextMenuItem[] { .tree-item-highlight { background-color: rgb(253 225 167) !important; background-color: oklch(0.92 0.08 85) !important; - transition: background-color 0.8s ease-out 0.6s; + transition: background-color 0.28s ease-out; } :root.dark .tree-item-highlight { background-color: rgb(110 67 0) !important; background-color: oklch(0.42 0.12 80) !important; - transition: background-color 0.8s ease-out 0.6s; + transition: background-color 0.28s ease-out; +} + +.tree-item-connection-tint.tree-item-highlight::before { + background-color: rgb(253 225 167) !important; + background-color: oklch(0.92 0.08 85) !important; +} + +:root.dark .tree-item-connection-tint.tree-item-highlight::before { + background-color: rgb(110 67 0) !important; + background-color: oklch(0.42 0.12 80) !important; } diff --git a/apps/desktop/src/lib/queryCursorTableTarget.ts b/apps/desktop/src/lib/queryCursorTableTarget.ts new file mode 100644 index 0000000000..cc6664de0d --- /dev/null +++ b/apps/desktop/src/lib/queryCursorTableTarget.ts @@ -0,0 +1,178 @@ +import { isSchemaAware, isSingleDatabase } from "@/lib/databaseFeatureSupport"; +import { isSqlKeyword } from "@/lib/sqlNavigation"; +import type { ActiveTabSidebarTarget } from "@/lib/sidebarActiveTabTarget"; +import type { DatabaseType, QueryTab, TreeNode } from "@/types/database"; + +export interface QueryCursorTableCandidate { + connectionId: string; + database: string; + schema?: string; + tableName: string; +} + +export interface IdentifierPart { + value: string; + quoted: boolean; +} + +function identifierChar(ch: string | undefined): boolean { + return !!ch && (/[A-Za-z0-9_$."`-]/.test(ch) || ch === "[" || ch === "]"); +} + +function readIdentifierWindow(sql: string, pos: number): string { + const clamped = Math.max(0, Math.min(pos, sql.length)); + let from = clamped; + let to = clamped; + + if (!identifierChar(sql[from]) && identifierChar(sql[from - 1])) { + from -= 1; + to = from + 1; + } + + if (!identifierChar(sql[from])) return ""; + + while (from > 0 && identifierChar(sql[from - 1])) from -= 1; + while (to < sql.length && identifierChar(sql[to])) to += 1; + return sql.slice(from, to).replace(/^\.+|\.+$/g, ""); +} + +function splitQualifiedIdentifier(text: string): IdentifierPart[] { + const parts: IdentifierPart[] = []; + let current = ""; + let quoted = false; + let quote: "`" | '"' | "]" | null = null; + + function pushPart() { + const value = current.trim(); + if (value) parts.push({ value, quoted }); + current = ""; + quoted = false; + } + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + + if (quote) { + if (quote === "]" && ch === "]") { + if (text[i + 1] === "]") { + current += "]"; + i += 1; + } else { + quote = null; + } + continue; + } + if ((quote === "`" || quote === '"') && ch === quote) { + if (text[i + 1] === quote) { + current += ch; + i += 1; + } else { + quote = null; + } + continue; + } + current += ch; + continue; + } + + if (ch === ".") { + pushPart(); + continue; + } + + if (ch === "`" || ch === '"') { + quoted = true; + quote = ch; + continue; + } + + if (ch === "[") { + quoted = true; + quote = "]"; + continue; + } + + current += ch; + } + + pushPart(); + return parts; +} + +export function extractQualifiedIdentifierPartsAt(sql: string, pos: number): IdentifierPart[] { + const text = readIdentifierWindow(sql, pos); + if (!text) return []; + const parts = splitQualifiedIdentifier(text); + const last = parts[parts.length - 1]; + if (!last || (!last.quoted && isSqlKeyword(last.value))) return []; + return parts; +} + +export function queryCursorTableCandidate(tab: QueryTab | undefined | null, databaseType?: DatabaseType): QueryCursorTableCandidate | null { + if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null; + + const cursor = tab.editorSelection?.head ?? tab.editorSelection?.anchor ?? tab.sql.length; + const parts = extractQualifiedIdentifierPartsAt(tab.sql, cursor).map((part) => part.value); + if (parts.length === 0) return null; + + const tableName = parts[parts.length - 1]; + let database = tab.database; + let schema = tab.schema; + + if (parts.length >= 3) { + database = parts[parts.length - 3]; + schema = parts[parts.length - 2]; + } else if (parts.length === 2) { + if (databaseType && !isSchemaAware(databaseType) && !isSingleDatabase(databaseType)) { + database = parts[0]; + schema = undefined; + } else { + schema = parts[0]; + } + } + + return { connectionId: tab.connectionId, database, schema, tableName }; +} + +export function queryContextTargetFromCandidate(tab: QueryTab | undefined | null, candidate?: QueryCursorTableCandidate | null): ActiveTabSidebarTarget | null { + if (!tab || tab.mode !== "query" || !tab.connectionId || !tab.database) return null; + return { + type: "query-context", + connectionId: tab.connectionId, + database: candidate?.database || tab.database, + schema: candidate?.schema ?? tab.schema, + }; +} + +function sameIdentifier(left: string | undefined, right: string | undefined): boolean { + return (left || "").toLowerCase() === (right || "").toLowerCase(); +} + +function nodeMatchesCandidate(node: TreeNode, candidate: QueryCursorTableCandidate): boolean { + if (node.type !== "table" && node.type !== "view") return false; + if (node.connectionId !== candidate.connectionId) return false; + if (!sameIdentifier(node.database, candidate.database)) return false; + if (candidate.schema && !sameIdentifier(node.schema, candidate.schema)) return false; + return sameIdentifier(node.label, candidate.tableName); +} + +export function findLoadedTableTargetForCandidate(nodes: readonly TreeNode[], candidate: QueryCursorTableCandidate): ActiveTabSidebarTarget | null { + for (const node of nodes) { + if (nodeMatchesCandidate(node, candidate)) { + return { + type: "table", + connectionId: candidate.connectionId, + database: node.database || candidate.database, + schema: node.schema || candidate.schema, + tableName: node.label, + }; + } + + if (node.children) { + const found = findLoadedTableTargetForCandidate(node.children, candidate); + if (found) return found; + } + } + + return null; +} diff --git a/apps/desktop/src/lib/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebarActiveTabTarget.ts index 775d83d50a..ceacaef78c 100644 --- a/apps/desktop/src/lib/sidebarActiveTabTarget.ts +++ b/apps/desktop/src/lib/sidebarActiveTabTarget.ts @@ -111,6 +111,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b export function findSidebarNodeForActiveTab(tab: QueryTab | undefined | null, flatNodes: readonly FlatTreeNode[]): FlatTreeNode | null { const target = activeTabSidebarTarget(tab); if (!target) return null; + return findSidebarNodeForTarget(target, flatNodes); +} + +export function findSidebarNodeForTarget(target: ActiveTabSidebarTarget, flatNodes: readonly FlatTreeNode[]): FlatTreeNode | null { return flatNodes.find((item) => matchesTarget(item.node, target)) ?? null; } @@ -136,6 +140,10 @@ export function scrollTopForSidebarNode(options: { index: number; currentScrollT export function findNodePathForActiveTab(tab: QueryTab | undefined | null, treeNodes: readonly TreeNode[]): TreeNode[] | null { const target = activeTabSidebarTarget(tab); if (!target) return null; + return findNodePathForTarget(target, treeNodes); +} + +export function findNodePathForTarget(target: ActiveTabSidebarTarget, treeNodes: readonly TreeNode[]): TreeNode[] | null { return findPath(treeNodes, (node) => matchesTarget(node, target)); } diff --git a/packages/app-tests/queryCursorTableTarget.test.ts b/packages/app-tests/queryCursorTableTarget.test.ts new file mode 100644 index 0000000000..cca6cafd70 --- /dev/null +++ b/packages/app-tests/queryCursorTableTarget.test.ts @@ -0,0 +1,97 @@ +import { strict as assert } from "node:assert"; +import { test } from "vitest"; +import { extractQualifiedIdentifierPartsAt, findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate } from "../../apps/desktop/src/lib/queryCursorTableTarget.ts"; +import type { QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function queryTab(sql: string, head: number, schema = "public"): QueryTab { + return { + id: "tab-1", + title: "Query 1", + connectionId: "conn-1", + database: "app", + schema, + sql, + editorSelection: { anchor: head, head }, + isExecuting: false, + mode: "query", + }; +} + +test("extracts the qualified identifier under or after the cursor", () => { + const sql = "select * from public.users"; + assert.deepEqual( + extractQualifiedIdentifierPartsAt(sql, sql.length).map((part) => part.value), + ["public", "users"], + ); + assert.deepEqual( + extractQualifiedIdentifierPartsAt("select * from `sales-db`.`Order`", 29).map((part) => part.value), + ["sales-db", "Order"], + ); + assert.deepEqual( + extractQualifiedIdentifierPartsAt('select * from "public"."order"', 26).map((part) => part.value), + ["public", "order"], + ); +}); + +test("builds schema-aware cursor table candidates", () => { + const tab = queryTab("select * from reporting.users", "select * from reporting.users".length); + + assert.deepEqual(queryCursorTableCandidate(tab, "postgres"), { + connectionId: "conn-1", + database: "app", + schema: "reporting", + tableName: "users", + }); +}); + +test("builds database-qualified candidates for multi-database non-schema engines", () => { + const tab = queryTab("select * from analytics.events", "select * from analytics.events".length, undefined); + + assert.deepEqual(queryCursorTableCandidate(tab, "mysql"), { + connectionId: "conn-1", + database: "analytics", + schema: undefined, + tableName: "events", + }); +}); + +test("falls back to the candidate database and schema when no table is loaded", () => { + const tab = queryTab("select * from reporting.missing", "select * from reporting.missing".length); + const candidate = queryCursorTableCandidate(tab, "postgres"); + + assert.deepEqual(queryContextTargetFromCandidate(tab, candidate), { + type: "query-context", + connectionId: "conn-1", + database: "app", + schema: "reporting", + }); +}); + +test("resolves loaded table targets case-insensitively and keeps actual tree labels", () => { + const nodes: TreeNode[] = [ + { + id: "conn-1", + label: "local", + type: "connection", + connectionId: "conn-1", + children: [ + { + id: "db-app", + label: "app", + type: "database", + connectionId: "conn-1", + database: "app", + children: [{ id: "users", label: "Users", type: "table", connectionId: "conn-1", database: "app", schema: "public" }], + }, + ], + }, + ]; + + assert.deepEqual(findLoadedTableTargetForCandidate(nodes, { connectionId: "conn-1", database: "APP", schema: "PUBLIC", tableName: "users" }), { + type: "table", + connectionId: "conn-1", + database: "app", + schema: "public", + tableName: "Users", + }); +});