fix(sidebar): align group selection with visible tree

This commit is contained in:
dadong authored and GitHub committed 2026-08-19 15:42:44 +08:00
1 parent c09dfbc813
commit a40dc02825
6 files changed
+297 -40

No files matched your search

@@ -668,6 +668,19 @@ const filteredNodes = computed(() => {
return nodes;
});
const projectedConnectionIds = computed<ReadonlySet<string> | null>(() => {
if (!isRootListPartial.value && !deferredSearchQuery.value) return null;
const connectionIds = new Set<string>();
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<FlatTreeNode[]>(() =>
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.
@@ -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) {
</div>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"> <Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }} </Badge>
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
<button
@@ -1453,14 +1471,17 @@ function onKeydown(event: KeyboardEvent) {
<button
v-if="node.type === 'connection-group'"
type="button"
role="checkbox"
data-sidebar-group-selection-toggle="true"
class="ml-auto flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground/55 opacity-0 transition-colors transition-opacity hover:bg-secondary/45 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring group-hover/sidebar-row:opacity-100"
:class="{ 'opacity-100': isConnectionGroupSelectionChecked() || isConnectionGroupMultiSelectActive() }"
:aria-label="isConnectionGroupSelectionChecked() ? t('connectionGroup.deselectGroup') : t('connectionGroup.selectGroup')"
:class="{ 'opacity-100': isConnectionMultiSelectActive() || isConnectionGroupMultiSelectActive() || connectionGroupSelectionState !== 'none' }"
:aria-label="connectionGroupSelectionState === 'all' ? t('connectionGroup.deselectGroup') : t('connectionGroup.selectGroup')"
:aria-checked="connectionGroupSelectionState === 'partial' ? 'mixed' : connectionGroupSelectionState === 'all'"
@mousedown.stop
@click="toggleConnectionGroupMultiSelection"
>
<Check v-if="isConnectionGroupSelectionChecked()" class="h-3 w-3 text-primary" />
<Check v-if="connectionGroupSelectionState === 'all'" class="h-3 w-3 text-primary" />
<Minus v-else-if="connectionGroupSelectionState === 'partial'" class="h-3 w-3 text-primary" />
<Square v-else class="h-3 w-3 stroke-[1.7]" />
</button>
</div>
@@ -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);
@@ -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<string>(),
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<string> | 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<string> {
const connectionIds = new Set<string>();
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<string>();
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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 () => {