From 9b7447b44de9d2848fe5b2eaab18ff752b0719a7 Mon Sep 17 00:00:00 2001 From: auth0 Date: Wed, 29 Jul 2026 09:12:01 +0800 Subject: [PATCH 01/10] feat(tabs): move tabs into detached windows  Conflicts:  apps/desktop/src/App.vue --- apps/desktop/src/App.vue | 463 ++++++++++++++---- .../src/components/layout/AppTabBar.vue | 10 +- .../components/layout/DetachedTabWindow.vue | 96 ++++ .../src/composables/useDataGridEditor.ts | 44 ++ apps/desktop/src/i18n/locales/en.ts | 6 + apps/desktop/src/i18n/locales/es.ts | 6 + apps/desktop/src/i18n/locales/it.ts | 6 + apps/desktop/src/i18n/locales/ja.ts | 6 + apps/desktop/src/i18n/locales/pt-BR.ts | 6 + apps/desktop/src/i18n/locales/zh-CN.ts | 6 + apps/desktop/src/i18n/locales/zh-TW.ts | 6 + apps/desktop/src/lib/tabs/tabWindow.ts | 211 ++++++++ .../queryStore.detached-transfer.spec.ts | 82 ++++ apps/desktop/src/stores/queryStore.ts | 112 ++++- src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 19 +- 16 files changed, 987 insertions(+), 94 deletions(-) create mode 100644 apps/desktop/src/components/layout/DetachedTabWindow.vue create mode 100644 apps/desktop/src/lib/tabs/tabWindow.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.detached-transfer.spec.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index a01e7d6410..4622254379 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -2,6 +2,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineAsyncComponent } from "vue"; import { useI18n } from "vue-i18n"; import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import type { UnlistenFn } from "@tauri-apps/api/event"; import { ChevronsRight } from "@lucide/vue"; import { TooltipProvider } from "@/components/ui/tooltip"; import AppToolbar from "@/components/layout/AppToolbar.vue"; @@ -9,6 +11,7 @@ import AppTabBar from "@/components/layout/AppTabBar.vue"; import AppSidebar from "@/components/layout/AppSidebar.vue"; import EditorToolbar from "@/components/layout/EditorToolbar.vue"; import ContentArea from "@/components/layout/ContentArea.vue"; +import DetachedTabWindow from "@/components/layout/DetachedTabWindow.vue"; import AppDialogs from "@/components/layout/AppDialogs.vue"; import WelcomeScreen from "@/components/layout/WelcomeScreen.vue"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; @@ -28,6 +31,7 @@ import { useSqlExecution } from "@/composables/useSqlExecution"; import { useDialogSources } from "@/composables/useDialogSources"; import { useNavigationTargets } from "@/composables/useNavigationTargets"; import { useDataGridActions } from "@/composables/useDataGridActions"; +import { captureDataGridPendingSnapshotsForTab, clearDataGridPendingSnapshotsForTab, restoreDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { useTauriEvents } from "@/composables/useTauriEvents"; import { useCloseActionPrompt, type AppCloseAction, type AppCloseRequestOptions } from "@/composables/useCloseActionPrompt"; import { useVisibilityChange } from "@/composables/useVisibilityChange"; @@ -51,7 +55,8 @@ import { isMacOS, isWindows } from "@/lib/backend/platform"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; -import type { ConnectionConfig, ObjectSourceKind, QueryTab } from "@/types/database"; +import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar"; +import type { ConnectionConfig, ObjectBrowserViewport, ObjectSourceKind, QueryTab } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { isBrowserReloadShortcut, @@ -76,7 +81,8 @@ import { isZoomOutShortcut, switchToTabIndexFromShortcut, } from "@/lib/editor/keyboardShortcuts"; -import { isPreviewTab } from "@/lib/tabs/tabPresentation"; +import { isPreviewTab, tabDisplayTitle } from "@/lib/tabs/tabPresentation"; +import { isDetachedTabWindow, prepareTabWindow, receiveDetachedTab } from "@/lib/tabs/tabWindow"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend"; @@ -156,9 +162,13 @@ const { const { setupFileDrop } = useFileDrop(); const isDesktop = isTauriRuntime(); +const isDetachedWindow = isDetachedTabWindow(); const drawDesktopWindowFrame = shouldDrawDesktopWindowFrame(isMacOS(), isDesktop, isWindows()); const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; let updateCheckTimer: ReturnType | undefined; +let unlistenDetachedTabTransfer: UnlistenFn | undefined; +let unlistenDetachedWindowClose: UnlistenFn | undefined; +let detachedWindowClosing = false; const needsAuth = ref(!isDesktop); const authenticated = ref(isDesktop); const setupRequired = ref(false); @@ -344,9 +354,12 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openConnectionDeepLink, }); const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose }); -useVisibilityChange(); -useWebDavAutoUpload(); -useScheduledDatabaseBackups({ scheduler: true }); +if (!isDetachedWindow) { + // 全局后台任务只能由主窗口调度,否则每个分离窗口都会重复备份和上传。 + useVisibilityChange(); + useWebDavAutoUpload(); + useScheduledDatabaseBackups({ scheduler: true }); +} const appVersion = ref(""); const isClassicLayout = computed(() => settingsStore.editorSettings.appLayout === "classic"); @@ -1894,6 +1907,11 @@ function handleKeydown(e: KeyboardEvent) { } if (isCloseTabShortcut(e, shortcuts)) { e.preventDefault(); + if (isDetachedWindow) { + e.stopPropagation(); + void closeDetachedTab(); + return; + } if (showSettingsPage.value) { closeSettingsPage(); } else if (showDriverStore.value) { @@ -1969,15 +1987,17 @@ function onLoginSuccess() { void initApp(); } -async function initApp() { +async function initApp({ restoreOpenTabs = true }: { restoreOpenTabs?: boolean } = {}) { const t0 = performance.now(); console.log("[STARTUP] initApp begin"); await settingsStore.initAiConfigs(); try { await settingsStore.initEditorSettings(); console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`); - await queryStore.initOpenTabs(); - console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); + if (restoreOpenTabs) { + await queryStore.initOpenTabs(); + console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); + } await settingsStore.initDesktopSettings().catch(() => {}); void promptTemplateStore.init(); @@ -1999,6 +2019,113 @@ async function initApp() { } } +async function openTabWindow(tabId: string) { + const tab = queryStore.tabs.find((item) => item.id === tabId); + if (!tab) return; + if (tab.isExecuting || tab.isCancelling || tab.isExplaining || tab.resultTotalRowCountLoading) { + toast(t("tabs.moveTabWindowBusy"), 5000); + return; + } + + let preparedWindow: Awaited> | undefined; + let persistSuspended = false; + try { + preparedWindow = await prepareTabWindow(tab.id, tabDisplayTitle(tab, t)); + const dataGridSnapshots = captureDataGridPendingSnapshotsForTab(tab.id); + const selection = tab.editorSelection; + const selectionFrom = Math.min(selection?.anchor ?? 0, selection?.head ?? 0); + const selectionTo = Math.max(selection?.anchor ?? 0, selection?.head ?? 0); + const tabRuntimeState = + tab.id === queryStore.activeTabId + ? { + activeOutputView: activeOutputView.value, + selectedSql: selectedSql.value, + cursorPos: cursorPos.value, + } + : { + // 非活动标签没有 App 级运行态;按主窗口重新激活该标签时的 + // 既有语义恢复默认结果视图,并从标签自身的编辑器选区派生状态。 + activeOutputView: "result" as const, + selectedSql: tab.sql.slice(selectionFrom, selectionTo), + cursorPos: selection?.head ?? 0, + }; + queryStore.suspendOpenTabsPersist(); + persistSuspended = true; + const transferState = queryStore.takeTabForTransfer(tab.id); + if (!transferState) { + await queryStore.resumeOpenTabsPersist({ flush: false }); + persistSuspended = false; + await preparedWindow.abort(); + return; + } + try { + // 子窗口确认接收后才提交迁移;失败时恢复原位置和活动标签页。 + await preparedWindow.transfer({ + tab: transferState.tab, + ...tabRuntimeState, + dataGridSnapshots, + }); + } catch (error) { + queryStore.restoreTabFromTransfer(transferState); + await queryStore.resumeOpenTabsPersist().catch(() => {}); + persistSuspended = false; + await preparedWindow.abort(); + throw error; + } + clearDataGridPendingSnapshotsForTab(tab.id); + const resumePersist = queryStore.resumeOpenTabsPersist(); + persistSuspended = false; + let persistError: unknown; + try { + await resumePersist; + } catch (error) { + persistError = error; + for (let attempt = 0; attempt < 2; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 250 * (attempt + 1))); + try { + await queryStore.flushPendingPersist(); + persistError = undefined; + break; + } catch (retryError) { + persistError = retryError; + } + } + } + if (persistError) { + toast(t("tabs.moveTabWindowPersistFailed", { message: persistError instanceof Error ? persistError.message : String(persistError) }), 5000); + } + } catch (e: any) { + if (persistSuspended) await queryStore.resumeOpenTabsPersist().catch(() => {}); + toast(t("tabs.openTabWindowFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function closeDetachedTab() { + if (!isDetachedWindow || detachedWindowClosing) return; + detachedWindowClosing = true; + const detachedWindow = isTauriRuntime() ? getCurrentWebviewWindow() : null; + // 先隐藏以提供即时关闭反馈,但保留 WebView 上下文直到资源清理完成。 + if (detachedWindow) await detachedWindow.hide().catch(() => {}); + const tabId = queryStore.activeTabId; + try { + if (tabId) await queryStore.closeTabAndWait(tabId, { force: true }); + } catch (error) { + // 关闭语义优先:清理失败需要记录,但不能留下不可见且无法再次关闭的窗口。 + console.warn("[DBX][detached-tab:cleanup:error]", { tabId, error }); + } + if (!detachedWindow) { + window.close(); + return; + } + try { + await detachedWindow.close(); + } catch (error) { + detachedWindowClosing = false; + await detachedWindow.show().catch(() => {}); + toast(t("tabs.closeTabWindowFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + } +} + function restoreActiveConnectionContext() { const activeConnectionId = activeTab.value?.connectionId || connectionStore.activeConnectionId; if (activeConnectionId && connectionStore.getConfig(activeConnectionId)) { @@ -2073,6 +2200,30 @@ onMounted(async () => { }); applyTheme(); void applyUiScale(settingsStore.editorSettings.uiScale); + if (isDetachedWindow) { + window.addEventListener("keydown", handleKeydown); + if (isDesktop) document.addEventListener("contextmenu", handleContextMenu); + const detachedWindow = getCurrentWebviewWindow(); + unlistenDetachedWindowClose = await detachedWindow.onCloseRequested(async (event) => { + if (detachedWindowClosing) return; + event.preventDefault(); + await closeDetachedTab(); + }); + await initApp({ restoreOpenTabs: false }); + unlistenDetachedTabTransfer = await receiveDetachedTab((payload) => { + restoreDataGridPendingSnapshotsForTab(payload.tab.id, payload.dataGridSnapshots); + queryStore.adoptTransferredTab(payload.tab); + activeOutputView.value = payload.activeOutputView; + selectedSql.value = payload.selectedSql; + cursorPos.value = payload.cursorPos; + restoreActiveConnectionContext(); + return () => { + queryStore.takeTabForTransfer(payload.tab.id); + clearDataGridPendingSnapshotsForTab(payload.tab.id); + }; + }); + return; + } window.addEventListener("keydown", handleKeydown); window.addEventListener("dbx-open-driver-store", openDriverStoreFromEvent); if (isDesktop) { @@ -2133,6 +2284,8 @@ onMounted(async () => { }); onUnmounted(() => { + unlistenDetachedTabTransfer?.(); + unlistenDetachedWindowClose?.(); cleanupTauriListeners(); cleanupCloseActionPromptListener(); if (updateCheckTimer) { @@ -2145,8 +2298,138 @@ onUnmounted(() => { -
- -
+
diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index e242ff5a1c..499834abf9 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -12,6 +12,7 @@ import AppSidebar from "@/components/layout/AppSidebar.vue"; import EditorToolbar from "@/components/layout/EditorToolbar.vue"; import ContentArea from "@/components/layout/ContentArea.vue"; import DetachedTabWindow from "@/components/layout/DetachedTabWindow.vue"; +import DetachedWindowShell from "@/components/layout/DetachedWindowShell.vue"; import AppDialogs from "@/components/layout/AppDialogs.vue"; import WelcomeScreen from "@/components/layout/WelcomeScreen.vue"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; @@ -82,7 +83,18 @@ import { switchToTabIndexFromShortcut, } from "@/lib/editor/keyboardShortcuts"; import { isPreviewTab, tabDisplayTitle } from "@/lib/tabs/tabPresentation"; -import { checkDetachedWindowsBeforeAppClose, destroyDetachedWindowAfterCleanup, focusDetachedTabWindow, isDetachedTabWindow, listenForDetachedAppCloseChecks, listenForDetachedTabMainWindowActions, prepareTabWindow, receiveDetachedTab, type DetachedTabMainWindowAction } from "@/lib/tabs/tabWindow"; +import { + checkDetachedWindowsBeforeAppClose, + destroyDetachedWindowAfterCleanup, + focusDetachedTabWindow, + isDetachedTabWindow, + listenForDetachedAppCloseChecks, + listenForDetachedTabMainWindowActions, + prepareTabWindow, + receiveDetachedTab, + requestDetachedTabMainWindowAction, + type DetachedTabMainWindowAction, +} from "@/lib/tabs/tabWindow"; import type { TabWindowClientPlacement } from "@/lib/tabs/tabWindowPlacement"; import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities"; import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy"; @@ -359,7 +371,11 @@ function rejectDetachedTabCreation(): boolean { } async function openTableTarget(...args: Parameters) { - if (rejectDetachedTabCreation()) return; + const [target, options] = args; + if (isDetachedWindow) { + // A detached window keeps one owner by replacing its current tab in place. + return openTableTargetInMainWindow(target, { ...options, replaceActiveInDetached: true }); + } return openTableTargetInMainWindow(...args); } @@ -718,6 +734,12 @@ function handleDetachedMainWindowAction(action: DetachedTabMainWindowAction) { } } +function forwardToMainWindow(action: DetachedTabMainWindowAction) { + void requestDetachedTabMainWindowAction(action).catch((error) => { + toast(t("tabs.openTabWindowFailed", { message: error instanceof Error ? error.message : String(error) }), 5000); + }); +} + function openAiPanel() { openRightSidebarPanel("ai"); } @@ -1537,11 +1559,14 @@ function onViewTableDdl(table: SqlObjectNavigationTarget) { showQueryEditorDdlDialog.value = true; } -function onEditTableStructure(table: SqlObjectNavigationTarget) { - if (rejectDetachedTabCreation()) return; +async function onEditTableStructure(table: SqlObjectNavigationTarget) { const target = tableTargetFromActiveTab(table); // Keep view-like objects out of the table editor even if a stale menu dispatches this event. if (!target || sqlObjectNavigationSourceKind(table)) return; + if (isDetachedWindow) { + await connectionStore.ensureConnected(target.connectionId); + await queryStore.replaceActiveTabForDetachedNavigation(); + } queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName, undefined, undefined, target.catalog); } @@ -2094,19 +2119,40 @@ async function initApp({ restoreOpenTabs = true }: { restoreOpenTabs?: boolean } } } -async function openTabWindow(tabId: string, placement?: TabWindowClientPlacement) { - const tab = queryStore.tabs.find((item) => item.id === tabId); - if (!tab) return; - const isBusy = (item: QueryTab) => !!(item.isExecuting || item.isCancelling || item.isExplaining || item.resultTotalRowCountLoading); - if (isBusy(tab)) { - toast(t("tabs.moveTabWindowBusy"), 5000); - return; - } +async function initDetachedApp() { + const t0 = performance.now(); + console.log("[STARTUP] initDetachedApp begin"); + + // Only state required to render and operate the transferred tab blocks + // acceptance. Main-window-only and optional services continue in parallel. + await Promise.all([settingsStore.initEditorSettings(), connectionStore.initFromDisk()]); + console.log(`[STARTUP] detached core state: ${(performance.now() - t0).toFixed(0)}ms`); + restoreActiveConnectionContext(); + void settingsStore.initAiConfigs().catch((error) => console.warn("[DBX][detached-tab:ai-config:init:error]", error)); + void settingsStore.initDesktopSettings().catch((error) => console.warn("[DBX][detached-tab:desktop-settings:init:error]", error)); + void promptTemplateStore.init().catch((error) => console.warn("[DBX][detached-tab:prompt-templates:init:error]", error)); + void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()]) + .then(() => queryStore.hydrateSavedSqlTabs()) + .catch((error) => console.warn("[DBX][detached-tab:saved-sql:init:error]", error)); +} + +async function openTabWindow(tabId: string, placement?: TabWindowClientPlacement, releasePreview?: () => void) { let preparedWindow: Awaited> | undefined; let persistSuspended = false; try { - preparedWindow = await prepareTabWindow(tab.id, tabDisplayTitle(tab, t), placement); + const tab = queryStore.tabs.find((item) => item.id === tabId); + if (!tab) return; + const isBusy = (item: QueryTab) => !!(item.isExecuting || item.isCancelling || item.isExplaining || item.resultTotalRowCountLoading); + if (isBusy(tab)) { + toast(t("tabs.moveTabWindowBusy"), 5000); + return; + } + + preparedWindow = await prepareTabWindow(tab.id, tabDisplayTitle(tab, t), { + placement, + onWindowShown: releasePreview, + }); const currentTab = queryStore.tabs.find((item) => item.id === tab.id); if (!currentTab || isBusy(currentTab)) { await preparedWindow.abort(); @@ -2181,6 +2227,9 @@ async function openTabWindow(tabId: string, placement?: TabWindowClientPlacement } catch (e: any) { if (persistSuspended) await queryStore.resumeOpenTabsPersist().catch(() => {}); toast(t("tabs.openTabWindowFailed", { message: e?.message || String(e) }), 5000); + } finally { + // Startup errors and early exits must also release the retained drag preview. + releasePreview?.(); } } @@ -2295,22 +2344,27 @@ onMounted(async () => { event.preventDefault(); await closeDetachedTab(); }); - await initApp({ restoreOpenTabs: false }); + // Register lifecycle listeners before optional startup work so a visible + // shell can always answer close checks and receive ownership immediately. unlistenDetachedAppCloseCheck = await listenForDetachedAppCloseChecks(() => queryStore.hasDirtyTabs); - unlistenDetachedTabTransfer = await receiveDetachedTab((payload) => { - restoreDataGridPendingSnapshotsForTab(payload.tab.id, payload.dataGridSnapshots); - queryStore.adoptTransferredTab(payload.tab); - activeOutputView.value = payload.activeOutputView; - selectedSql.value = payload.selectedSql; - cursorPos.value = payload.cursorPos; - explainMode.value = payload.explainMode; - blockDangerousRedisCommands.value = payload.blockDangerousRedisCommands; - restoreActiveConnectionContext(); - return () => { - queryStore.takeTabForTransfer(payload.tab.id); - clearDataGridPendingSnapshotsForTab(payload.tab.id); - }; - }); + const detachedInitialization = initDetachedApp(); + unlistenDetachedTabTransfer = await receiveDetachedTab( + (payload) => { + restoreDataGridPendingSnapshotsForTab(payload.tab.id, payload.dataGridSnapshots); + queryStore.adoptTransferredTab(payload.tab); + activeOutputView.value = payload.activeOutputView; + selectedSql.value = payload.selectedSql; + cursorPos.value = payload.cursorPos; + explainMode.value = payload.explainMode; + blockDangerousRedisCommands.value = payload.blockDangerousRedisCommands; + restoreActiveConnectionContext(); + return () => { + queryStore.takeTabForTransfer(payload.tab.id); + clearDataGridPendingSnapshotsForTab(payload.tab.id); + }; + }, + { initialization: detachedInitialization }, + ); return; } window.addEventListener("keydown", handleKeydown); @@ -2390,7 +2444,9 @@ onUnmounted(() => {