diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 21a1bacc15..499461acb9 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -668,6 +668,19 @@ const filteredNodes = computed(() => { return nodes; }); +const projectedConnectionIds = computed | null>(() => { + if (!isRootListPartial.value && !deferredSearchQuery.value) return null; + const connectionIds = new Set(); + const visit = (nodes: readonly TreeNode[]) => { + for (const node of nodes) { + if (node.type === "connection" && node.connectionId) connectionIds.add(node.connectionId); + if (node.children?.length) visit(node.children); + } + }; + visit(filteredNodes.value); + return connectionIds; +}); + const flatNodes = computed(() => insertSidebarTableSearchControls(flattenTree(filteredNodes.value), { enabled: settingsStore.editorSettings.sidebarTableSearchEnabled && !isTreeSearchFiltering.value, @@ -1124,6 +1137,7 @@ const pasteHandlerRegistry = createSidebarPasteHandlerRegistry(); provide(sidebarTreeContextKey, { getVisibleNodes: () => selectableVisibleNodes.value, getVisibleNodeIndex: (id: string) => selectableVisibleNodeIndexById.value.get(id) ?? -1, + getProjectedConnectionIds: () => projectedConnectionIds.value, // Cover both sides of the input debounce: the immediate query prevents a // collapse while a projection is about to start, and the deferred query // keeps the currently rendered projection alive while clearing settles. diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index e92f9e5927..05f2f6cc48 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -34,6 +34,7 @@ import { Lock, Archive, Square, + Minus, X, CircleX, RefreshCw, @@ -61,6 +62,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay"; import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip"; import { filterSidebarModifierSelectionIds, supportsSidebarModifierSelection, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection"; import { applyConnectionMultiSelection, applyTreeNodeSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect"; +import { connectionBearingGroupIdsUnder, connectionIdsUnderGroup } from "@/lib/sidebar/sidebarLayout"; import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState"; import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu"; @@ -645,37 +647,53 @@ function toggleConnectionMultiSelection(event: MouseEvent) { rowRef.value?.focus({ preventScroll: true }); } -function selectedConnectionGroupIdsForAction(): string[] { - const groupIds = connectionGroupIdsForSelection(); - return connectionStore.selectedTreeNodeIds.filter((id) => groupIds.has(id)); +function connectionIdsForActiveGroupSelection(): string[] { + if (activeNode.value.type !== "connection-group") return []; + const groupConnectionIds = connectionIdsUnderGroup(connectionStore.sidebarLayout, activeNode.value.id); + const projectedConnectionIds = sidebarTreeContext?.getProjectedConnectionIds?.(); + return projectedConnectionIds ? groupConnectionIds.filter((id) => projectedConnectionIds.has(id)) : groupConnectionIds; } -function isConnectionGroupSelectionChecked(): boolean { - if (!isConnectionGroupMultiSelectActive() || activeNode.value.type !== "connection-group") return false; - return connectionStore.selectedTreeNodeIds.includes(activeNode.value.id); -} +const connectionGroupSelectionState = computed<"none" | "partial" | "all">(() => { + const groupConnectionIds = connectionIdsForActiveGroupSelection(); + if (groupConnectionIds.length === 0) return "none"; + const selectedIds = connectionStore.selectedTreeNodeIdsSet; + const selectedCount = groupConnectionIds.filter((id) => selectedIds.has(id)).length; + if (selectedCount === 0) return "none"; + if (selectedCount === groupConnectionIds.length) return "all"; + return "partial"; +}); function toggleConnectionGroupMultiSelection(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); if (activeNode.value.type !== "connection-group") return; - const selectedGroupIds = connectionStore.connectionMultiSelectActive ? selectedConnectionGroupIdsForAction() : []; - const nextGroupIds = new Set(selectedGroupIds); - if (nextGroupIds.has(activeNode.value.id)) nextGroupIds.delete(activeNode.value.id); - else nextGroupIds.add(activeNode.value.id); - const nodeIds = [...nextGroupIds]; - const activeNodeId = nextGroupIds.has(activeNode.value.id) ? activeNode.value.id : (nodeIds[0] ?? null); - applyTreeNodeSelection( - connectionStore, - { - nodeIds, - activeNodeId, - anchorNodeId: activeNodeId, - }, - connectionIdsForSelection(), - connectionGroupIdsForSelection(), - ); + const groupConnectionIds = connectionIdsForActiveGroupSelection(); + if (groupConnectionIds.length === 0) return; + + // 当前是分组多选(Ctrl/Shift 框选分组行)时,改为级联选中连接并重新开始 + const baseConnectionIds = isConnectionMultiSelectActive() ? selectedConnectionIdsForAction() : []; + const next = new Set(baseConnectionIds); + const allSelected = groupConnectionIds.every((id) => next.has(id)); + if (allSelected) { + // 分组下连接已全部勾选:取消勾选这些连接,保留其他已勾选的连接 + for (const id of groupConnectionIds) next.delete(id); + } else { + // 分组下连接未全部勾选:级联勾选全部连接,并保留其他已勾选的连接 + for (const id of groupConnectionIds) next.add(id); + // 自动展开该分组及含连接的子分组:折叠状态下选中的连接不可见, + // 会被树的选择修剪逻辑清空,展开后选择才能保留并可被右键操作 + connectionStore.expandConnectionGroups(connectionBearingGroupIdsUnder(connectionStore.sidebarLayout, activeNode.value.id)); + } + + const connectionIds = [...next]; + applyConnectionMultiSelection(connectionStore, { + connectionIds, + activeConnectionId: connectionIds[0] ?? null, + anchorConnectionId: connectionIds[0] ?? null, + active: connectionIds.length > 0, + }); rowRef.value?.focus({ preventScroll: true }); } @@ -1424,7 +1442,7 @@ function onKeydown(event: KeyboardEvent) { - {{ t("connection.readOnlyBadge") }} + {{ t("connection.readOnlyBadge") }} {{ formattedObjectStorage() }} @@ -1586,6 +1607,7 @@ function onKeydown(event: KeyboardEvent) { .tree-item-active { background-color: var(--tree-connection-active-bg, rgb(235 235 235)) !important; } + :root.dark .tree-item-active { background-color: var(--tree-connection-active-bg, rgb(36 36 36)) !important; } @@ -1594,6 +1616,7 @@ function onKeydown(event: KeyboardEvent) { .tree-item-active:focus { background-color: var(--tree-connection-active-focus-bg, rgb(211 227 245)) !important; } + :root.dark .tree-item-active:focus { background-color: var(--tree-connection-active-focus-bg, rgb(33 60 89)) !important; } @@ -1603,6 +1626,7 @@ function onKeydown(event: KeyboardEvent) { background-color: var(--tree-connection-active-bg, rgb(235 235 235)) !important; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 14%, transparent); } + :root.dark .tree-item-active--selection-set:focus { background-color: var(--tree-connection-active-bg, rgb(36 36 36)) !important; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 18%, transparent); diff --git a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts index 4be0d3905c..f68731e4d9 100644 --- a/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts +++ b/apps/desktop/src/components/sidebar/__tests__/TreeItem.loadMoreActivation.spec.ts @@ -4,8 +4,9 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; import i18n from "@/i18n"; import TreeItem from "@/components/sidebar/TreeItem.vue"; -import { filterSidebarTree } from "@/lib/sidebar/sidebarSearchTree"; +import { filterSidebarTree, filterSidebarTreeToConnectedConnections } from "@/lib/sidebar/sidebarSearchTree"; import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHost } from "@/lib/sidebar/sidebarTreeRuntime"; +import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext"; import type { SidebarLayout, TreeNode } from "@/types/database"; const connectionStore = { @@ -14,7 +15,8 @@ const connectionStore = { connectingIds: new Set(), connectionErrors: {}, connectionMultiSelectActive: false, - connections: [], + connections: [] as { id: string }[], + expandConnectionGroups: vi.fn(), getConfig: () => ({ id: "connection-1", db_type: "sqlserver" }), getSidebarVisibleFilterSummary: () => null, clearConnectionError: vi.fn(), @@ -74,7 +76,7 @@ function runtimeHost(): SidebarTreeRuntimeHost { }; } -async function mountTreeItem(node: TreeNode) { +async function mountTreeItem(node: TreeNode, options: { projectedConnectionIds?: ReadonlySet | null } = {}) { const container = document.createElement("div"); document.body.append(container); const runtime = createSidebarTreeRuntime(); @@ -88,6 +90,13 @@ async function mountTreeItem(node: TreeNode) { mountedApps.push(app); app.use(i18n); app.provide(sidebarTreeRuntimeKey, runtime); + if (options.projectedConnectionIds !== undefined) { + app.provide(sidebarTreeContextKey, { + getVisibleNodes: () => [node], + getVisibleNodeIndex: (id) => (id === node.id ? 0 : -1), + getProjectedConnectionIds: () => options.projectedConnectionIds ?? null, + }); + } app.mount(container); await nextTick(); @@ -96,6 +105,18 @@ async function mountTreeItem(node: TreeNode) { return { row, host }; } +function connectionIdsInProjection(nodes: readonly TreeNode[]): Set { + const connectionIds = new Set(); + const visit = (currentNodes: readonly TreeNode[]) => { + for (const node of currentNodes) { + if (node.type === "connection" && node.connectionId) connectionIds.add(node.connectionId); + if (node.children?.length) visit(node.children); + } + }; + visit(nodes); + return connectionIds; +} + afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; @@ -103,6 +124,10 @@ afterEach(() => { connectionStore.selectedTreeNodeIds = []; connectionStore.selectedTreeNodeIdsSet = new Set(); connectionStore.connectionMultiSelectActive = false; + // 重置测试中修改的连接列表,避免残留数据影响后续用例的选择过滤 + connectionStore.connections = []; + // 清空分组展开的调用记录,避免上一个用例的调用历史污染断言 + connectionStore.expandConnectionGroups.mockClear(); connectionStore.sidebarLayout = { groups: [], order: [] }; connectionStore.treeNodes = []; connectionStore.treeSelectionAnchorId = null; @@ -111,7 +136,43 @@ afterEach(() => { }); describe("TreeItem load-more activation", () => { - it("toggles a connection group through its row checkbox", async () => { + it("cascades a connection group checkbox to its connections", async () => { + const group: TreeNode = { + id: "group-1", + label: "Group 1", + type: "connection-group", + children: [], + }; + connectionStore.treeNodes = [group]; + // 分组下挂一个连接,勾选框会级联选中该连接 + connectionStore.sidebarLayout = { + groups: [{ id: group.id, name: group.label, collapsed: false }], + order: [{ type: "group", id: group.id, children: [{ type: "connection", id: "connection-2" }] }], + }; + connectionStore.connections = [{ id: "connection-2" }]; + const { row } = await mountTreeItem(group); + const toggle = row.querySelector('[data-sidebar-group-selection-toggle="true"]'); + expect(toggle).not.toBeNull(); + + toggle?.click(); + + // 勾选后级联选中分组下的连接,并自动展开该分组 + expect(connectionStore.selectedTreeNodeIds).toEqual(["connection-2"]); + expect(connectionStore.selectedTreeNodeId).toBe("connection-2"); + expect(connectionStore.treeSelectionAnchorId).toBe("connection-2"); + expect(connectionStore.connectionMultiSelectActive).toBe(true); + expect(connectionStore.expandConnectionGroups).toHaveBeenCalledWith([group.id]); + + toggle?.click(); + + // 再次点击取消勾选分组下的全部连接 + expect(connectionStore.selectedTreeNodeIds).toEqual([]); + expect(connectionStore.selectedTreeNodeId).toBeNull(); + expect(connectionStore.treeSelectionAnchorId).toBeNull(); + expect(connectionStore.connectionMultiSelectActive).toBe(false); + }); + + it("ignores the checkbox of an empty connection group", async () => { const group: TreeNode = { id: "group-1", label: "Group 1", @@ -129,17 +190,134 @@ describe("TreeItem load-more activation", () => { toggle?.click(); - expect(connectionStore.selectedTreeNodeIds).toEqual([group.id]); - expect(connectionStore.selectedTreeNodeId).toBe(group.id); - expect(connectionStore.treeSelectionAnchorId).toBe(group.id); - expect(connectionStore.connectionMultiSelectActive).toBe(true); - - toggle?.click(); - + // 空分组没有可级联的连接,点击不产生任何选择变化 expect(connectionStore.selectedTreeNodeIds).toEqual([]); - expect(connectionStore.selectedTreeNodeId).toBeNull(); - expect(connectionStore.treeSelectionAnchorId).toBeNull(); expect(connectionStore.connectionMultiSelectActive).toBe(false); + expect(connectionStore.expandConnectionGroups).not.toHaveBeenCalled(); + }); + + it("limits a searched group cascade to connections in the current projection", async () => { + const firstConnection: TreeNode = { + id: "connection-1", + label: "Alpha", + type: "connection", + connectionId: "connection-1", + }; + const secondConnection: TreeNode = { + id: "connection-2", + label: "Beta", + type: "connection", + connectionId: "connection-2", + }; + const group: TreeNode = { + id: "group-1", + label: "Group 1", + type: "connection-group", + isExpanded: true, + children: [firstConnection, secondConnection], + }; + connectionStore.treeNodes = [group]; + connectionStore.sidebarLayout = { + groups: [{ id: group.id, name: group.label, collapsed: false }], + order: [ + { + type: "group", + id: group.id, + children: [ + { type: "connection", id: firstConnection.id }, + { type: "connection", id: secondConnection.id }, + ], + }, + ], + }; + connectionStore.connections = [{ id: firstConnection.id }, { id: secondConnection.id }]; + const projection = connectionIdsInProjection(filterSidebarTree([group], "alpha", new Set())); + const { row } = await mountTreeItem(group, { projectedConnectionIds: projection }); + + row.querySelector('[data-sidebar-group-selection-toggle="true"]')?.click(); + + expect(connectionStore.selectedTreeNodeIds).toEqual([firstConnection.id]); + expect(connectionStore.selectedTreeNodeIds).not.toContain(secondConnection.id); + }); + + it("limits a connected-only group cascade to connected connections", async () => { + const connected: TreeNode = { + id: "connection-1", + label: "Connected", + type: "connection", + connectionId: "connection-1", + }; + const disconnected: TreeNode = { + id: "connection-2", + label: "Disconnected", + type: "connection", + connectionId: "connection-2", + }; + const group: TreeNode = { + id: "group-1", + label: "Group 1", + type: "connection-group", + isExpanded: true, + children: [connected, disconnected], + }; + connectionStore.treeNodes = [group]; + connectionStore.sidebarLayout = { + groups: [{ id: group.id, name: group.label, collapsed: false }], + order: [ + { + type: "group", + id: group.id, + children: [ + { type: "connection", id: connected.id }, + { type: "connection", id: disconnected.id }, + ], + }, + ], + }; + connectionStore.connections = [{ id: connected.id }, { id: disconnected.id }]; + const projection = connectionIdsInProjection(filterSidebarTreeToConnectedConnections([group], new Set([connected.id]))); + const { row } = await mountTreeItem(group, { projectedConnectionIds: projection }); + + row.querySelector('[data-sidebar-group-selection-toggle="true"]')?.click(); + + expect(connectionStore.selectedTreeNodeIds).toEqual([connected.id]); + expect(connectionStore.selectedTreeNodeIds).not.toContain(disconnected.id); + }); + + it("exposes none, mixed, and all group selection states", async () => { + const group: TreeNode = { + id: "group-1", + label: "Group 1", + type: "connection-group", + }; + connectionStore.sidebarLayout = { + groups: [{ id: group.id, name: group.label, collapsed: false }], + order: [ + { + type: "group", + id: group.id, + children: [ + { type: "connection", id: "connection-1" }, + { type: "connection", id: "connection-2" }, + ], + }, + ], + }; + + const none = (await mountTreeItem(group)).row.querySelector('[data-sidebar-group-selection-toggle="true"]'); + expect(none?.getAttribute("role")).toBe("checkbox"); + expect(none?.getAttribute("aria-checked")).toBe("false"); + expect(none?.getAttribute("aria-label")).toBe(i18n.global.t("connectionGroup.selectGroup")); + + connectionStore.selectedTreeNodeIdsSet = new Set(["connection-1"]); + const mixed = (await mountTreeItem(group)).row.querySelector('[data-sidebar-group-selection-toggle="true"]'); + expect(mixed?.getAttribute("aria-checked")).toBe("mixed"); + expect(mixed?.getAttribute("aria-label")).toBe(i18n.global.t("connectionGroup.selectGroup")); + + connectionStore.selectedTreeNodeIdsSet = new Set(["connection-1", "connection-2"]); + const all = (await mountTreeItem(group)).row.querySelector('[data-sidebar-group-selection-toggle="true"]'); + expect(all?.getAttribute("aria-checked")).toBe("true"); + expect(all?.getAttribute("aria-label")).toBe(i18n.global.t("connectionGroup.deselectGroup")); }); it("runs load-more on a single click in double-click activation mode", async () => { diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index afd43a6a81..0a10b03ff0 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -449,6 +449,42 @@ export function connectionIdsInGroups(layout: SidebarLayout, groupIds: Iterable< return connectionIds; } +/** 收集单个分组下的所有连接 ID(含嵌套子分组),供分组勾选框级联选中连接使用。 */ +export function connectionIdsUnderGroup(layout: SidebarLayout, groupId: string): string[] { + return connectionIdsInGroups(layout, [groupId]); +} + +/** 收集分组自身及其下属所有含连接的子分组 ID,供级联选中时自动展开使用。 */ +export function connectionBearingGroupIdsUnder(layout: SidebarLayout, groupId: string): string[] { + const entry = findGroupEntry(layout.order, groupId); + if (!entry) return []; + const ids: string[] = []; + const visit = (groupEntry: Extract): boolean => { + let hasConnection = false; + for (const child of entryChildren(groupEntry)) { + if (child.type === "connection") hasConnection = true; + else if (visit(child)) hasConnection = true; + } + if (hasConnection) ids.push(groupEntry.id); + return hasConnection; + }; + visit(entry); + return ids; +} + +/** 批量展开指定分组(collapsed 置为 false),无变化时返回原布局。 */ +export function expandGroups(layout: SidebarLayout, groupIds: Iterable): SidebarLayout { + const targets = new Set(groupIds); + if (!targets.size) return layout; + let changed = false; + const groups = layout.groups.map((group) => { + if (!targets.has(group.id) || !group.collapsed) return group; + changed = true; + return { ...group, collapsed: false }; + }); + return changed ? { ...layout, groups } : layout; +} + export function deleteGroups(layout: SidebarLayout, groupIds: Iterable): SidebarLayout { const targets = new Set(groupIds); if (!targets.size) return layout; diff --git a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts index 52bdd6e818..8de6acb818 100644 --- a/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts +++ b/apps/desktop/src/lib/sidebar/sidebarTreeContext.ts @@ -4,6 +4,7 @@ import type { TreeNode } from "@/types/database"; export interface SidebarTreeContext { getVisibleNodes: () => TreeNode[]; getVisibleNodeIndex: (id: string) => number; + getProjectedConnectionIds?: () => ReadonlySet | null; isSearchProjectionActive?: () => boolean; getTreeLoadSearchOptions?: (node: TreeNode) => { searchFilter: string; allowGlobalSearchMismatch: true; expectedSidebarSearchQuery: string } | undefined; setTableSearchQuery?: (parentNodeId: string, query: string, local: boolean) => void; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index be1c07d17c..7100acb859 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -51,6 +51,7 @@ import { deleteGroups as deleteGroupsOp, connectionIdsInGroups as connectionIdsInGroupsOp, toggleGroupCollapsed as toggleGroupCollapsedOp, + expandGroups as expandGroupsOp, collapseAllGroups as collapseAllGroupsOp, moveConnectionToGroup as moveConnectionToGroupOp, remapSidebarLayoutConnectionIds, @@ -8280,6 +8281,9 @@ export const useConnectionStore = defineStore("connection", () => { toggleConnectionGroupCollapsed(groupId: string) { updateLayoutAndRebuild(toggleGroupCollapsedOp(sidebarLayout.value, groupId)); }, + expandConnectionGroups(groupIds: Iterable) { + updateLayoutAndRebuild(expandGroupsOp(sidebarLayout.value, groupIds)); + }, moveConnectionToGroup(connectionId: string, groupId: string | null) { updateLayoutAndRebuild(moveConnectionToGroupOp(sidebarLayout.value, connectionId, groupId)); },