From 14fa15531f5712256f623d8e00632ee0ac2d7514 Mon Sep 17 00:00:00 2001 From: vrustx <279631638@qq.com> Date: Mon, 8 Jun 2026 02:09:26 +0800 Subject: [PATCH] fix(sidebar): multi-select drag, blank-click deselect, empty-group-name (#681) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent connection-list bugs: 1. Multi-select drag only moved one row. The drag system tracks a single draggedId and ignored selectedTreeNodeIds. The drop callback now expands to the full selection when the grabbed row is part of it, and the store gains reorderSidebarEntries() to move them together. 2. Clicking the blank tree area didn't clear the selection (notably in double-click activation mode). Row clicks now stopPropagation and the tree containers clear the selection on click, so only blank clicks reset. 3. Creating a group then submitting an empty name dissolved ALL groups. Enter (@keydown.enter) and the following @blur both fired finishRenameGroup; the first call rebuilt the tree and recycled props.node onto another group, so the second deleted the wrong one, cascading. Guard against double invocation and treat an empty name as a cancel (never delete here — deleting a group stays in the context menu). Verified end-to-end on the web build: multi-select drag nests both connections under the group; blank-click clears selection; empty-name Enter keeps all groups intact. pnpm check is green. Co-authored-by: vrustx Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/components/sidebar/ConnectionTree.vue | 11 ++++++++ .../src/components/sidebar/TreeItem.vue | 28 +++++++++++++------ apps/desktop/src/stores/connectionStore.ts | 12 ++++++++ 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index d73e40150..0a11b9266 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -188,6 +188,15 @@ async function scrollToSidebarNode(nodeId: string) { } } +function clearSidebarSelection() { + // Clicking the blank area of the tree clears the current selection. Row + // clicks call event.stopPropagation(), so this only fires for blank clicks + // (issue #681 — selection wasn't cleared in double-click activation mode). + store.selectedTreeNodeId = null; + store.selectedTreeNodeIds = []; + store.treeSelectionAnchorId = null; +} + async function createNewGroup() { const groupId = store.createConnectionGroup(t("connectionGroup.newGroupDefault")); pendingRenameGroupId.value = groupId; @@ -506,6 +515,7 @@ defineExpose({ focusSearch, createNewGroup }); ref="treeScrollerRef" class="sidebar-tree connection-tree-scroller min-h-0 flex-1 overflow-y-auto" :class="sidebarTreeOverflowClass" + @click="clearSidebarSelection" :items="flatNodes" :item-size="SIDEBAR_TREE_ROW_HEIGHT" :buffer="SIDEBAR_TREE_SCROLL_BUFFER" @@ -533,6 +543,7 @@ defineExpose({ focusSearch, createNewGroup }); ref="plainTreeScrollerRef" class="sidebar-tree min-h-0 flex-1 overflow-y-auto" :class="sidebarTreeOverflowClass" + @click="clearSidebarSelection" > finishRenameGroup again. + // The first call can rebuild the tree and recycle props.node onto a different + // group, so a second run would act on the wrong group and cascade across + // groups (issue #681). + if (!isRenamingGroup.value) return; isRenamingGroup.value = false; const trimmed = renameInput.value.trim(); - if (!trimmed) { - connectionStore.deleteConnectionGroup(props.node.id); - return; - } - if (trimmed !== props.node.label) { - connectionStore.renameConnectionGroup(props.node.id, trimmed); - } + // An empty name cancels the rename and keeps the group as-is — never delete + // here. Deleting a group is done explicitly via the context menu (issue #681). + if (!trimmed || trimmed === props.node.label) return; + connectionStore.renameConnectionGroup(props.node.id, trimmed); } function deleteConnectionGroup() { @@ -2725,7 +2731,13 @@ const { startDrag, updateTarget, clearTarget, -} = useDragSort((draggedId, targetId, position) => connectionStore.reorderSidebarEntry(draggedId, targetId, position)); +} = useDragSort((draggedId, targetId, position) => { + // If the grabbed row is part of a multi-selection, move all selected rows + // together; otherwise just the grabbed one (issue #681). + const selected = connectionStore.selectedTreeNodeIds; + const draggedIds = selected.length > 1 && selected.includes(draggedId) ? [...selected] : [draggedId]; + connectionStore.reorderSidebarEntries(draggedIds, targetId, position); +}); const isDraggable = computed(() => { if (props.dragDisabled) return false; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 8d3759c88..0d2ab622e 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -2344,5 +2344,17 @@ export const useConnectionStore = defineStore("connection", () => { reorderSidebarEntry(draggedId: string, targetId: string, position: DropPosition) { updateLayoutAndRebuild(reorderEntryOp(sidebarLayout.value, draggedId, targetId, position)); }, + reorderSidebarEntries(draggedIds: string[], targetId: string, position: DropPosition) { + // Apply each dragged entry in turn so a multi-selection moves together, + // not just the single grabbed row (issue #681). + let layout = sidebarLayout.value; + let changed = false; + for (const id of draggedIds) { + if (id === targetId) continue; + layout = reorderEntryOp(layout, id, targetId, position); + changed = true; + } + if (changed) updateLayoutAndRebuild(layout); + }, }; });