Merge pull request #706 from DengQingNian/feat/drag-tab-reorder

feat(ui): add tab drag-and-drop reordering
This commit is contained in:
skyler 2026-06-04 11:21:52 +08:00 committed by GitHub
commit 0f691485c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 318 additions and 5 deletions

View File

@ -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<string | null>(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<CSSProperties>(() => ({
msOverflowStyle: "none",
scrollbarWidth: "none",
@ -306,14 +326,15 @@ 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)"
@mousemove="tabDrag.updateTarget($event, tab.id)"
@mouseleave="tabDrag.clearTarget(tab.id)"
>
<span class="shrink-0" :class="tabIconClass(tab)">
<Table2 v-if="tab.mode === 'data'" class="h-3.5 w-3.5" />

View File

@ -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<TabDragState>({
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,
};
}

View File

@ -482,6 +482,16 @@ 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);
tabs.value = orderPinnedFirst(tabs.value, (item) => !!item.pinned);
}
function updateDatabase(id: string, database: string) {
const tab = tabs.value.find((t) => t.id === id);
if (!tab || tab.database === database) return;
@ -1440,6 +1450,7 @@ export const useQueryStore = defineStore("query", () => {
linkSavedSql,
openSavedSql,
togglePinnedTab,
reorderTab,
updateDatabase,
updateSchema,
updateConnection,

View File

@ -1257,3 +1257,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],
);
});