diff --git a/apps/desktop/src/components/connection/ConnectionTreeSelect.vue b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue
new file mode 100644
index 0000000000..f594b9d51b
--- /dev/null
+++ b/apps/desktop/src/components/connection/ConnectionTreeSelect.vue
@@ -0,0 +1,239 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ searchPlaceholder }}
+ (searchText = String(value))" @keydown="handleKeydown" />
+
+
+
+
+
+
+
+
+
+ {{ emptyText }}
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts
new file mode 100644
index 0000000000..2bb638e2b2
--- /dev/null
+++ b/apps/desktop/src/components/connection/__tests__/ConnectionTreeSelect.searchCollapse.spec.ts
@@ -0,0 +1,114 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type App } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
+import type { ConnectionConfig, SidebarLayout } from "@/types/database";
+
+vi.mock("@/components/ui/button", async () => {
+ const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
+ return { Button: createPassthroughStub("Button", "button") };
+});
+
+vi.mock("@/components/ui/popover", async () => {
+ const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness");
+ return {
+ Popover: createPassthroughStub("Popover"),
+ PopoverContent: createPassthroughStub("PopoverContent"),
+ PopoverTrigger: createPassthroughStub("PopoverTrigger"),
+ };
+});
+
+vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
+ default: defineComponent({
+ name: "DatabaseIconStub",
+ setup: () => () => h("span"),
+ }),
+}));
+
+const mountedApps: App[] = [];
+
+const connections: ConnectionConfig[] = [
+ {
+ id: "c1",
+ name: "Primary",
+ db_type: "mysql",
+ host: "localhost",
+ port: 3306,
+ username: "root",
+ password: "",
+ },
+];
+
+const layout: SidebarLayout = {
+ groups: [{ id: "g1", name: "Production", collapsed: false }],
+ order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
+};
+
+async function mountPicker() {
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(ConnectionTreeSelect, {
+ modelValue: "",
+ connections,
+ layout,
+ placeholder: "Select connection",
+ searchPlaceholder: "Search connections",
+ emptyText: "No connections",
+ });
+ mountedApps.push(app);
+ app.mount(container);
+ await nextTick();
+}
+
+async function setSearchText(value: string) {
+ const input = document.body.querySelector("input");
+ if (!(input instanceof HTMLInputElement)) throw new Error("Search input was not rendered");
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ await nextTick();
+ await nextTick();
+}
+
+function groupButton() {
+ const button = document.body.querySelector('[data-picker-group="g1"]');
+ if (!(button instanceof HTMLButtonElement)) throw new Error("Group button was not rendered");
+ return button;
+}
+
+afterEach(() => {
+ for (const app of mountedApps.splice(0)) app.unmount();
+ document.body.innerHTML = "";
+});
+
+describe("ConnectionTreeSelect search collapse behavior", () => {
+ it("ignores group clicks during search without changing the post-search state", async () => {
+ await mountPicker();
+ await setSearchText("Primary");
+
+ expect(groupButton().disabled).toBe(true);
+ expect(groupButton().getAttribute("aria-expanded")).toBe("true");
+
+ groupButton().dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await nextTick();
+ await setSearchText("");
+
+ expect(groupButton().disabled).toBe(false);
+ expect(groupButton().getAttribute("aria-expanded")).toBe("true");
+ expect(document.body.querySelector('[data-picker-connection="c1"]')).not.toBeNull();
+ });
+
+ it("preserves group collapsing when search is inactive", async () => {
+ await mountPicker();
+
+ groupButton().click();
+ await nextTick();
+ expect(groupButton().getAttribute("aria-expanded")).toBe("false");
+ expect(document.body.querySelector('[data-picker-connection="c1"]')).toBeNull();
+
+ groupButton().click();
+ await nextTick();
+ expect(groupButton().getAttribute("aria-expanded")).toBe("true");
+ expect(document.body.querySelector('[data-picker-connection="c1"]')).not.toBeNull();
+ });
+});
diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue
index 344f85557d..4ac429ece8 100644
--- a/apps/desktop/src/components/layout/EditorToolbar.vue
+++ b/apps/desktop/src/components/layout/EditorToolbar.vue
@@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
+import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -14,8 +15,6 @@ import { catalogDatabaseOptionsKey, databaseAfterCatalogChange, normalizedQueryT
import { useSchemaOptions } from "@/composables/useSchemaOptions";
import { connectionIconType } from "@/lib/connection/connectionPresentation";
import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDatabase";
-import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
-import { useConnectionGroupLabel } from "@/composables/useConnectionGroupLabel";
import { isSingleDatabase, supportsClearableQuerySchema, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { supportsQueryExecution } from "@/lib/database/databaseFeatureSupport";
import { connectionIsDorisFamilyCatalogCapable } from "@/lib/database/databaseFeatureSupport";
@@ -91,8 +90,6 @@ const loadingActiveDatabaseOptions = computed(() => {
});
const switchingCatalog = ref(false);
-const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
-const { connectionGroupLabel } = useConnectionGroupLabel();
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
@@ -225,10 +222,6 @@ function databaseDisplayName(database: string): string {
});
}
-function connectionById(connectionId: string): ConnectionConfig | undefined {
- return connectionStore.getConfig(connectionId);
-}
-
function databaseOptionIsProduction(database: string): boolean {
if (!database || props.activeConnection?.is_production) return false;
return productionContextForDatabase(props.activeConnection, database).reason === "database";
@@ -427,19 +420,16 @@ async function changeCatalog(selectedCatalog: string) {
- emit('changeConnection', connectionId)"
>
@@ -450,18 +440,7 @@ async function changeCatalog(selectedCatalog: string) {
{{ t("editor.selectConnection") }}
-
-
-
-
-
- {{ connectionGroupLabel(option) }}
-
-
-
-
-
-
+
`${row.kind}:${row.id}@${row.depth}`);
+}
+
+describe("buildConnectionPickerRows", () => {
+ it("flattens the layout tree with depths in sidebar order", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(), "");
+ expect(kinds(rows)).toEqual(["group:g1@0", "group:g2@1", "connection:c1@2", "connection:c2@1", "connection:c3@0", "connection:c4@0"]);
+ });
+
+ it("appends connections missing from the layout at the top level", () => {
+ const rows = buildConnectionPickerRows({ groups: [], order: [] }, connections, new Set(), "");
+ expect(kinds(rows)).toEqual(["connection:c1@0", "connection:c2@0", "connection:c3@0", "connection:c4@0"]);
+ });
+
+ it("hides children of collapsed groups without dropping them from the fallback", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(["g1"]), "");
+ expect(kinds(rows)).toEqual(["group:g1@0", "connection:c3@0", "connection:c4@0"]);
+ expect(rows[0]?.collapsed).toBe(true);
+ });
+
+ it("keeps deeper groups visible when only a nested group is collapsed", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(["g2"]), "");
+ expect(kinds(rows)).toEqual(["group:g1@0", "group:g2@1", "connection:c2@1", "connection:c3@0", "connection:c4@0"]);
+ });
+
+ it("skips stale layout connection ids that no longer exist", () => {
+ const stale: SidebarLayout = { groups: [], order: [{ type: "connection", id: "gone" }] };
+ const rows = buildConnectionPickerRows(stale, connections, new Set(), "");
+ expect(kinds(rows)).toEqual(["connection:c1@0", "connection:c2@0", "connection:c3@0", "connection:c4@0"]);
+ });
+
+ it("filters connections by name and keeps ancestor groups expanded", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(["g1", "g2"]), "本地");
+ expect(kinds(rows)).toEqual(["connection:c3@0"]);
+ });
+
+ it("matches connections by group path and keeps the hierarchy", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(), "华东");
+ expect(kinds(rows)).toEqual(["group:g1@0", "group:g2@1", "connection:c1@2"]);
+ expect(rows.every((row) => !row.collapsed)).toBe(true);
+ });
+
+ it("matches fallback connections by name during search", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(), "孤儿");
+ expect(kinds(rows)).toEqual(["connection:c4@0"]);
+ });
+
+ it("returns no rows when nothing matches the query", () => {
+ expect(buildConnectionPickerRows(layout(), connections, new Set(), "不存在")).toEqual([]);
+ });
+
+ it("falls back to the connection id when the name is empty", () => {
+ const rows = buildConnectionPickerRows({ groups: [], order: [] }, [{ id: "c9", name: "" }], new Set(), "");
+ expect(rows[0]?.label).toBe("c9");
+ });
+});
+
+describe("connectionPickerSelectableRows", () => {
+ it("keeps only connection rows for keyboard navigation", () => {
+ const rows = buildConnectionPickerRows(layout(), connections, new Set(), "");
+ expect(connectionPickerSelectableRows(rows).map((row) => row.id)).toEqual(["c1", "c2", "c3", "c4"]);
+ });
+});
diff --git a/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts b/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts
index 8b3942cb6c..3bc1bfa2de 100644
--- a/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts
+++ b/apps/desktop/src/lib/common/__tests__/searchableSelectLayout.spec.ts
@@ -20,10 +20,9 @@ describe("SearchableSelect layout", () => {
expect(fixedConnectionIcons).toHaveLength(2);
});
- it("wraps deep connection group paths without hiding connection names", () => {
- expect(editorToolbarSource).toContain('item-class="min-h-9 h-auto py-1"');
- expect(editorToolbarSource).toMatch(/\s*\{\{ connectionGroupLabel\(option\) \}\}\s*<\/span>/);
- expect(editorToolbarSource).toContain(' {
+ expect(editorToolbarSource).toContain("): SidebarOrderEntry[] {
+ return entry.children ?? entry.connectionIds?.map((id) => ({ type: "connection" as const, id })) ?? [];
+}
+
+function collectLayoutConnectionIds(entries: SidebarOrderEntry[], into: Set) {
+ for (const entry of entries) {
+ if (entry.type === "connection") {
+ into.add(entry.id);
+ } else {
+ collectLayoutConnectionIds(groupEntryChildren(entry), into);
+ }
+ }
+}
+
+function connectionMatchesQuery(name: string, groupPath: string[], normalizedQuery: string): boolean {
+ if (name.toLowerCase().includes(normalizedQuery)) return true;
+ return groupPath.join(" / ").toLowerCase().includes(normalizedQuery);
+}
+
+/**
+ * Flattens the sidebar layout into renderable rows for the toolbar connection
+ * picker. Groups are shown as non-selectable rows that mirror the sidebar tree;
+ * connections missing from the layout are appended at the top level so they
+ * stay reachable. With a search query the tree is fully expanded and only
+ * connections matching by name or group path (plus their ancestor groups) are
+ * kept.
+ */
+export function buildConnectionPickerRows(layout: SidebarLayout, connections: readonly ConnectionPickerConnection[], collapsedGroupIds: ReadonlySet, searchQuery: string): ConnectionPickerRow[] {
+ const connectionById = new Map(connections.map((connection) => [connection.id, connection]));
+ const groupById = new Map(layout.groups.map((group) => [group.id, group]));
+ const normalizedQuery = searchQuery.trim().toLowerCase();
+
+ if (normalizedQuery) {
+ const rows: ConnectionPickerRow[] = [];
+ const visitSearch = (entries: SidebarOrderEntry[], depth: number, groupPath: string[]): ConnectionPickerRow[] => {
+ const result: ConnectionPickerRow[] = [];
+ for (const entry of entries) {
+ if (entry.type === "connection") {
+ const connection = connectionById.get(entry.id);
+ if (!connection) continue;
+ const label = connection.name || connection.id;
+ if (connectionMatchesQuery(label, groupPath, normalizedQuery)) {
+ result.push({ key: `connection:${connection.id}`, kind: "connection", id: connection.id, label, depth, collapsed: false });
+ }
+ continue;
+ }
+ const group = groupById.get(entry.id);
+ if (!group) continue;
+ const childRows = visitSearch(groupEntryChildren(entry), depth + 1, [...groupPath, group.name]);
+ if (childRows.length === 0) continue;
+ result.push({ key: `group:${group.id}`, kind: "group", id: group.id, label: group.name, depth, collapsed: false }, ...childRows);
+ }
+ return result;
+ };
+ rows.push(...visitSearch(layout.order, 0, []));
+
+ const layoutConnectionIds = new Set();
+ collectLayoutConnectionIds(layout.order, layoutConnectionIds);
+ for (const connection of connections) {
+ if (layoutConnectionIds.has(connection.id)) continue;
+ const label = connection.name || connection.id;
+ if (connectionMatchesQuery(label, [], normalizedQuery)) {
+ rows.push({ key: `connection:${connection.id}`, kind: "connection", id: connection.id, label, depth: 0, collapsed: false });
+ }
+ }
+ return rows;
+ }
+
+ const rows: ConnectionPickerRow[] = [];
+ const visitTree = (entries: SidebarOrderEntry[], depth: number) => {
+ for (const entry of entries) {
+ if (entry.type === "connection") {
+ const connection = connectionById.get(entry.id);
+ if (!connection) continue;
+ rows.push({ key: `connection:${connection.id}`, kind: "connection", id: connection.id, label: connection.name || connection.id, depth, collapsed: false });
+ continue;
+ }
+ const group = groupById.get(entry.id);
+ if (!group) continue;
+ const collapsed = collapsedGroupIds.has(group.id);
+ rows.push({ key: `group:${group.id}`, kind: "group", id: group.id, label: group.name, depth, collapsed });
+ if (!collapsed) visitTree(groupEntryChildren(entry), depth + 1);
+ }
+ };
+ visitTree(layout.order, 0);
+
+ const layoutConnectionIds = new Set();
+ collectLayoutConnectionIds(layout.order, layoutConnectionIds);
+ for (const connection of connections) {
+ if (layoutConnectionIds.has(connection.id)) continue;
+ rows.push({ key: `connection:${connection.id}`, kind: "connection", id: connection.id, label: connection.name || connection.id, depth: 0, collapsed: false });
+ }
+ return rows;
+}
+
+/** Selectable rows in display order, used for keyboard navigation. */
+export function connectionPickerSelectableRows(rows: readonly ConnectionPickerRow[]): ConnectionPickerRow[] {
+ return rows.filter((row) => row.kind === "connection");
+}