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 @@ + + + + + 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) {
- - - +
`${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"); +}