diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index 04f272a2c0..141c57faea 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -2246,7 +2246,8 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); 1 && selected.includes(draggedId) ? [...selected] : [draggedId]; - connectionStore.reorderSidebarEntries(draggedIds, targetId, position); + connectionStore.reorderSidebarEntries(draggedIds, targetId, position, { preserveSameGroupOrder: props.moveToGroupOnly }); }); const canReorderTreeNode = computed(() => { @@ -1066,6 +1067,12 @@ function updateTreeDragTarget(event: MouseEvent) { return; } updateTarget(event, activeNode.value.id, activeNode.value.type); + // While the list is display-sorted, the underlying manual order used for + // before/after positioning is invisible, so only moving into a different + // group (a display-order-independent operation) is a coherent drop. + if (props.moveToGroupOnly && dragState.dropPosition !== "inside") { + clearTarget(activeNode.value.id); + } } function clearTreeDragTarget() { diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarFilterGuards.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarFilterGuards.spec.ts index db67d10cb1..d9b048c29f 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarFilterGuards.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarFilterGuards.spec.ts @@ -25,7 +25,11 @@ describe("sidebar filter guards", () => { expect(source).toContain("sidebarTableSearchEnabled && !isTreeSearchFiltering.value"); expect(source).toContain("!useVirtualTree.value || isTreeSearchFiltering.value"); expect(source.match(/if \(isRootListPartial\.value\)/g)).toHaveLength(2); - expect(source.match(/:reorder-disabled="isRootListPartial \|\| isConnectionListAlphabeticallySorted"/g)).toHaveLength(2); + // Alphabetical sort only suppresses before/after position reordering (the manual + // order it would act on is invisible while sorted); moving a connection into a + // different group stays available so drag stays usable while sorted (#6669). + expect(source.match(/:reorder-disabled="isRootListPartial"/g)).toHaveLength(2); + expect(source.match(/:move-to-group-only="isConnectionListAlphabeticallySorted"/g)).toHaveLength(2); expect(source).not.toContain("isFiltering"); }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts index f4af733e72..be0c6f9abf 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarLayout.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { connectionIdsInGroups, deleteGroups, findConnectionGroupPath } from "@/lib/sidebar/sidebarLayout"; +import { connectionIdsInGroups, deleteGroups, findConnectionGroupPath, reorderEntries } from "@/lib/sidebar/sidebarLayout"; import type { SidebarLayout } from "@/types/database"; const layout: SidebarLayout = { @@ -24,6 +24,34 @@ const layout: SidebarLayout = { ], }; +const dragLayout: SidebarLayout = { + groups: [ + { id: "alpha", name: "Alpha", collapsed: false }, + { id: "beta", name: "Beta", collapsed: false }, + { id: "nested", name: "Nested", collapsed: false }, + ], + order: [ + { + type: "group", + id: "alpha", + children: [ + { type: "connection", id: "alpha-first" }, + { + type: "group", + id: "nested", + children: [{ type: "connection", id: "nested-connection" }], + }, + { type: "connection", id: "alpha-second" }, + ], + }, + { + type: "group", + id: "beta", + children: [{ type: "connection", id: "beta-connection" }], + }, + ], +}; + describe("findConnectionGroupPath", () => { it("returns every containing group from root to leaf", () => { expect(findConnectionGroupPath(layout, "nested")).toEqual(["Project", "Staging"]); @@ -82,3 +110,96 @@ describe("connection group deletion", () => { }); }); }); + +describe("sorted sidebar move-only drops", () => { + const preserveSameGroupOrder = { preserveSameGroupOrder: true }; + + it("keeps a same-folder connection drop from rewriting hidden manual order", () => { + expect(reorderEntries(dragLayout, ["alpha-first"], "alpha", "inside", preserveSameGroupOrder)).toBe(dragLayout); + }); + + it("keeps a same-folder multi-selection drop from rewriting hidden manual order", () => { + expect(reorderEntries(dragLayout, ["alpha-first", "alpha-second"], "alpha", "inside", preserveSameGroupOrder)).toBe(dragLayout); + }); + + it("keeps a same-folder group drop from rewriting hidden manual order", () => { + expect(reorderEntries(dragLayout, ["nested"], "alpha", "inside", preserveSameGroupOrder)).toBe(dragLayout); + }); + + it("moves cross-folder entries while preserving selected entries already in the target folder", () => { + expect(reorderEntries(dragLayout, ["alpha-first", "beta-connection"], "alpha", "inside", preserveSameGroupOrder)).toEqual({ + ...dragLayout, + order: [ + { + type: "group", + id: "alpha", + children: [ + { type: "connection", id: "alpha-first" }, + { + type: "group", + id: "nested", + children: [{ type: "connection", id: "nested-connection" }], + }, + { type: "connection", id: "alpha-second" }, + { type: "connection", id: "beta-connection" }, + ], + }, + { type: "group", id: "beta", children: [] }, + ], + }); + }); + + it("preserves ordinary same-folder inside reordering outside move-only mode", () => { + expect(reorderEntries(dragLayout, ["alpha-first"], "alpha", "inside")).toEqual({ + ...dragLayout, + order: [ + { + type: "group", + id: "alpha", + children: [ + { + type: "group", + id: "nested", + children: [{ type: "connection", id: "nested-connection" }], + }, + { type: "connection", id: "alpha-second" }, + { type: "connection", id: "alpha-first" }, + ], + }, + { + type: "group", + id: "beta", + children: [{ type: "connection", id: "beta-connection" }], + }, + ], + }); + }); + + it("still moves a group across folders in move-only mode", () => { + expect(reorderEntries(dragLayout, ["nested"], "beta", "inside", preserveSameGroupOrder)).toEqual({ + ...dragLayout, + order: [ + { + type: "group", + id: "alpha", + children: [ + { type: "connection", id: "alpha-first" }, + { type: "connection", id: "alpha-second" }, + ], + }, + { + type: "group", + id: "beta", + children: [ + { type: "connection", id: "beta-connection" }, + { + type: "group", + id: "nested", + children: [{ type: "connection", id: "nested-connection" }], + }, + ], + }, + ], + }); + }); +}); diff --git a/apps/desktop/src/lib/sidebar/sidebarLayout.ts b/apps/desktop/src/lib/sidebar/sidebarLayout.ts index 0a10b03ff0..1217ac8b47 100644 --- a/apps/desktop/src/lib/sidebar/sidebarLayout.ts +++ b/apps/desktop/src/lib/sidebar/sidebarLayout.ts @@ -354,6 +354,20 @@ export function moveConnectionToGroup(layout: SidebarLayout, connectionId: strin export type DropPosition = "before" | "after" | "inside"; +export interface ReorderEntriesOptions { + preserveSameGroupOrder?: boolean; +} + +function findEntryParentGroupId(entries: SidebarOrderEntry[], entryId: string, parentGroupId: string | null = null): string | null | undefined { + for (const entry of entries) { + if (entry.id === entryId) return parentGroupId; + if (entry.type !== "group") continue; + const found = findEntryParentGroupId(entryChildren(entry), entryId, entry.id); + if (found !== undefined) return found; + } + return undefined; +} + export function reorderEntry(layout: SidebarLayout, draggedId: string, targetId: string, position: DropPosition): SidebarLayout { if (draggedId === targetId) return layout; @@ -387,6 +401,16 @@ export function reorderEntry(layout: SidebarLayout, draggedId: string, targetId: return { ...layout, order }; } +export function reorderEntries(layout: SidebarLayout, draggedIds: string[], targetId: string, position: DropPosition, options: ReorderEntriesOptions = {}): SidebarLayout { + let nextLayout = layout; + for (const draggedId of draggedIds) { + if (draggedId === targetId) continue; + if (position === "inside" && options.preserveSameGroupOrder && findEntryParentGroupId(nextLayout.order, draggedId) === targetId) continue; + nextLayout = reorderEntry(nextLayout, draggedId, targetId, position); + } + return nextLayout; +} + export function createGroup(layout: SidebarLayout, name: string, parentGroupId?: string | null): { layout: SidebarLayout; groupId: string } { const groupId = uuid(); const group: ConnectionGroup = { id: groupId, name, collapsed: false }; diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 7100acb859..dc18c4038d 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -57,9 +57,11 @@ import { remapSidebarLayoutConnectionIds, mergeSidebarLayout, reorderEntry as reorderEntryOp, + reorderEntries as reorderEntriesOp, buildConnectionGroupPathMap, connectionSidebarSearchAliases, type DropPosition, + type ReorderEntriesOptions, } from "@/lib/sidebar/sidebarLayout"; import type { SqlCompletionColumn, SqlCompletionForeignKey, SqlCompletionObject, SqlCompletionTable } from "@/lib/sql/sqlCompletion"; import { mergeSqlObjectNavigationType, sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; @@ -8293,17 +8295,9 @@ 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); + reorderSidebarEntries(draggedIds: string[], targetId: string, position: DropPosition, options?: ReorderEntriesOptions) { + const layout = reorderEntriesOp(sidebarLayout.value, draggedIds, targetId, position, options); + if (layout !== sidebarLayout.value) updateLayoutAndRebuild(layout); }, }; });