feat(tabs): detach SQL and data tabs into separate windows

This commit is contained in:
DengQingNian authored and GitHub committed 2026-09-03 01:45:08 +08:00
1 parent 717dcc1294
commit d38be39e03
17 files changed
+957 -21

No files matched your search

@@ -30,6 +30,8 @@ const props = defineProps<{
settingsPageOpen?: boolean;
settingsPageActive?: boolean;
agentDriverUpdateCount?: number;
detachedDropTarget?: boolean;
canDetachTabs?: boolean;
}>();
const emit = defineEmits<{
@@ -45,6 +47,7 @@ const emit = defineEmits<{
"save-all-tab-close": [];
"discard-all-tab-close": [];
"cancel-tab-close": [];
"detach-tab": [tab: QueryTab, position?: { x: number; y: number }];
}>();
const { t } = useI18n();
@@ -52,16 +55,25 @@ const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { toast } = useToast();
const tabDrag = useTabDrag(
(draggedId, targetId, position) => {
return queryStore.reorderTab(draggedId, targetId, position);
},
(draggedId, position) => {
if (!props.canDetachTabs) return false;
const tab = queryStore.tabs.find((item) => item.id === draggedId);
if (!tab || !isDetachableTab(tab)) return false;
emit("detach-tab", tab, position);
return true;
},
() => (isVerticalLayout.value ? "vertical" : "horizontal"),
);
const editingTabId = ref<string | null>(null);
const editingTitle = ref("");
const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic");
const isVerticalLayout = computed(() => settingsStore.editorSettings.tabPlacement === "left" || settingsStore.editorSettings.tabPlacement === "right");
const isWrapLayout = computed(() => !isVerticalLayout.value && settingsStore.editorSettings.tabLayout === "wrap");
const isManualTabOrder = computed(() => settingsStore.editorSettings.tabSortMode === "manual");
const tabDrag = useTabDrag(
(draggedId, targetId, position) => queryStore.reorderTab(draggedId, targetId, position),
() => (isVerticalLayout.value ? "vertical" : "horizontal"),
);
function tabGroupKey(tab: QueryTab) {
const connection = connectionStore.getConfig(tab.connectionId);
@@ -153,6 +165,7 @@ function scheduleCloseConfirmListClose() {
}
onUnmounted(() => {
tabDrag.setDetachBoundsProvider(null);
if (closeConfirmListCloseTimer) {
clearTimeout(closeConfirmListCloseTimer);
closeConfirmListCloseTimer = null;
@@ -174,6 +187,10 @@ function canRenameTab(tab: QueryTab) {
return tab.mode === "query";
}
function isDetachableTab(tab: QueryTab) {
return tab.mode === "query" || tab.mode === "data";
}
function startRenameTab(tab: QueryTab) {
if (!canRenameTab(tab)) return;
editingTabId.value = tab.id;
@@ -389,6 +406,12 @@ function getTabMenuItems(tab: QueryTab): ContextMenuItem[] {
},
icon: Copy,
},
{
label: t("tabs.openInNewWindow"),
action: () => emit("detach-tab", tab),
icon: Maximize2,
visible: !!props.canDetachTabs && isDetachableTab(tab),
},
{
label: t("sidebar.locateActiveTab"),
action: () => emit("locate-tab", tab),
@@ -468,6 +491,13 @@ const {
onTabsWheel: onFixedTabsWheel,
startScrollbarDrag: startFixedScrollbarDrag,
} = useTabScroll(fixedTabsContainerRef);
tabDrag.setDetachBoundsProvider(() => {
const regular = tabsContainerRef.value?.getBoundingClientRect();
const fixed = fixedTabsContainerRef.value?.getBoundingClientRect();
if (!regular) return fixed;
if (!fixed) return regular;
return new DOMRect(Math.min(regular.left, fixed.left), Math.min(regular.top, fixed.top), Math.max(regular.right, fixed.right) - Math.min(regular.left, fixed.left), Math.max(regular.bottom, fixed.bottom) - Math.min(regular.top, fixed.top));
});
const tabScrollBehavior = ref<ScrollBehavior>("smooth");
function updateAllScrollButtons() {
@@ -773,7 +803,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
</script>
<template>
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" class="app-tab-bar relative flex min-w-0 shrink-0 overflow-hidden" :class="tabBarClass">
<div v-if="queryStore.tabs.length > 0 || driverStoreOpen || settingsPageOpen" data-main-tab-bar class="app-tab-bar relative flex w-full min-w-0 shrink-0 overflow-hidden" :class="[tabBarClass, { 'ring-2 ring-primary ring-inset': detachedDropTarget }]">
<div v-if="isVerticalLayout" class="relative shrink-0 border-b p-2">
<Search class="pointer-events-none absolute left-4 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input v-model="tabSearchQuery" type="search" :placeholder="t('tabs.searchOpenTabs')" class="h-8 w-full pl-7 text-sm" />
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ArrowLeft, Minus, Square, Copy, X, Grip } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { useWindowControls } from "@/composables/useWindowControls";
defineProps<{
title: string;
dirty?: boolean;
}>();
const emit = defineEmits<{
return: [];
close: [];
"drag-start": [position: { x: number; y: number }];
dragging: [position: { x: number; y: number }];
"drag-end": [position: { x: number; y: number }];
}>();
const { t } = useI18n();
const { isMac, isMaximized, minimize, toggleMaximize } = useWindowControls();
let dragging = false;
async function cursorPosition(event: PointerEvent) {
// PointerEvent.screenX/Y are global screen coordinates. Convert the
// browser's logical pixels to physical pixels so they can be compared with
// Tauri's PhysicalPosition on the main window, including mixed-DPI setups.
const scale = typeof window !== "undefined" && Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
return { x: event.screenX * scale, y: event.screenY * scale };
}
async function handleDragStart(event: PointerEvent) {
if (event.button !== 0 || event.pointerType === "touch") return;
event.preventDefault();
event.stopPropagation();
dragging = true;
const target = event.currentTarget as HTMLElement;
target.setPointerCapture?.(event.pointerId);
emit("drag-start", await cursorPosition(event));
}
async function handleDragging(event: PointerEvent) {
if (!dragging) return;
event.preventDefault();
emit("dragging", await cursorPosition(event));
}
async function handleDragEnd(event: PointerEvent) {
if (!dragging) return;
dragging = false;
event.preventDefault();
emit("drag-end", await cursorPosition(event));
}
</script>
<template>
<div class="flex h-10 shrink-0 items-center border-b bg-background/95 text-xs" :class="isMac ? 'pl-20' : ''">
<div class="flex min-w-0 flex-1 items-center gap-2 px-3" data-tauri-drag-region>
<button
type="button"
class="inline-flex h-7 w-6 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground active:cursor-grabbing"
:title="t('tabs.dragBackToMain')"
@pointerdown.stop="handleDragStart"
@pointermove.stop="handleDragging"
@pointerup.stop="handleDragEnd"
@pointercancel.stop="handleDragEnd"
>
<Grip class="h-3.5 w-3.5" />
</button>
<span class="min-w-0 flex-1 truncate font-medium" data-tauri-drag-region> <span v-if="dirty" class="mr-0.5">*</span>{{ title }} </span>
</div>
<button type="button" class="mr-1 inline-flex h-7 items-center gap-1 rounded px-2 text-muted-foreground hover:bg-accent hover:text-foreground" :title="t('tabs.returnToMainWindow')" @pointerdown.stop @click="emit('return')">
<ArrowLeft class="h-3.5 w-3.5" />
<span>{{ t("tabs.returnToMainWindow") }}</span>
</button>
<template v-if="!isMac">
<button type="button" class="inline-flex h-10 w-10 items-center justify-center hover:bg-foreground/10" :title="t('tabs.minimizeWindow')" @click="minimize"><Minus class="h-4 w-4" /></button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center hover:bg-foreground/10" :title="t('tabs.maximizeWindow')" @click="toggleMaximize"><Copy v-if="isMaximized" class="h-3.5 w-3.5" /><Square v-else class="h-3.5 w-3.5" /></button>
<button type="button" class="inline-flex h-10 w-10 items-center justify-center hover:bg-red-500 hover:text-white" :title="t('tabs.closeWindow')" @click="emit('close')"><X class="h-4 w-4" /></button>
</template>
<button v-else type="button" class="mr-2 inline-flex h-7 w-7 items-center justify-center rounded hover:bg-red-500 hover:text-white" :title="t('tabs.closeWindow')" @click="emit('close')"><X class="h-4 w-4" /></button>
</div>
</template>