fix(sidebar): multi-select drag, blank-click deselect, empty-group-name (#681) (#823)

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 <vrustx@vrustxdeMac-mini.local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
vrustx 2026-06-08 02:09:26 +08:00 committed by GitHub
parent c54e43e4b9
commit 14fa15531f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 43 additions and 8 deletions

View File

@ -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"
>
<TreeItem
v-for="item in flatNodes"

View File

@ -530,6 +530,9 @@ function onClick(event: MouseEvent) {
event.stopPropagation();
return;
}
// Row clicks must not bubble to the tree container, whose click handler
// clears the selection when the blank area is clicked (issue #681).
event.stopPropagation();
if (event.shiftKey) {
selectTreeNodeRange(props.node);
rowRef.value?.focus({ preventScroll: true });
@ -2591,15 +2594,18 @@ watch(
);
function finishRenameGroup() {
// Guard against double invocation: pressing Enter sets isRenamingGroup=false
// and unmounts the input, which then fires @blur -> 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;

View File

@ -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);
},
};
});