fix(sidebar): improve connection multi-select actions

This commit is contained in:
zipg authored and GitHub committed 2026-08-12 20:17:57 +08:00
1 parent 668c5ddadb
commit 105920047a
24 files changed
+510 -44

No files matched your search

@@ -212,6 +212,44 @@ describe("AppSidebar connection multi-select moves", () => {
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
});
it("shows the selected count beside the connection title instead of as a toolbar item", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
const count = host.querySelector("[data-connection-selection-count]");
expect(count?.textContent?.trim()).toBe("connectionGroup.selectedConnections");
expect(count?.previousElementSibling?.textContent?.trim()).toBe("sidebar.connections");
});
it("exits connection multi-select when Escape is pressed in the sidebar", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true });
host.querySelector<HTMLElement>(".app-sidebar-panel")!.dispatchEvent(event);
await nextTick();
expect(event.defaultPrevented).toBe(true);
expect(mocks.store.selectedTreeNodeIds).toEqual([]);
expect(mocks.store.selectedTreeNodeId).toBeNull();
expect(mocks.store.treeSelectionAnchorId).toBeNull();
expect(mocks.store.connectionMultiSelectActive).toBe(false);
});
it("does not exit connection multi-select when Escape comes from an input", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
click(host.querySelector('[data-tooltip="connectionGroup.createGroup"] button'));
await nextTick();
const input = host.querySelector<HTMLInputElement>("[data-dialog] input");
input!.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
await nextTick();
expect(mocks.store.selectedTreeNodeIds).toEqual(["conn-visible", "conn-hidden"]);
expect(mocks.store.connectionMultiSelectActive).toBe(true);
});
it("keeps selection on cancel and releases it after creating a new group", async () => {
setSelection(["conn-visible", "conn-hidden"]);
const host = await mountSidebar();
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import LightDropdown from "@/components/ui/LightDropdown.vue";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { applyConnectionMultiSelection, emptyConnectionMultiSelection, isExitConnectionMultiSelectionShortcut } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
@@ -90,6 +90,18 @@ function clearConnectionMultiSelection() {
applyConnectionMultiSelection(connectionStore, emptyConnectionMultiSelection());
}
function isEditableSidebarTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || target.isContentEditable || !!target.closest("[contenteditable='true'], [role='textbox']");
}
function onSidebarKeydown(event: KeyboardEvent) {
if (event.defaultPrevented || !connectionStore.connectionMultiSelectActive || !isExitConnectionMultiSelectionShortcut(event) || isEditableSidebarTarget(event.target)) return;
event.preventDefault();
event.stopPropagation();
clearConnectionMultiSelection();
}
function toggleAllConnectionsSelected() {
if (allConnectionsSelected.value) {
clearConnectionMultiSelection();
@@ -153,13 +165,17 @@ defineExpose({ focusSearch });
</script>
<template>
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }">
<div class="app-sidebar-panel h-full shrink-0 relative select-none" :class="classicLayout ? '' : 'rounded-md border border-border/80 bg-background'" :style="{ width: sidebarWidth + 'px' }" @keydown="onSidebarKeydown">
<div class="h-full flex flex-col overflow-hidden">
<div class="app-sidebar-toolbar flex items-center gap-px px-3 text-xs font-medium text-muted-foreground border-b bg-muted/20" :class="classicLayout ? 'h-9' : 'h-10'">
<span class="flex self-stretch items-center truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span class="flex min-w-0 self-stretch items-center" data-tauri-drag-region>
<span class="truncate" data-tauri-drag-region>{{ t("sidebar.connections") }}</span>
<span v-if="showConnectionMultiSelectToolbar" class="ml-1.5 shrink-0 text-[11px] font-normal text-muted-foreground/80" data-connection-selection-count>
{{ t("connectionGroup.selectedConnections", { count: selectedConnectionCount }) }}
</span>
</span>
<span class="flex-1 self-stretch" data-tauri-drag-region />
<template v-if="showConnectionMultiSelectToolbar">
<span class="mr-1 text-[11px] font-medium text-muted-foreground">{{ selectedConnectionCount }}</span>
<LightTooltip :text="t('connectionGroup.createGroup')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="openCreateSelectedGroupDialog">
<FolderPlus class="h-3 w-3" />
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -52,6 +52,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
const { t } = useI18n();
const store = useConnectionStore();
@@ -61,6 +62,7 @@ const { toast } = useToast();
const searchQuery = ref("");
const deferredSearchQuery = ref("");
const showConnectedConnectionsOnly = ref(false);
const isDisconnectingAllActiveConnections = ref(false);
const searchInputRef = ref<HTMLInputElement>();
const rootRef = ref<HTMLElement>();
const pointerInsideTree = ref(false);
@@ -159,6 +161,39 @@ watch(
{ flush: "sync" },
);
watch(
[showConnectedConnectionsOnly, () => store.connectedIds.size],
([showConnectedOnly, activeConnectionCount]) => {
if (showConnectedOnly && activeConnectionCount === 0) showConnectedConnectionsOnly.value = false;
},
{ flush: "sync" },
);
async function disconnectAllActiveConnections() {
if (isDisconnectingAllActiveConnections.value) return;
const connectionIds = [...store.connectedIds];
if (!connectionIds.length) {
showConnectedConnectionsOnly.value = false;
return;
}
isDisconnectingAllActiveConnections.value = true;
try {
const result = await disconnectSidebarConnections(connectionIds, (connectionId) => store.disconnect(connectionId));
if (!result.failed) {
toast(t("connection.disconnectedSelected", { count: connectionIds.length }), 2000);
} else if (result.succeeded > 0) {
toast(t("connection.disconnectSelectedPartial", { succeeded: result.succeeded, failed: result.failed }), 5000);
} else {
const message = result.firstError instanceof Error ? result.firstError.message : String(result.firstError);
toast(t("connection.saveFailed", { message }), 5000);
}
} finally {
isDisconnectingAllActiveConnections.value = false;
if (store.connectedIds.size === 0) showConnectedConnectionsOnly.value = false;
}
}
function refreshActiveSidebarTableSearches() {
if (isTreeSearchFiltering.value) return;
for (const parentNodeId of Object.keys(store.sidebarTableSearchQueries)) {
@@ -1974,6 +2009,13 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</div>
</div>
</CustomContextMenu>
<div v-if="showConnectedConnectionsOnly && store.connectedIds.size > 0" class="shrink-0 border-t border-border bg-background px-2 py-2">
<Button type="button" variant="outline" size="sm" class="h-7 w-full justify-center gap-1.5 text-xs" :disabled="isDisconnectingAllActiveConnections" @click="disconnectAllActiveConnections">
<Loader2 v-if="isDisconnectingAllActiveConnections" class="h-3.5 w-3.5 animate-spin" />
<Unplug v-else class="h-3.5 w-3.5" />
{{ t("sidebar.disconnectAllActiveConnections") }}
</Button>
</div>
<SidebarDdlViewDialog
v-if="sidebarDdlTarget"
v-model:open="sidebarDdlOpen"
@@ -140,7 +140,7 @@ import { getTableStructureCapabilities } from "@/lib/table/tableStructureCapabil
import { connectionObjectTreeNodeSchema, connectionObjectTreeQuerySchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, tableStructureDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { hasTreeNodeDatabaseContext } from "@/lib/sidebar/treeNodeContext";
import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableClipboardMenuState, tableClipboardSourceContext, tableDataCopyColumnOptions, type TableClipboardContext, type TableClipboardTableContext } from "@/lib/table/tableClipboard";
import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
import { selectedSidebarBatchTargets, selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes } from "@/lib/sidebar/sidebarTreeSelection";
import { connectionPasteTargetGroupId, selectedConnectionClipboardTargets, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection";
import { connectionSupportsDatabaseUserAdmin, resolveDatabaseUserAdminProviderForConnection, type DatabaseUserIdentity } from "@/lib/database/databaseUserAdmin";
import { authorizationPlanSql, authorizationPlanStatus, buildCreateDatabaseAuthorizationPlan, executeAuthorizationPlan, type AuthorizationPlan, type AuthorizationStepResult } from "@/lib/database/databaseAuthorizationPlan";
@@ -368,6 +368,8 @@ const {
canBackupSqliteDatabase,
backupSqliteDatabase,
disconnectConnection,
connectionDisconnectMenuLabel,
canDisconnectConnection,
cancelConnectionAttempt,
closeDatabaseConnection,
isPinned,
@@ -1934,14 +1936,7 @@ async function refreshMutatedTableDataTabsForNodes(nodes: readonly TreeNode[]) {
}
function selectedBatchDropTargets(): TreeNode[] {
const selected = selectedTreeNodesInVisibleOrder();
if (selected.length <= 1 || !selected.some((node) => node.id === activeNode.value.id)) return [];
const first = selected[0];
if (!first?.connectionId || !first.database || !selected.every((node) => node.type === first.type)) return [];
if (!selected.every((node) => node.connectionId === first.connectionId && node.database === first.database && canDropTreeNode(node))) {
return [];
}
return selected;
return selectedSidebarBatchTargets(activeNode.value, selectedTreeNodesInVisibleOrder(), canDropTreeNode);
}
function selectedBatchTableTargets(): TreeNode[] {
@@ -4152,10 +4147,10 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
if (node.type === "connection") {
if (isConnecting.value) {
items.push({ label: t("connection.cancelConnecting"), action: cancelConnectionAttempt, icon: X });
} else if (canDisconnectConnection()) {
items.push({ label: connectionDisconnectMenuLabel(), action: disconnectConnection, icon: Unplug });
} else if (!isConnected.value) {
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Plug });
} else {
items.push({ label: t("contextMenu.closeConnection"), action: disconnectConnection, icon: Unplug });
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
@@ -5044,11 +5039,17 @@ function bindMenuTarget(items: ContextMenuItem[], target: SidebarActionTarget, s
function buildContextMenu(node: TreeNode): ContextMenuItem[] {
const previousNode = activeNode.value;
const menuContext = createSidebarMenuContext(node, connectionStore.selectedTreeNodeIds, databaseTypeForNode(node));
acceptedSelectionIds = menuContext.selectedNodeIds;
activateRuntimeNode(node);
claimTreeItemDialogOwnership();
ensureDangerDialogRouting();
routeTreeItemDialogController();
const rawItems = treeItemMenuItems();
let rawItems: ContextMenuItem[];
try {
rawItems = treeItemMenuItems();
} finally {
acceptedSelectionIds = null;
}
// Normalization is intentionally evaluated only when a menu opens. Besides
// parity tests, it provides deterministic action identifiers for diagnostics.
normalizeSidebarMenuDescriptors(menuContext, rawItems);
@@ -59,8 +59,8 @@ import { connectionDisplayUrlScheme } from "@/lib/connection/connectionPresentat
import { hexToRgba } from "@/lib/common/color";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebar/sidebarLabelTooltip";
import { treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { applyConnectionMultiSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { filterSidebarModifierSelectionIds, supportsSidebarModifierSelection, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebar/sidebarTreeSelection";
import { applyConnectionMultiSelection, applyTreeNodeSelection, connectionMultiSelectionAfterToggle } from "@/lib/sidebar/sidebarConnectionMultiSelect";
import { isSidebarDatabaseOpenForVisual } from "@/lib/sidebar/sidebarDatabaseOpenState";
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
import { connectionCanConfigureSidebarVisibleDatabases } from "@/lib/sidebar/sidebarVisibleFilterMenu";
@@ -524,25 +524,45 @@ function selectSingleTreeNode(node: TreeNode) {
}
function toggleTreeNodeSelection(node: TreeNode) {
connectionStore.connectionMultiSelectActive = false;
if (!supportsSidebarModifierSelection(node)) {
selectSingleTreeNode(node);
return;
}
const ids = new Set(connectionStore.selectedTreeNodeIds);
if (ids.has(node.id)) ids.delete(node.id);
else ids.add(node.id);
connectionStore.selectedTreeNodeIds = ids.size ? [...ids] : [node.id];
connectionStore.selectedTreeNodeId = node.id;
connectionStore.treeSelectionAnchorId = node.id;
const filteredIds = filterSidebarModifierSelectionIds(visibleTreeNodes(), [...ids]);
applyTreeNodeSelection(
connectionStore,
{
nodeIds: filteredIds.length ? filteredIds : [node.id],
activeNodeId: node.id,
anchorNodeId: node.id,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
}
function selectTreeNodeRange(node: TreeNode) {
connectionStore.connectionMultiSelectActive = false;
if (!supportsSidebarModifierSelection(node)) {
selectSingleTreeNode(node);
return;
}
const visible = visibleTreeNodes();
const anchorId = connectionStore.treeSelectionAnchorId || connectionStore.selectedTreeNodeId || node.id;
const currentIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(node.id) : -1;
const anchorIndex = sidebarTreeContext ? sidebarTreeContext.getVisibleNodeIndex(anchorId) : -1;
if (sidebarTreeContext && currentIndex >= 0 && anchorIndex >= 0) {
connectionStore.selectedTreeNodeIds = treeSelectionRangeIdsByIndex(visible, currentIndex, anchorIndex, node.id);
connectionStore.selectedTreeNodeId = node.id;
applyTreeNodeSelection(
connectionStore,
{
nodeIds: filterSidebarModifierSelectionIds(visible, treeSelectionRangeIdsByIndex(visible, currentIndex, anchorIndex, node.id)),
activeNodeId: node.id,
anchorNodeId: anchorId,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
return;
}
@@ -551,9 +571,16 @@ function selectTreeNodeRange(node: TreeNode) {
return;
}
const rangeIds = treeSelectionRangeIds(visible, node.id, anchorId, connectionStore.selectedTreeNodeId);
connectionStore.selectedTreeNodeIds = rangeIds;
connectionStore.selectedTreeNodeId = node.id;
const rangeIds = filterSidebarModifierSelectionIds(visible, treeSelectionRangeIds(visible, node.id, anchorId, connectionStore.selectedTreeNodeId));
applyTreeNodeSelection(
connectionStore,
{
nodeIds: rangeIds,
activeNodeId: node.id,
anchorNodeId: anchorId,
},
new Set(connectionStore.connections.map((connection) => connection.id)),
);
}
function selectedConnectionIdsForAction(): string[] {
@@ -101,6 +101,7 @@ afterEach(() => {
connectionStore.selectedTreeNodeId = null;
connectionStore.selectedTreeNodeIds = [];
connectionStore.selectedTreeNodeIdsSet = new Set<string>();
connectionStore.treeNodes = [];
connectionStore.treeSelectionAnchorId = null;
settingsStore.editorSettings.sidebarActivation = "double";
vi.restoreAllMocks();
@@ -144,6 +145,37 @@ describe("TreeItem load-more activation", () => {
expect(host.handleRowClick).not.toHaveBeenCalled();
});
it("drops a selected object group when modifier-selecting a real table", async () => {
const group: TreeNode = {
id: "connection-1:app:dbo:__tables",
label: "tree.tables",
type: "group-tables",
connectionId: "connection-1",
database: "app",
schema: "dbo",
};
const table: TreeNode = {
id: "connection-1:app:dbo:orders",
label: "orders",
type: "table",
connectionId: "connection-1",
database: "app",
schema: "dbo",
};
connectionStore.treeNodes = [{ ...group, children: [table] }];
connectionStore.selectedTreeNodeId = group.id;
connectionStore.selectedTreeNodeIds = [group.id];
connectionStore.selectedTreeNodeIdsSet = new Set([group.id]);
connectionStore.treeSelectionAnchorId = group.id;
const { row } = await mountTreeItem(table);
row.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1, metaKey: true }));
expect(connectionStore.selectedTreeNodeIds).toEqual([table.id]);
expect(connectionStore.selectedTreeNodeId).toBe(table.id);
expect(connectionStore.treeSelectionAnchorId).toBe(table.id);
});
it("shows the invalid marker for objects reported as invalid", async () => {
const node: TreeNode = {
id: "connection-1:app:dbo:broken_proc",