From be9b07147311a6a2260ef22bcfe79196a207e8a5 Mon Sep 17 00:00:00 2001 From: dqn Date: Wed, 3 Jun 2026 23:58:01 +0800 Subject: [PATCH 1/2] feat(ui): add tab drag-and-drop reordering Support dragging tabs horizontally to reorder them. A ghost element follows the cursor during drag, and a drop indicator shows the target position. --- .../src/components/layout/AppTabBar.vue | 30 ++- apps/desktop/src/composables/useTabDrag.ts | 175 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 10 + 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/composables/useTabDrag.ts diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 5a08d0f5f..74ccddcf1 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -22,6 +22,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTabScroll } from "@/composables/useTabScroll"; +import { useTabDrag } from "@/composables/useTabDrag"; import { connectionColor, shouldShowTabOverflowControls, @@ -44,6 +45,9 @@ const emit = defineEmits<{ const { t } = useI18n(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); +const tabDrag = useTabDrag((draggedId, targetId, position) => { + queryStore.reorderTab(draggedId, targetId, position); +}); const editingTabId = ref(null); const editingTitle = ref(""); const compactTabTitle = computed({ @@ -231,6 +235,22 @@ function activateTab(tabId: string) { emit("close-driver-store"); } +function handleTabClick(tab: QueryTab) { + if (tabDrag.state.wasDragged) return; + activateTab(tab.id); +} + +function tabDropStyle(tabId: string) { + if (!tabDrag.state.active) return {}; + if (tabDrag.state.draggedId === tabId) return { opacity: 0.4 }; + if (tabDrag.state.targetId !== tabId) return {}; + const dropColor = `var(--ring)`; + if (tabDrag.state.dropPosition === "before") { + return { boxShadow: `inset 3px 0 0 0 ${dropColor}` }; + } + return { boxShadow: `inset -3px 0 0 0 ${dropColor}` }; +} + const tabsContainerStyle = computed(() => ({ msOverflowStyle: "none", scrollbarWidth: "none", @@ -306,14 +326,14 @@ const tabOverflowControlClass = computed(() => : 'border-border/60 text-foreground/70 hover:border-border hover:text-foreground/90', ] " - :style="tabColorStyle(tab)" + :style="[tabColorStyle(tab), tabDropStyle(tab.id)]" :data-active-tab="tab.id === queryStore.activeTabId && !showDriverStore" - @click=" - queryStore.activeTabId = tab.id; - emit('close-driver-store'); - " + @click="handleTabClick(tab)" @dblclick.stop="startRenameTab(tab)" @mousedown.middle.prevent="queryStore.closeTab(tab.id)" + @mousedown="tabDrag.startDrag($event, tab.id)" + @mouseenter="tabDrag.updateTarget($event, tab.id)" + @mouseleave="tabDrag.clearTarget(tab.id)" > diff --git a/apps/desktop/src/composables/useTabDrag.ts b/apps/desktop/src/composables/useTabDrag.ts new file mode 100644 index 000000000..1d5fca650 --- /dev/null +++ b/apps/desktop/src/composables/useTabDrag.ts @@ -0,0 +1,175 @@ +import { reactive, readonly } from "vue"; + +export type TabDropPosition = "before" | "after"; + +interface TabDragState { + active: boolean; + draggedId: string | null; + targetId: string | null; + dropPosition: TabDropPosition | null; + wasDragged: boolean; + startX: number; + startY: number; +} + +const DRAG_THRESHOLD = 5; + +const state = reactive({ + active: false, + draggedId: null, + targetId: null, + dropPosition: null, + wasDragged: false, + startX: 0, + startY: 0, +}); + +let pending: { + id: string; + x: number; + y: number; + sourceEl: HTMLElement | null; +} | null = null; +let onDropCallback: ((draggedId: string, targetId: string, position: TabDropPosition) => void) | null = null; +let ghostEl: HTMLElement | null = null; + +function createGhost(sourceEl: HTMLElement, x: number, y: number) { + const ghost = document.createElement("div"); + const textNode = sourceEl.querySelector(".truncate"); + ghost.textContent = textNode?.textContent || ""; + ghost.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 9999; + opacity: 0.9; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + border-radius: 6px; + background: var(--background, #fff); + border: 1px solid var(--border, #e5e7eb); + max-width: 200px; + height: 28px; + padding: 0 12px; + font-size: 12px; + line-height: 28px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + left: ${x + 12}px; + top: ${y - 14}px; + `; + document.body.appendChild(ghost); + return ghost; +} + +function moveGhost(x: number, y: number) { + if (!ghostEl) return; + ghostEl.style.left = `${x + 8}px`; + ghostEl.style.top = `${y - 14}px`; +} + +function removeGhost() { + if (ghostEl) { + ghostEl.remove(); + ghostEl = null; + } +} + +function onMouseMove(event: MouseEvent) { + if (!pending && !state.active) return; + + if (pending && !state.active) { + const dx = event.clientX - pending.x; + const dy = event.clientY - pending.y; + if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return; + state.active = true; + state.wasDragged = true; + state.draggedId = pending.id; + state.startX = pending.x; + state.startY = pending.y; + if (pending.sourceEl) { + ghostEl = createGhost(pending.sourceEl, event.clientX, event.clientY); + } + pending = null; + document.body.style.cursor = "grabbing"; + document.body.style.userSelect = "none"; + } + + if (state.active) { + moveGhost(event.clientX, event.clientY); + } +} + +function onMouseUp() { + if (state.active && state.draggedId && state.targetId && state.dropPosition && onDropCallback) { + onDropCallback(state.draggedId, state.targetId, state.dropPosition); + } + reset(); +} + +function reset() { + state.active = false; + state.draggedId = null; + state.targetId = null; + state.dropPosition = null; + state.startX = 0; + state.startY = 0; + pending = null; + removeGhost(); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; +} + +let listenersAttached = false; + +function ensureListeners() { + if (listenersAttached) return; + document.addEventListener("mousemove", onMouseMove, true); + document.addEventListener("mouseup", onMouseUp, true); + listenersAttached = true; +} + +export function useTabDrag(onDrop: (draggedId: string, targetId: string, position: TabDropPosition) => void) { + ensureListeners(); + onDropCallback = onDrop; + + function startDrag(event: MouseEvent, tabId: string) { + if (event.button !== 0) return; + const target = event.target as HTMLElement; + if (target.closest("button, input, [data-tab-title-input]")) return; + state.wasDragged = false; + const el = (event.currentTarget as HTMLElement) || null; + pending = { id: tabId, x: event.clientX, y: event.clientY, sourceEl: el }; + } + + function updateTarget(event: MouseEvent, tabId: string) { + if (!state.active || tabId === state.draggedId) { + if (state.targetId === tabId) { + state.targetId = null; + state.dropPosition = null; + } + return; + } + + state.targetId = tabId; + + const el = event.currentTarget as HTMLElement; + const rect = el.getBoundingClientRect(); + const x = event.clientX - rect.left; + + state.dropPosition = x < rect.width / 2 ? "before" : "after"; + } + + function clearTarget(tabId: string) { + if (state.targetId === tabId) { + state.targetId = null; + state.dropPosition = null; + } + } + + return { + state: readonly(state), + startDrag, + updateTarget, + clearTarget, + }; +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 42f3222b2..37a1b5d7f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -470,6 +470,15 @@ export const useQueryStore = defineStore("query", () => { tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned); } + function reorderTab(id: string, targetId: string, position: "before" | "after") { + const fromIdx = tabs.value.findIndex((t) => t.id === id); + const toIdx = tabs.value.findIndex((t) => t.id === targetId); + if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return; + const [tab] = tabs.value.splice(fromIdx, 1); + const newToIdx = tabs.value.findIndex((t) => t.id === targetId); + tabs.value.splice(newToIdx + (position === "after" ? 1 : 0), 0, tab); + } + function updateDatabase(id: string, database: string) { const tab = tabs.value.find((t) => t.id === id); if (!tab || tab.database === database) return; @@ -1358,6 +1367,7 @@ export const useQueryStore = defineStore("query", () => { linkSavedSql, openSavedSql, togglePinnedTab, + reorderTab, updateDatabase, updateSchema, updateConnection, From b028b0aea2b90370383f1d00c45223e873062244 Mon Sep 17 00:00:00 2001 From: dqn Date: Thu, 4 Jun 2026 10:40:42 +0800 Subject: [PATCH 2/2] fix(ui): update drop position on mousemove and enforce pinned grouping in tab reorder - Track mousemove during drag to continuously update before/after indicator - Re-apply orderPinnedFirst after reorder to prevent breaking pinned tab grouping --- .../src/components/layout/AppTabBar.vue | 1 + apps/desktop/src/stores/queryStore.ts | 1 + packages/app-tests/queryStore.test.ts | 106 ++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 74ccddcf1..b8f4e261d 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -333,6 +333,7 @@ const tabOverflowControlClass = computed(() => @mousedown.middle.prevent="queryStore.closeTab(tab.id)" @mousedown="tabDrag.startDrag($event, tab.id)" @mouseenter="tabDrag.updateTarget($event, tab.id)" + @mousemove="tabDrag.updateTarget($event, tab.id)" @mouseleave="tabDrag.clearTarget(tab.id)" > diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 37a1b5d7f..3ead2eff1 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -477,6 +477,7 @@ export const useQueryStore = defineStore("query", () => { const [tab] = tabs.value.splice(fromIdx, 1); const newToIdx = tabs.value.findIndex((t) => t.id === targetId); tabs.value.splice(newToIdx + (position === "after" ? 1 : 0), 0, tab); + tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned); } function updateDatabase(id: string, database: string) { diff --git a/packages/app-tests/queryStore.test.ts b/packages/app-tests/queryStore.test.ts index 32ef4e909..b8e7bffa9 100644 --- a/packages/app-tests/queryStore.test.ts +++ b/packages/app-tests/queryStore.test.ts @@ -1135,3 +1135,109 @@ test("new table structure tabs can open multiple drafts while existing tables st restoreStorage(); } }); + +test("reorderTab keeps pinned tabs before unpinned tabs after reorder", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + + const tabA = store.createTab("conn-1", "db", "A", "query"); + const tabB = store.createTab("conn-1", "db", "B", "query"); + const tabC = store.createTab("conn-1", "db", "C", "query"); + const tabD = store.createTab("conn-1", "db", "D", "query"); + + store.tabs[0].pinned = false; + store.tabs[1].pinned = true; + store.tabs[2].pinned = false; + store.tabs[3].pinned = true; + + // Force store to apply pinned ordering + store.togglePinnedTab(tabB); + store.togglePinnedTab(tabB); + // Now tabs: D(b), B(b), A, C + + // Try dragging unpinned tab A before pinned tab B + store.reorderTab(tabA, tabB, "before"); + const idsAfter = store.tabs.map((t) => t.id); + const pinnedIndices = store.tabs.map((t, i) => ({ pinned: t.pinned, i })).filter((t) => t.pinned); + const unpinnedIndices = store.tabs.map((t, i) => ({ pinned: t.pinned, i })).filter((t) => !t.pinned); + + // All pinned tabs should come before any unpinned tab + assert.equal(Math.max(...pinnedIndices.map((t) => t.i)) < Math.min(...unpinnedIndices.map((t) => t.i)), true); +}); + +test("reorderTab preserves relative order within pinned group", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + + const tabA = store.createTab("conn-1", "db", "A", "query"); + const tabB = store.createTab("conn-1", "db", "B", "query"); + const tabC = store.createTab("conn-1", "db", "C", "query"); + const tabD = store.createTab("conn-1", "db", "D", "query"); + const tabE = store.createTab("conn-1", "db", "E", "query"); + + // Pin A, B, C — leave D, E unpinned + store.togglePinnedTab(tabA); + // toggle so orderPinnedFirst runs: [A, B, C, D, E] + store.togglePinnedTab(tabB); + // [A, B, C, D, E] + assert.equal(store.tabs.filter((t) => t.pinned).length, 2); + + store.togglePinnedTab(tabC); + // pinned = [A, B, C], unpinned = [D, E] + assert.equal(store.tabs.filter((t) => t.pinned).length, 3); + + // Now: A, B, C (pinned), D, E (unpinned) + // Drag C before A (within pinned group) + store.reorderTab(tabC, tabA, "before"); + // After orderPinnedFirst: C, A, B, D, E + const ids = store.tabs.map((t) => t.id); + assert.equal(ids[0], tabC, "C should be first pinned tab"); + assert.equal(ids[1], tabA, "A should be second pinned tab"); + assert.equal(ids[2], tabB, "B should be third pinned tab"); + assert.equal(ids[3], tabD, "D should be first unpinned"); + assert.equal(ids[4], tabE, "E should be second unpinned"); +}); + +test("reorderTab preserves relative order within unpinned group", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + + const tabA = store.createTab("conn-1", "db", "A", "query"); + const tabB = store.createTab("conn-1", "db", "B", "query"); + const tabC = store.createTab("conn-1", "db", "C", "query"); + const tabD = store.createTab("conn-1", "db", "D", "query"); + + store.tabs[0].pinned = true; + store.tabs[1].pinned = false; + store.tabs[2].pinned = false; + store.tabs[3].pinned = false; + + store.togglePinnedTab(tabA); + store.togglePinnedTab(tabA); + + // Now tabs: A(pinned), B, C, D(unpinned) + // Drag D before B + store.reorderTab(tabD, tabB, "before"); + // After orderPinnedFirst: A, D, B, C + const ids = store.tabs.map((t) => t.id); + assert.equal(ids[0], tabA, "A should stay pinned"); + assert.equal(ids[1], tabD, "D should be first unpinned"); + assert.equal(ids[2], tabB, "B should be second unpinned"); + assert.equal(ids[3], tabC, "C should be last unpinned"); +}); + +test("reorderTab with after position places tab correctly", () => { + setActivePinia(createPinia()); + const store = useQueryStore(); + + const tabA = store.createTab("conn-1", "db", "A", "query"); + const tabB = store.createTab("conn-1", "db", "B", "query"); + const tabC = store.createTab("conn-1", "db", "C", "query"); + + // Drag A after C + store.reorderTab(tabA, tabC, "after"); + assert.deepEqual( + store.tabs.map((t) => t.id), + [tabB, tabC, tabA], + ); +});