diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index b2630ec7d..773e35a46 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -29,6 +29,7 @@ import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; +import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext"; import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; import { resolveExecutableSql } from "@/lib/sqlExecutionTarget"; import { isTauriRuntime } from "@/lib/tauriRuntime"; @@ -114,6 +115,7 @@ const selectedSql = ref(""); const cursorPos = ref(0); const formatSqlRequestId = ref(0); const activeOutputView = ref<"result" | "explain" | "chart">("result"); +const newQueryContextSource = ref<"tab" | "sidebar">("tab"); const showSaveSqlDialog = ref(false); const saveSqlName = ref(""); const saveSqlFolderId = ref(""); @@ -206,6 +208,7 @@ const saveSqlFolders = computed(() => { watch( () => queryStore.activeTabId, (id) => { + if (id) newQueryContextSource.value = "tab"; selectedSql.value = ""; activeOutputView.value = "result"; showDriverStore.value = false; @@ -213,6 +216,13 @@ watch( }, ); +watch( + () => connectionStore.selectedTreeNodeId, + (id) => { + if (id) newQueryContextSource.value = "sidebar"; + }, +); + function toggleAiPanel() { showAiPanel.value = !showAiPanel.value; localStorage.setItem("dbx-ai-panel-open", String(showAiPanel.value)); @@ -435,16 +445,24 @@ function setConnectionDialogOpen(value: boolean) { } async function newQuery() { - const connId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id; - if (!connId) return; - const conn = connectionStore.getConfig(connId); + const target = resolveNewQueryTarget({ + activeTab: activeTab.value, + selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId), + activeConnectionId: connectionStore.activeConnectionId, + connections: connectionStore.connections, + preferredSource: newQueryContextSource.value, + }); + if (!target) return; + const conn = connectionStore.getConfig(target.connectionId); if (!conn) return; - connectionStore.activeConnectionId = connId; - const tabId = queryStore.createTab(conn.id, resolveDefaultDatabase(conn, [])); + connectionStore.activeConnectionId = target.connectionId; + const tabId = queryStore.createTab(conn.id, target.database); try { - await connectionStore.ensureConnected(connId); - const options = await getDatabaseOptions(connId); - queryStore.updateDatabase(tabId, resolveDefaultDatabase(conn, options)); + await connectionStore.ensureConnected(target.connectionId); + if (target.shouldRefreshDefaultDatabase) { + const options = await getDatabaseOptions(target.connectionId); + queryStore.updateDatabase(tabId, resolveDefaultDatabase(conn, options)); + } } catch (e: any) { toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); } diff --git a/apps/desktop/src/lib/newQueryContext.ts b/apps/desktop/src/lib/newQueryContext.ts new file mode 100644 index 000000000..fc7bf8722 --- /dev/null +++ b/apps/desktop/src/lib/newQueryContext.ts @@ -0,0 +1,64 @@ +import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; +import type { ConnectionConfig, QueryTab, TreeNode } from "@/types/database"; + +export interface NewQueryTarget { + connectionId: string; + database: string; + shouldRefreshDefaultDatabase: boolean; +} + +export type NewQueryContextSource = "tab" | "sidebar"; + +interface ResolveNewQueryTargetInput { + activeTab?: Pick; + selectedTreeNode?: Pick | null; + activeConnectionId?: string | null; + connections: Pick[]; + preferredSource?: NewQueryContextSource; +} + +export function findTreeNodeById(nodes: TreeNode[], id: string | null | undefined): TreeNode | null { + if (!id) return null; + for (const node of nodes) { + if (node.id === id) return node; + const found = findTreeNodeById(node.children || [], id); + if (found) return found; + } + return null; +} + +export function resolveNewQueryTarget(input: ResolveNewQueryTargetInput): NewQueryTarget | null { + const primaryContext = input.preferredSource === "sidebar" ? input.selectedTreeNode || undefined : input.activeTab; + const secondaryContext = input.preferredSource === "sidebar" ? input.activeTab : input.selectedTreeNode || undefined; + const primaryTarget = targetFromContext(primaryContext, input.connections); + if (primaryTarget) return primaryTarget; + const secondaryTarget = targetFromContext(secondaryContext, input.connections); + if (secondaryTarget) return secondaryTarget; + + const activeConnection = input.activeConnectionId + ? input.connections.find((connection) => connection.id === input.activeConnectionId) + : undefined; + const fallbackConnection = activeConnection || input.connections[0]; + return fallbackConnection + ? { + connectionId: fallbackConnection.id, + database: resolveDefaultDatabase(fallbackConnection, []), + shouldRefreshDefaultDatabase: true, + } + : null; +} + +function targetFromContext( + context: Pick | undefined, + connections: Pick[], +): NewQueryTarget | null { + if (!context?.connectionId) return null; + const connection = connections.find((item) => item.id === context.connectionId); + if (!connection) return null; + const database = context.database || resolveDefaultDatabase(connection, []); + return { + connectionId: context.connectionId, + database, + shouldRefreshDefaultDatabase: !context.database, + }; +} diff --git a/packages/app-tests/newQueryContext.test.ts b/packages/app-tests/newQueryContext.test.ts new file mode 100644 index 000000000..bcaf76a4e --- /dev/null +++ b/packages/app-tests/newQueryContext.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { resolveNewQueryTarget } from "../../apps/desktop/src/lib/newQueryContext.ts"; +import type { ConnectionConfig, QueryTab, TreeNode } from "../../apps/desktop/src/types/database.ts"; + +function connection(id: string, database = ""): ConnectionConfig { + return { + id, + name: id, + db_type: "postgres", + host: "localhost", + port: 5432, + username: "postgres", + password: "", + database, + }; +} + +function queryTab(connectionId: string, database: string, mode: QueryTab["mode"] = "data"): QueryTab { + return { + id: `${connectionId}-${database}`, + title: "users", + connectionId, + database, + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode, + }; +} + +test("new query target prefers the active data tab context", () => { + const target = resolveNewQueryTarget({ + activeTab: queryTab("conn-data", "analytics", "data"), + selectedTreeNode: { + id: "conn-tree:reporting", + label: "reporting", + type: "database", + connectionId: "conn-tree", + database: "reporting", + }, + activeConnectionId: "conn-active", + connections: [connection("conn-active"), connection("conn-data"), connection("conn-tree")], + }); + + assert.deepEqual(target, { + connectionId: "conn-data", + database: "analytics", + shouldRefreshDefaultDatabase: false, + }); +}); + +test("new query target uses the selected sidebar node when there is no active tab", () => { + const selectedTreeNode: TreeNode = { + id: "conn-tree:reporting:public:users", + label: "users", + type: "table", + connectionId: "conn-tree", + database: "reporting", + schema: "public", + }; + + const target = resolveNewQueryTarget({ + activeTab: undefined, + selectedTreeNode, + activeConnectionId: "conn-active", + connections: [connection("conn-active"), connection("conn-tree")], + }); + + assert.deepEqual(target, { + connectionId: "conn-tree", + database: "reporting", + shouldRefreshDefaultDatabase: false, + }); +}); + +test("new query target prefers the selected sidebar node after sidebar focus", () => { + const target = resolveNewQueryTarget({ + activeTab: queryTab("conn-data", "analytics", "data"), + selectedTreeNode: { + id: "conn-tree:reporting:public:users", + label: "users", + type: "table", + connectionId: "conn-tree", + database: "reporting", + }, + activeConnectionId: "conn-active", + connections: [connection("conn-active"), connection("conn-data"), connection("conn-tree")], + preferredSource: "sidebar", + }); + + assert.deepEqual(target, { + connectionId: "conn-tree", + database: "reporting", + shouldRefreshDefaultDatabase: false, + }); +}); + +test("new query target refreshes default database for connection-only sidebar nodes", () => { + const target = resolveNewQueryTarget({ + activeTab: undefined, + selectedTreeNode: { id: "conn-tree", label: "conn-tree", type: "connection", connectionId: "conn-tree" }, + activeConnectionId: "conn-active", + connections: [connection("conn-active"), connection("conn-tree", "saved_default")], + }); + + assert.deepEqual(target, { + connectionId: "conn-tree", + database: "saved_default", + shouldRefreshDefaultDatabase: true, + }); +});