fix(sql): inherit active context for new SQL editors

This commit is contained in:
jearhe
2026-10-10 11:09:03 +08:00
committed by GitHub
parent 6eb8242cf7
commit 36f69f4da9
5 changed files with 207 additions and 14 deletions
+9 -13
View File
@@ -490,7 +490,6 @@ const activeOutputView = computed<TabOutputView>({
if (tab) queryStore.updateTabUiState(tab.id, { activeOutputView: view });
},
});
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
const queryEditorObjectSourceTarget = ref<{
connectionId: string;
@@ -1111,7 +1110,6 @@ provide(EDITOR_TOOLBAR_ACTIONS, {
canNewQuery: canCreateNewQuery,
newQuery: (groupId: string) => {
queryStore.focusGroup(groupId);
newQueryContextSource.value = "tab";
void newQuery();
},
explainMode,
@@ -1755,7 +1753,6 @@ watch(
}),
);
}
if (id) newQueryContextSource.value = "tab";
if (id) activateQuerySurface();
else if (previousId) activateOpenSpecialPageFallback();
if (id && pluginCenterActive.value) pluginCenterActive.value = false;
@@ -1855,13 +1852,6 @@ watch(
{ immediate: true },
);
watch(
() => connectionStore.selectedTreeNodeId,
(id) => {
if (id) newQueryContextSource.value = "sidebar";
},
);
watch(
() => settingsStore.editorSettings.uiScale,
(scale) => {
@@ -3098,14 +3088,21 @@ function openConnectionSettings(connectionId: string, initialTab: ConfigTab = "c
}
async function newQuery() {
const sqlConnections = connectionStore.connections.filter((connection) => quickConnectionOpenTarget(connection).kind === "query" && supportsGenericNewQuery(connection));
const connectedSqlConnectionIds = new Set(sqlConnections.filter((connection) => connectionStore.connectedIds.has(connection.id)).map((connection) => connection.id));
let target = resolveNewQueryTarget({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
activeConnectionId: connectionStore.activeConnectionId,
connections: connectionStore.connections,
preferredSource: newQueryContextSource.value,
connectedSqlConnectionIds,
});
if (!target) return;
if (!target) {
// No active editor/sidebar choice and no unique online SQL connection:
// leave the connection unset so the user can choose in the editor toolbar.
if (sqlConnections.length > 0) queryStore.createTab("", "", undefined, "query");
return;
}
let conn = connectionStore.getConfig(target.connectionId);
if (!conn) return;
@@ -3155,7 +3152,6 @@ async function newQuery() {
const initialSql = resolveNewQueryInitialSql({
activeTab: activeTab.value,
selectedTreeNode: findTreeNodeById(connectionStore.treeNodes, connectionStore.selectedTreeNodeId),
preferredSource: newQueryContextSource.value,
prefillEnabled: settingsStore.editorSettings.prefillNewQueryWithSelect,
targetConnectionId: target.connectionId,
targetDatabase: target.database,
@@ -106,6 +106,83 @@ describe("resolveNewQueryTarget", () => {
})?.schema,
).toBe("reporting");
});
it("prefers the active tab over a selected sidebar node when no explicit source is given", () => {
// Every ad-hoc entry (app toolbar, shortcut, welcome screen, editor-group
// "+") resolves through this default order, so a sidebar node that merely
// happens to be selected must not outrank the editor the user is looking at
// (issue #11517).
expect(
resolveNewQueryTarget({
activeTab: { connectionId: "conn-tab", database: "bilibili" },
selectedTreeNode: { connectionId: "conn-tree", database: "reporting" },
connections: [
{ id: "conn-tree", host: "localhost", database: "reporting", db_type: "mysql" },
{ id: "conn-tab", host: "localhost", database: "bilibili", db_type: "mysql" },
],
}),
).toMatchObject({ connectionId: "conn-tab", database: "bilibili" });
});
it("falls back to the single online connection instead of the first configured one", () => {
expect(
resolveNewQueryTarget({
connections: [
{ id: "conn-unused", host: "localhost", database: "unused_db", db_type: "mysql" },
{ id: "conn-online", host: "localhost", database: "online_db", db_type: "mysql" },
],
connectedSqlConnectionIds: new Set(["conn-online"]),
}),
).toMatchObject({ connectionId: "conn-online", database: "online_db", shouldRefreshDefaultDatabase: true });
});
it("leaves the connection unselected when several configured connections are offline", () => {
expect(
resolveNewQueryTarget({
connections: [
{ id: "conn-first", host: "localhost", database: "first_db", db_type: "mysql" },
{ id: "conn-second", host: "localhost", database: "second_db", db_type: "mysql" },
],
connectedSqlConnectionIds: new Set<string>(),
}),
).toBeNull();
});
it("leaves the connection unselected when multiple SQL connections are online", () => {
// An ambiguous online state must not pick a connection on its own (#11517).
expect(
resolveNewQueryTarget({
connections: [
{ id: "conn-first", host: "localhost", database: "first_db", db_type: "mysql" },
{ id: "conn-second", host: "localhost", database: "second_db", db_type: "mysql" },
{ id: "conn-third", host: "localhost", database: "third_db", db_type: "mysql" },
],
connectedSqlConnectionIds: new Set(["conn-second", "conn-third"]),
}),
).toBeNull();
});
it("keeps the sole configured connection as the legacy fallback", () => {
expect(
resolveNewQueryTarget({
connections: [{ id: "conn-only", host: "localhost", database: "only_db", db_type: "mysql" }],
connectedSqlConnectionIds: new Set<string>(),
}),
).toMatchObject({ connectionId: "conn-only", database: "only_db" });
});
it("keeps the last active connection ahead of the single online connection", () => {
expect(
resolveNewQueryTarget({
activeConnectionId: "conn-active",
connections: [
{ id: "conn-active", host: "localhost", database: "active_db", db_type: "mysql" },
{ id: "conn-online", host: "localhost", database: "online_db", db_type: "mysql" },
],
connectedSqlConnectionIds: new Set(["conn-online"]),
}),
).toMatchObject({ connectionId: "conn-active" });
});
});
describe("resolveNewQueryTable", () => {
@@ -0,0 +1,55 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// Regression coverage for https://github.com/t8y2/dbx/issues/11517.
//
// Every ad-hoc "new query" entry (app toolbar, keyboard shortcut, welcome
// screen, editor-group "+") funnels into App.vue's newQuery(). That shared entry
// used to carry a sticky `newQueryContextSource`, which the sidebar selection
// watcher set to "sidebar" while only the active-tab watcher and editor-group
// "+" reset it. The global toolbar and shortcut could therefore use a stale
// sidebar connection instead of the editor the user was looking at.
//
// The precedence itself is unit-tested in newQueryContext.spec.ts; this suite
// pins the App.vue wiring, because App.vue is a large SFC the suite never mounts.
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
function bodyOf(fnSignature: string): string {
const start = appSource.indexOf(fnSignature);
expect(start, `expected to find "${fnSignature}" in App.vue`).toBeGreaterThanOrEqual(0);
const braceStart = appSource.indexOf("{", start);
let depth = 0;
for (let i = braceStart; i < appSource.length; i++) {
if (appSource[i] === "{") depth++;
else if (appSource[i] === "}") {
depth--;
if (depth === 0) return appSource.slice(braceStart, i + 1);
}
}
throw new Error(`unbalanced braces reading body of "${fnSignature}"`);
}
describe("new query entry context wiring", () => {
it("does not keep a sticky context source that can outrank the active tab", () => {
expect(appSource).not.toContain("newQueryContextSource");
});
it("resolves the shared new-query entry from the active tab and unique online SQL connection", () => {
const body = bodyOf("async function newQuery()");
// Without an explicit source the resolver defaults to the active tab first
// and the selected sidebar node second, so all ad-hoc entries agree.
expect(body).not.toContain("preferredSource");
expect(body).toContain('quickConnectionOpenTarget(connection).kind === "query"');
expect(body).toContain("connectedSqlConnectionIds,");
});
it("creates an unbound editor for manual SQL connection selection when the target is ambiguous", () => {
const body = bodyOf("async function newQuery()");
expect(body).toContain("if (sqlConnections.length > 0)");
expect(body).toContain('queryStore.createTab("", "", undefined, "query")');
});
it("keeps the editor-group entry focused on its own group", () => {
expect(bodyOf("newQuery: (groupId: string) =>")).toContain("queryStore.focusGroup(groupId)");
});
});
+17 -1
View File
@@ -19,6 +19,8 @@ interface ResolveNewQueryTargetInput {
activeConnectionId?: string | null;
connections: Pick<ConnectionConfig, "id" | "host" | "database" | "default_schema" | "db_type">[];
preferredSource?: NewQueryContextSource;
/** Ids of currently online SQL connections, used only when there is exactly one. */
connectedSqlConnectionIds?: ReadonlySet<string>;
}
export function findTreeNodeById(nodes: TreeNode[], id: string | null | undefined): TreeNode | null {
@@ -40,7 +42,11 @@ export function resolveNewQueryTarget(input: ResolveNewQueryTargetInput): NewQue
if (secondaryTarget) return secondaryTarget;
const activeConnection = input.activeConnectionId ? input.connections.find((connection) => connection.id === input.activeConnectionId) : undefined;
const fallbackConnection = activeConnection || input.connections[0];
// Preserve an explicit active connection, then use the only online SQL
// connection. A sole configured connection remains a safe legacy fallback;
// when several choices remain, return null so the caller can leave selection
// to the user instead of silently taking connections[0].
const fallbackConnection = activeConnection ?? singleOnlineSqlConnection(input.connections, input.connectedSqlConnectionIds) ?? (input.connections.length === 1 ? input.connections[0] : undefined);
return fallbackConnection
? {
connectionId: fallbackConnection.id,
@@ -51,6 +57,16 @@ export function resolveNewQueryTarget(input: ResolveNewQueryTargetInput): NewQue
: null;
}
/**
* The one online SQL connection, when there is exactly one. An ambiguous or
* empty online set must not be resolved by connection-list order.
*/
function singleOnlineSqlConnection(connections: ResolveNewQueryTargetInput["connections"], connectedSqlConnectionIds?: ReadonlySet<string>): ResolveNewQueryTargetInput["connections"][number] | undefined {
if (!connectedSqlConnectionIds || connectedSqlConnectionIds.size !== 1) return undefined;
const [onlyOnlineId] = connectedSqlConnectionIds;
return connections.find((connection) => connection.id === onlyOnlineId);
}
function targetFromContext(
context: Pick<QueryTab, "connectionId" | "database" | "schema" | "catalog" | "objectBrowser" | "tableMeta"> | Pick<TreeNode, "connectionId" | "database" | "schema" | "catalog"> | undefined,
connections: Pick<ConnectionConfig, "id" | "host" | "database" | "default_schema" | "db_type">[],
+49
View File
@@ -142,3 +142,52 @@ test("new query target repairs stale SQLite file paths without changing attached
assert.equal(stalePathTarget?.database, "main");
assert.equal(attachedAliasTarget?.database, "analytics");
});
// Issue #11517: without a tab or sidebar context the fallback must not treat the
// first *configured* connection as if it were connected, because that silently
// binds the new editor to a database the user may never have opened.
test("new query target falls back to the single online connection, not the first configured one", () => {
const target = resolveNewQueryTarget({
connections: [connection("conn-unused", "unused_db"), connection("conn-online", "online_db")],
connectedSqlConnectionIds: new Set(["conn-online"]),
});
assert.deepEqual(target, {
connectionId: "conn-online",
database: "online_db",
schema: undefined,
shouldRefreshDefaultDatabase: true,
});
});
test("new query target remains unselected when several configured connections are offline", () => {
const target = resolveNewQueryTarget({
connections: [connection("conn-first", "first_db"), connection("conn-second", "second_db")],
connectedSqlConnectionIds: new Set<string>(),
});
assert.equal(target, null);
});
test("new query target remains unselected when several SQL connections are online", () => {
const target = resolveNewQueryTarget({
connections: [connection("conn-first", "first_db"), connection("conn-second", "second_db"), connection("conn-third", "third_db")],
connectedSqlConnectionIds: new Set(["conn-second", "conn-third"]),
});
assert.equal(target, null);
});
test("new query target keeps the sole configured connection as the legacy fallback", () => {
const target = resolveNewQueryTarget({
connections: [connection("conn-only", "only_db")],
connectedSqlConnectionIds: new Set<string>(),
});
assert.deepEqual(target, {
connectionId: "conn-only",
database: "only_db",
schema: undefined,
shouldRefreshDefaultDatabase: true,
});
});