From d38be39e03ea06b6f9942d64f5092431dc802787 Mon Sep 17 00:00:00 2001 From: DengQingNian <117324885+DengQingNian@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:45:08 +0800 Subject: [PATCH] feat(tabs): detach SQL and data tabs into separate windows --- apps/desktop/src/App.vue | 345 +++++++++++++++++- .../src/components/layout/AppTabBar.vue | 40 +- .../components/layout/DetachedTabHeader.vue | 83 +++++ .../composables/__tests__/useTabDrag.spec.ts | 62 +++- apps/desktop/src/composables/useTabDrag.ts | 29 +- apps/desktop/src/i18n/locales/en.ts | 10 + apps/desktop/src/i18n/locales/zh-CN.ts | 10 + .../desktop/src/lib/app/detachedTabHandoff.ts | 24 ++ apps/desktop/src/lib/app/detachedTabWindow.ts | 77 ++++ apps/desktop/src/lib/app/windowContext.ts | 46 +++ apps/desktop/src/lib/backend/api.ts | 5 + apps/desktop/src/lib/backend/http.ts | 15 + apps/desktop/src/lib/backend/tauri.ts | 21 ++ apps/desktop/src/stores/queryStore.ts | 108 +++++- src-tauri/capabilities/default.json | 7 +- src-tauri/src/commands/app_settings.rs | 74 +++- src-tauri/src/lib.rs | 22 ++ 17 files changed, 957 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/components/layout/DetachedTabHeader.vue create mode 100644 apps/desktop/src/lib/app/detachedTabHandoff.ts create mode 100644 apps/desktop/src/lib/app/detachedTabWindow.ts create mode 100644 apps/desktop/src/lib/app/windowContext.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5eb7a393fc..b02b6247ae 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -9,6 +9,7 @@ import AppSidebar from "@/components/layout/AppSidebar.vue"; import EditorToolbar from "@/components/layout/EditorToolbar.vue"; import ContentArea from "@/components/layout/ContentArea.vue"; import AppDialogs from "@/components/layout/AppDialogs.vue"; +import DetachedTabHeader from "@/components/layout/DetachedTabHeader.vue"; import WelcomeScreen from "@/components/layout/WelcomeScreen.vue"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; import { useConnectionStore } from "@/stores/connectionStore"; @@ -137,6 +138,8 @@ import { SearchableSelect } from "@/components/ui/searchable-select"; import type { HistoryEntry } from "@/lib/backend/tauri"; import { resolveDefaultAiSchema, type AiAction } from "@/lib/ai/ai"; import ExternalSqlFileChangeDialog from "@/components/editor/ExternalSqlFileChangeDialog.vue"; +import { resolveWindowContext } from "@/lib/app/windowContext"; +import { openDetachedTabWindow } from "@/lib/app/detachedTabWindow"; const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue")); const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue")); @@ -219,6 +222,9 @@ const { const { setupFileDrop } = useFileDrop(); const isDesktop = isTauriRuntime(); +const windowContext = resolveWindowContext(); +const isDetachedWindowContext = windowContext.kind === "detached-tab"; +const detachedContextTabId = windowContext.kind === "detached-tab" ? windowContext.tabId : undefined; const activeAiRunCount = computed(() => (isDesktop ? activeDesktopAiRuns().length : 0)); /** Runs waiting for a write confirmation — the panel-entry badge shows these * with a higher-priority indicator (parent PRD §4 line 71 / §9). */ @@ -328,6 +334,267 @@ let aiRunsQuitConfirmed = false; const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId)); const tabNavigationHistory = ref(createTabNavigationHistory()); let pendingTabHistoryNavigationId: string | null = null; +let detachedEventUnlisteners: Array<() => void> = []; +let detachedCloseInProgress = false; +const detachedDropTargetTabId = ref(null); +const showDetachedClosePrompt = ref(false); + +function isDetachableTab(tab: QueryTab | undefined): tab is QueryTab { + return !!tab && (tab.mode === "query" || tab.mode === "data"); +} + +async function emitDetachedEvent(event: string, payload: unknown) { + if (!isDesktop) return; + const { emit } = await import("@tauri-apps/api/event"); + await emit(event, payload); +} + +async function isPointOverMainTabBar(position: { x: number; y: number }): Promise { + if (isDetachedWindowContext) return false; + const tabBar = document.querySelector("[data-main-tab-bar]"); + if (!tabBar) return false; + try { + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + const currentWindow = getCurrentWindow(); + const [innerPosition, scaleFactor] = await Promise.all([currentWindow.innerPosition(), currentWindow.scaleFactor()]); + const rect = tabBar.getBoundingClientRect(); + const safeScale = Number.isFinite(scaleFactor) && scaleFactor > 0 ? scaleFactor : 1; + const left = innerPosition.x + rect.left * safeScale; + const top = innerPosition.y + rect.top * safeScale; + const right = innerPosition.x + rect.right * safeScale; + const bottom = innerPosition.y + rect.bottom * safeScale; + return position.x >= left && position.x <= right && position.y >= top && position.y <= bottom; + } catch { + return false; + } +} + +async function handleDetachedTabDragging(payload: unknown) { + if (isDetachedWindowContext) return; + const data = payload as { tabId?: unknown; x?: unknown; y?: unknown } | null; + if (typeof data?.tabId !== "string" || typeof data.x !== "number" || typeof data.y !== "number") return; + detachedDropTargetTabId.value = (await isPointOverMainTabBar({ x: data.x, y: data.y })) ? data.tabId : null; +} + +async function handleDetachedTabDropped(payload: unknown) { + if (isDetachedWindowContext) return; + const data = payload as { tabId?: unknown; x?: unknown; y?: unknown } | null; + const pointerIsOverTabBar = typeof data?.x === "number" && typeof data.y === "number" ? await isPointOverMainTabBar({ x: data.x, y: data.y }) : false; + const shouldReturn = typeof data?.tabId === "string" && (detachedDropTargetTabId.value === data.tabId || pointerIsOverTabBar); + detachedDropTargetTabId.value = null; + if (!shouldReturn) return; + await handleDetachedReturnRequested(payload); +} + +async function finishDetachedWindowClose() { + if (!isDetachedWindowContext) return; + detachedCloseInProgress = true; + await api.approveDetachedWindowClose().catch(() => {}); + const { getCurrentWindow } = await import("@tauri-apps/api/window"); + await getCurrentWindow() + .close() + .catch(() => {}); +} + +async function requestDetachedReturn(reason: "return" | "close" = "return") { + if (!isDetachedWindowContext || !detachedContextTabId || detachedCloseInProgress) return; + const tab = activeTab.value; + if (!tab) { + if (reason === "close") await closeDetachedTabAndWindow(); + return; + } + if (reason === "close" && queryStore.isTabDirty(tab)) { + showDetachedClosePrompt.value = true; + return; + } + if (reason === "close") { + await closeDetachedTabAndWindow(); + return; + } + await persistDetachedReturnRequest(reason); +} + +async function closeDetachedTabAndWindow() { + if (!detachedContextTabId) return; + try { + // X means explicitly discard this detached tab. Delete the durable + // handoff before closing so a subsequent startup/lost-window recovery + // cannot resurrect a tab the user chose to remove. + await api.deleteDetachedTabHandoff(detachedContextTabId); + showDetachedClosePrompt.value = false; + await finishDetachedWindowClose(); + } catch (error: any) { + detachedCloseInProgress = false; + toast(error?.message || String(error), 5000); + } +} + +async function persistDetachedReturnRequest(reason: "return" | "close") { + if (!detachedContextTabId) return; + try { + const handoff = await queryStore.prepareDetachedTab(detachedContextTabId, { activeOutputView: activeOutputView.value }); + await api.saveDetachedTabHandoff(detachedContextTabId, handoff); + detachedCloseInProgress = true; + await emitDetachedEvent("dbx:detached-tab-return-requested", { tabId: detachedContextTabId, revision: handoff.revision, reason }); + } catch (error: any) { + detachedCloseInProgress = false; + toast(error?.message || String(error), 5000); + } +} + +async function saveDetachedTabBeforeClose() { + if (!detachedContextTabId) return; + const tab = activeTab.value; + if (!tab) return; + const saved = await saveTabForCloseAll(detachedContextTabId); + if (!saved || queryStore.isTabDirty(tab)) return; + showDetachedClosePrompt.value = false; + await closeDetachedTabAndWindow(); +} + +async function discardDetachedTabBeforeClose() { + if (!detachedContextTabId) return; + queryStore.discardTabChanges(detachedContextTabId); + showDetachedClosePrompt.value = false; + await closeDetachedTabAndWindow(); +} + +function cancelDetachedTabClose() { + showDetachedClosePrompt.value = false; +} + +async function handleDetachedHeaderDragStart(position: { x: number; y: number }) { + if (!detachedContextTabId) return; + await emitDetachedEvent("dbx:detached-tab-dragging", { tabId: detachedContextTabId, x: position.x, y: position.y }); +} + +async function handleDetachedHeaderDragging(position: { x: number; y: number }) { + if (!detachedContextTabId) return; + await emitDetachedEvent("dbx:detached-tab-dragging", { tabId: detachedContextTabId, x: position.x, y: position.y }); +} + +async function handleDetachedHeaderDragEnd(position: { x: number; y: number }) { + if (!detachedContextTabId) return; + try { + const handoff = await queryStore.prepareDetachedTab(detachedContextTabId, { activeOutputView: activeOutputView.value }); + await api.saveDetachedTabHandoff(detachedContextTabId, handoff); + await emitDetachedEvent("dbx:detached-tab-dropped", { tabId: detachedContextTabId, revision: handoff.revision, x: position.x, y: position.y }); + } catch (error: any) { + toast(error?.message || String(error), 5000); + } +} + +async function handleDetachedCloseRequested(payload: unknown) { + const tabId = (payload as { tabId?: unknown } | null)?.tabId; + if (typeof tabId !== "string" || tabId !== detachedContextTabId) return; + await requestDetachedReturn("close"); +} + +async function handleDetachedReturnComplete(payload: unknown) { + const tabId = (payload as { tabId?: unknown } | null)?.tabId; + if (!isDetachedWindowContext || tabId !== detachedContextTabId) return; + await finishDetachedWindowClose(); +} + +async function handleDetachedTabReady(payload: unknown) { + if (isDetachedWindowContext) return; + const data = payload as { tabId?: unknown; revision?: unknown } | null; + if (typeof data?.tabId !== "string") return; + const handoff = await api.loadDetachedTabHandoff(data.tabId).catch(() => null); + if (!handoff || handoff.tabId !== data.tabId) return; + if (typeof data.revision === "number" && handoff.revision !== data.revision) return; + queryStore.removeTabAfterDetachedReady(data.tabId); + await queryStore.flushPendingPersist(); +} + +async function handleDetachedTabLost(payload: unknown) { + if (isDetachedWindowContext) return; + const tabId = (payload as { tabId?: unknown } | null)?.tabId; + if (typeof tabId !== "string") return; + const handoff = await api.loadDetachedTabHandoff(tabId).catch(() => null); + if (!handoff || handoff.tabId !== tabId) return; + try { + await queryStore.adoptDetachedTab(handoff); + // Persist the adopted tab before dropping the durable handoff so an + // interrupted shutdown cannot lose it from both stores. + await queryStore.flushPendingPersist(); + await api.deleteDetachedTabHandoff(tabId); + } catch (error) { + console.warn("[DBX][detached-tab:lost-restore:error]", error); + } +} + +async function handleDetachedReturnRequested(payload: unknown) { + if (isDetachedWindowContext) return; + const data = payload as { tabId?: unknown; revision?: unknown } | null; + if (typeof data?.tabId !== "string") return; + const handoff = await api.loadDetachedTabHandoff(data.tabId).catch(() => null); + if (!handoff || handoff.tabId !== data.tabId) return; + if (typeof data.revision === "number" && handoff.revision !== data.revision) return; + try { + await queryStore.adoptDetachedTab(handoff); + // Persist before deleting the handoff so the returned tab exists in at + // least one durable store at every point of the flow. + await queryStore.flushPendingPersist(); + await api.deleteDetachedTabHandoff(data.tabId); + await emitDetachedEvent("dbx:detached-tab-return-complete", { tabId: data.tabId }); + } catch (error: any) { + toast(error?.message || String(error), 5000); + } +} + +async function initDetachedWindow() { + if (!isDetachedWindowContext || !detachedContextTabId) return; + await settingsStore.initEditorSettings(); + await connectionStore.initFromDisk(); + const handoff = await api.loadDetachedTabHandoff(detachedContextTabId); + if (!handoff) { + toast(t("tabs.detachedTabUnavailable"), 5000); + await finishDetachedWindowClose(); + return; + } + await queryStore.adoptDetachedTab(handoff); + await queryStore.hydrateSavedSqlTabs(); + if (handoff.runtime.activeOutputView) activeOutputView.value = handoff.runtime.activeOutputView; + const { emit } = await import("@tauri-apps/api/event"); + await emit("dbx:detached-tab-ready", { tabId: detachedContextTabId, revision: handoff.revision }); +} + +async function setupDetachedWindowEvents() { + if (!isDesktop) return; + const { listen } = await import("@tauri-apps/api/event"); + const events: Array<[string, (payload: unknown) => Promise]> = isDetachedWindowContext + ? [ + ["dbx:detached-tab-close-requested", handleDetachedCloseRequested], + ["dbx:detached-tab-return-complete", handleDetachedReturnComplete], + ] + : [ + ["dbx:detached-tab-ready", handleDetachedTabReady], + ["dbx:detached-tab-return-requested", handleDetachedReturnRequested], + ["dbx:detached-tab-dragging", handleDetachedTabDragging], + ["dbx:detached-tab-dropped", handleDetachedTabDropped], + ["dbx:detached-tab-lost", handleDetachedTabLost], + ]; + for (const [event, handler] of events) { + detachedEventUnlisteners.push(await listen(event, (message) => void (handler as (payload: unknown) => Promise)(message.payload))); + } +} + +async function detachTab(tab: QueryTab, position?: { x: number; y: number }) { + if (isDetachedWindowContext || !isDetachableTab(tab)) return; + try { + const handoff = await queryStore.prepareDetachedTab(tab.id, { activeOutputView: tab.id === queryStore.activeTabId ? activeOutputView.value : "result" }); + await api.saveDetachedTabHandoff(tab.id, handoff); + const result = await openDetachedTabWindow(tab.id, tab.title, position); + if (!result.opened) { + await api.deleteDetachedTabHandoff(tab.id); + toast(result.error ? `${t("tabs.openInNewWindowFailed")} ${result.error}` : t("tabs.openInNewWindowFailed"), 7000); + } + } catch (error: any) { + await api.deleteDetachedTabHandoff(tab.id).catch(() => {}); + toast(error?.message || String(error), 5000); + } +} watch( () => activeTab.value?.mode, @@ -336,6 +603,12 @@ watch( }, ); +watch(activeOutputView, (view) => { + if (isDetachedWindowContext && detachedContextTabId) { + void queryStore.flushDetachedTabPersistence(detachedContextTabId, { activeOutputView: view }).catch(() => {}); + } +}); + function toggleZenMode() { if (activeTab.value?.mode !== "data") return; isZenMode.value = !isZenMode.value; @@ -2915,6 +3188,15 @@ watch(updateNotificationsEnabled, (enabled) => { onMounted(async () => { console.log("[STARTUP] onMounted begin"); const mountStart = performance.now(); + if (isDetachedWindowContext) { + await setupDetachedWindowEvents(); + await initDetachedWindow().catch((error) => { + console.error("[STARTUP] detached window initialization failed", error); + toast(error?.message || String(error), 5000); + void finishDetachedWindowClose(); + }); + return; + } requestAnimationFrame(() => { aiPanelReady.value = true; }); @@ -2982,6 +3264,7 @@ onMounted(async () => { .catch(() => {}); setupTauriListeners(); setupCloseActionPromptListener(); + void setupDetachedWindowEvents(); void openPendingSqlFiles(); void openPendingDbFiles(); void openPendingConnectionLinks(); @@ -2990,6 +3273,8 @@ onMounted(async () => { }); onUnmounted(() => { + detachedEventUnlisteners.forEach((unlisten) => unlisten()); + detachedEventUnlisteners = []; cleanupTauriListeners(); cleanupCloseActionPromptListener(); if (updateCheckTimer) { @@ -3017,6 +3302,7 @@ onUnmounted(() => {
{ @open-data-compare="dialogs.showDataCompareDialog.value = true" /> -
+
{ @open-settings="(initialTab) => openSettings(initialTab ?? 'appearance')" @add-to-ai="addToAi" /> -
+
-
-
+
+
{ @save-all-tab-close="handleSaveAllPendingTabClose" @discard-all-tab-close="handleDiscardAllPendingTabClose" @cancel-tab-close="cancelPendingAppClose" + @detach-tab="detachTab" /> + + + + + {{ t("tabs.detachedCloseTitle") }} + +

{{ t("tabs.detachedCloseMessage") }}

+ + + + + +
+
{
{
-
+
-
+
-
+
diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index 1f5f449696..c44d6a85f6 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -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(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("smooth"); function updateAllScrollButtons() { @@ -773,7 +803,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul