diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 72823d3d36..b7c8e12f8d 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -22,6 +22,7 @@ import WelcomeScreen from "@/components/layout/WelcomeScreen.vue"; import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; +import { useNavigationStore } from "@/stores/navigationStore"; import { useSqlExecutionDangerStore } from "@/stores/sqlExecutionDangerStore"; import type { MultiDbExecutionContext } from "@/composables/useMultiDbExecution"; import type { MultiDbExecutionTarget } from "@/types/sqlExecution"; @@ -96,7 +97,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { activeTabExternalSqlFileTarget, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, resolveExternalSqlFileTargetForActiveTab, unassociatedExternalSqlFileTarget, type ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import { defaultSavedQueryFileName, externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, queryEditorOpenFileAccept, queryEditorOpenFileFilters, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; -import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database"; +import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TableInfoTab, TabOutputView, TreeNode } from "@/types/database"; import { OPEN_PLUGIN_SETTINGS, type PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation"; import { parsePluginInstallDeepLink } from "@/lib/plugins/pluginInstallDeepLink"; import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin"; @@ -104,6 +105,10 @@ import { parseConnectionDeepLink, parseConnectionDeepLinkUpdate, type Connection import { resolveConnectionDeepLinkUpdate } from "@/lib/connection/connectionDeepLinkUpdate"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry"; +import type { GlobalNavigationEntry, GlobalNavigationKind, GlobalNavigationSurface } from "@/lib/navigation/navigationEntry"; +import { navigationEntryKey } from "@/lib/navigation/navigationEntry"; +import { canRestoreQueryNavigationEntry, captureSourceNavigationIdentity, matchesNavigationDataTab, navigationSourceTarget, navigationTableTarget, type SourceNavigationIdentity } from "@/lib/navigation/navigationRestoreTargets"; +import { restoreGlobalNavigationEntry } from "@/lib/navigation/globalNavigationExecutor"; import { isBrowserReloadShortcut, @@ -138,7 +143,6 @@ import { switchToTabIndexFromShortcut, tabSwitcherDirectionFromShortcut, } from "@/lib/editor/keyboardShortcuts"; -import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory"; import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget"; import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher"; import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard"; @@ -169,7 +173,7 @@ import { objectBrowserTablesToAiTreeNodes } from "@/lib/ai/objectBrowserToAiTarg import { aiTargetFromTab, type AiConversationBinding } from "@/lib/ai/aiConversationBinding"; import type { AiExternalContextRequest } from "@/lib/ai/aiExternalContext"; import { isSchemaAware, isSingleDatabase, supportsConnectionQueryActions, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; -import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; +import { codeMirrorSqlDialect, connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { canFormatSqlForDatabaseType, formatSqlForEditing, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; import { formatSqlSnapshotForSave } from "@/lib/sql/sqlFormatOnSave"; import { detectAndFormatStructured } from "@/lib/sql/autoFormat"; @@ -227,6 +231,7 @@ const { t, locale: appLocale } = useI18n(); const startupProps = defineProps<{ startupAuthentication?: StartupAuthentication }>(); const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); +const navigationStore = useNavigationStore(); const settingsStore = useSettingsStore(); const { active: appBackgroundActive, backgroundObjectUrl: appBackgroundObjectUrl, backgroundImageStyle: appBackgroundImageStyle } = useBackgroundImage(settingsStore); const { uiFontFamilyPreview } = useUiFontFamilyPreview(); @@ -537,8 +542,6 @@ function updatePluginAiRecommendations(tabId: string, update: PluginAiRecommenda const mountedPluginWorkbenchTabs = computed(() => queryStore.tabs.filter((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench)); type PluginWorkbenchTabHandle = { refresh: () => Promise }; const pluginWorkbenchTabRefs = new Map(); -const tabNavigationHistory = ref(createTabNavigationHistory()); -let pendingTabHistoryNavigationId: string | null = null; let detachedEventUnlisteners: Array<() => void> = []; let floatingEventUnlisteners: Array<() => void> = []; let detachedCloseInProgress = false; @@ -1724,15 +1727,6 @@ watch( }), ); } - if (id) { - if (pendingTabHistoryNavigationId === id) pendingTabHistoryNavigationId = null; - else { - pendingTabHistoryNavigationId = null; - tabNavigationHistory.value = recordTabVisit(tabNavigationHistory.value, id); - } - } else { - pendingTabHistoryNavigationId = null; - } if (id) newQueryContextSource.value = "tab"; if (id) activateQuerySurface(); else if (previousId) activateOpenSpecialPageFallback(); @@ -1745,6 +1739,94 @@ watch( }, ); +const sourceNavigationIdentity = new Map(); + +function currentGlobalNavigationEntry(): GlobalNavigationEntry | null { + let surface: GlobalNavigationSurface | null = null; + if (showSettingsPage.value) surface = "settings"; + else if (showDriverStore.value) surface = "driverStore"; + else if (showPluginCenter.value) surface = "pluginCenter"; + else if (queryStore.activeTabId) surface = "query"; + if (!surface) return null; + const tab = surface === "query" ? activeTab.value : undefined; + if (surface === "query" && !tab) return null; + // Keep the request identity for read-only sources, which never receive objectSource. + for (const id of sourceNavigationIdentity.keys()) { + if (!queryStore.tabs.some((item) => item.id === id)) sourceNavigationIdentity.delete(id); + } + const sourceIdentity = tab ? captureSourceNavigationIdentity(tab, sourceNavigationIdentity.get(tab.id)) : undefined; + if (tab && sourceIdentity) sourceNavigationIdentity.set(tab.id, sourceIdentity); + if (tab?.sourceView && !tab.ddlViewer && !sourceIdentity) return null; + const kind: GlobalNavigationKind = + surface !== "query" + ? "special" + : tab?.ddlViewer + ? "ddl" + : tab?.sourceView || tab?.objectSource + ? "objectSource" + : tab?.mode === "data" || (tab?.mode === "mongo" && tab.tableMeta?.tableName && connectionStore.getConfig(tab.connectionId)?.db_type === "mongodb") + ? "data" + : tab?.mode === "structure" + ? "structure" + : "query"; + const entry: GlobalNavigationEntry = { + id: "", + surface, + kind, + tabId: tab?.id, + title: tab?.title, + mode: tab?.mode, + tableInfoTab: tab?.tableInfoTab, + sourceView: tab?.sourceView, + initialEditing: sourceIdentity?.initialEditing, + connectionId: tab?.connectionId, + database: tab?.tableMeta?.database ?? tab?.database, + catalog: tab?.tableMeta?.catalog ?? tab?.catalog, + schema: tab?.objectSource?.schema ?? tab?.tableMeta?.schema ?? tab?.schema, + tableName: tab?.tableMeta?.tableName ?? tab?.structureTableName ?? tab?.ddlViewer?.tableName, + tableType: tab?.tableMeta?.tableType, + objectName: sourceIdentity?.name ?? tab?.ddlViewer?.tableName, + objectType: sourceIdentity?.objectType ?? tab?.ddlViewer?.objectType, + objectSignature: sourceIdentity?.signature, + }; + entry.id = navigationEntryKey(entry); + return entry; +} + +watch( + () => + [ + queryStore.activeTabId, + showSettingsPage.value, + showDriverStore.value, + showPluginCenter.value, + activeTab.value?.mode, + activeTab.value?.tableInfoTab, + activeTab.value?.sourceView, + activeTab.value?.connectionId, + activeTab.value?.database, + activeTab.value?.catalog, + activeTab.value?.schema, + activeTab.value?.sourceLoad, + activeTab.value?.sourceLoad?.initialEditing, + activeTab.value?.objectSource?.name, + activeTab.value?.objectSource?.objectType, + activeTab.value?.objectSource?.signature, + activeTab.value?.ddlViewer?.tableName, + activeTab.value?.structureTableName, + activeTab.value?.tableMeta?.schema, + activeTab.value?.tableMeta?.catalog, + activeTab.value?.tableMeta?.database, + activeTab.value?.tableMeta?.tableName, + activeTab.value?.tableMeta?.tableType, + ] as const, + () => { + const entry = currentGlobalNavigationEntry(); + navigationStore.record(entry); + }, + { immediate: true }, +); + watch( () => connectionStore.selectedTreeNodeId, (id) => { @@ -3769,17 +3851,130 @@ function activateAdjacentTab(direction: -1 | 1): boolean { return activateTabByIndex(nextIndex); } -function activateTabFromHistory(direction: -1 | 1): boolean { - const move = moveInTabNavigationHistory(tabNavigationHistory.value, direction, new Set(queryStore.tabs.map((tab) => tab.id)), queryStore.activeTabId); - if (!move) return false; +function isGlobalNavigationEntryAvailable(candidate: GlobalNavigationEntry): boolean { + if (candidate.surface === "query") { + if (candidate.kind === "objectSource") return navigationSourceTarget(candidate) !== null; + if (candidate.kind === "data" || candidate.kind === "structure") return navigationTableTarget(candidate) !== null; + return Boolean(candidate.tabId && queryStore.tabs.some((tab) => tab.id === candidate.tabId)); + } + if (candidate.surface === "settings") return settingsPageTabOpen.value; + if (candidate.surface === "driverStore") return driverStoreTabOpen.value; + if (candidate.surface === "pluginCenter") return pluginCenterTabOpen.value; + return false; +} - const previousHistory = tabNavigationHistory.value; - tabNavigationHistory.value = move.history; - pendingTabHistoryNavigationId = move.tabId; - if (activateQueryTab(move.tabId)) return true; +function canRestoreGlobalNavigationEntry(entry: GlobalNavigationEntry): boolean { + return canRestoreQueryNavigationEntry(entry, queryStore.tabs, activeTab.value, settingsStore.editorSettings.dataTabReuseMode); +} - tabNavigationHistory.value = previousHistory; - pendingTabHistoryNavigationId = null; +// Decide synchronously while the keyboard event is still dispatching. Waiting +// for I/O before preventDefault cannot stop the event's native action. +function navigateGlobal(direction: -1 | 1): boolean { + if (navigationStore.restoring) return false; + const candidate = navigationStore.peek(direction, isGlobalNavigationEntryAvailable); + if (!candidate || !canRestoreGlobalNavigationEntry(candidate)) return false; + const previousHistory = navigationStore.snapshot(); + const entry = navigationStore.move(direction, isGlobalNavigationEntryAvailable); + if (!entry) return false; + const restoreSerial = navigationStore.beginRestore(entry, previousHistory); + void completeGlobalNavigation(entry, previousHistory, restoreSerial); + return true; +} + +async function completeGlobalNavigation(entry: GlobalNavigationEntry, previousHistory: ReturnType, restoreSerial: number): Promise { + const isCurrent = () => navigationStore.isCurrentRestore(restoreSerial); + const accept = () => { + const actual = currentGlobalNavigationEntry(); + return actual !== null && navigationStore.acceptRestore(restoreSerial, actual); + }; + try { + const restored = await restoreGlobalNavigationEntry(entry, { + restoreQuery: (target) => restoreQueryNavigationEntry(target, isCurrent, accept), + activateSettings: () => { + activateSettingsPage(); + accept(); + }, + activateDriverStore: () => { + openDriverStorePage(); + accept(); + }, + activatePluginCenter: () => { + openPluginCenterPage(); + accept(); + }, + }); + if (!isCurrent()) return false; + if (!restored) { + navigationStore.restoreSnapshot(previousHistory); + return false; + } + await nextTick(); + return isCurrent(); + } catch (error) { + if (isCurrent()) navigationStore.restoreSnapshot(previousHistory); + console.error("[DBX] Navigation restore failed", error); + return false; + } finally { + navigationStore.endRestore(restoreSerial); + } +} + +async function restoreQueryNavigationEntry(entry: GlobalNavigationEntry, isCurrent: () => boolean = () => true, accept: () => boolean = () => true): Promise { + const activate = (id: string) => isCurrent() && activateQueryTab(id) && accept(); + if (!isCurrent()) return false; + const existingTab = entry.tabId ? queryStore.tabs.find((tab) => tab.id === entry.tabId) : undefined; + if (entry.kind === "objectSource" && entry.connectionId && entry.database && entry.objectName && entry.objectType) { + if (existingTab && existingTab.sourceView) return activate(existingTab.id); + const target = navigationSourceTarget(entry); + if (!target) return false; + return activate(queryStore.openObjectSourceTabPending(target)); + } + if (entry.kind === "data" && entry.connectionId && entry.database && entry.tableName) { + const matching = existingTab && matchesNavigationDataTab(existingTab, entry) ? existingTab : queryStore.tabs.find((tab) => matchesNavigationDataTab(tab, entry)); + if (matching) { + matching.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined; + return activate(matching.id); + } + if (!canRestoreGlobalNavigationEntry(entry)) return false; + const target = navigationTableTarget(entry); + if (!target) return false; + // Selection completes navigation; database I/O may continue in that tab. + // Never infer the target from activeTabId after an asynchronous operation. + return new Promise((resolve) => { + void openObjectBrowserTableTarget(target, { + isCurrent, + onOpened: (tabId) => { + if (!isCurrent()) { + resolve(false); + return; + } + const tab = queryStore.tabs.find((item) => item.id === tabId); + // Opening canonicalizes legacy database-as-schema identities (MySQL). + const schema = connectionObjectTreeNodeSchema(connectionStore.getConfig(target.connectionId), target.database, target.schema); + if (!tab || !matchesNavigationDataTab(tab, { ...entry, schema })) { + resolve(false); + return; + } + tab.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined; + resolve(activate(tabId)); + }, + }).then( + () => resolve(false), + (error) => { + console.error("[DBX] Navigation target loading failed", error); + resolve(false); + }, + ); + }); + } + if (entry.kind === "structure" && entry.connectionId && entry.database && entry.tableName) { + return activate(queryStore.openTableStructure(entry.connectionId, entry.database, entry.schema, entry.tableName, entry.tableInfoTab as TableInfoTab | undefined, undefined, entry.catalog)); + } + if (existingTab) { + existingTab.tableInfoTab = entry.tableInfoTab as TableInfoTab | undefined; + existingTab.sourceView = entry.sourceView; + return activate(existingTab.id); + } return false; } @@ -4077,7 +4272,7 @@ async function handleKeydown(e: KeyboardEvent) { } return; } - if (handleTabHistoryNavigationShortcut(e, shortcuts, activateTabFromHistory)) { + if (handleTabHistoryNavigationShortcut(e, shortcuts, navigateGlobal)) { e.preventDefault(); e.stopPropagation(); return; diff --git a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts index 9bd6856490..7dfd47284e 100644 --- a/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts +++ b/apps/desktop/src/composables/__tests__/useNavigationTargets.store.spec.ts @@ -65,6 +65,12 @@ vi.mock("@/lib/table/tableSelectSql", async (importOriginal) => { }; }); +// Disk-cache maintenance is outside navigation and has no web backend in Vitest. +vi.mock("@/lib/tabs/tabResultCache", async (importOriginal) => ({ + ...(await importOriginal()), + pruneTabResultSnapshots: vi.fn().mockResolvedValue(undefined), +})); + const dialogs = { showFieldLineageDialog: { value: false }, showDatabaseSearchDialog: { value: false }, @@ -128,6 +134,69 @@ describe("useNavigationTargets with the real query store", () => { })); }); + it.each(["always-new", "same-table", "active-tab"] as const)("reports the selected SQL tab before loading completes in %s mode", async (mode) => { + mocks.settingsStore.editorSettings.dataTabReuseMode = mode; + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users", tableType: "VIEW" }; + await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" }); + const previousId = queryStore.activeTabId; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + mocks.connectionStore.ensureConnected.mockReturnValue(gate); + const onOpened = vi.fn(); + const pending = navigation.openObjectBrowserTableTarget(target, { onOpened }); + await vi.waitFor(() => expect(onOpened).toHaveBeenCalledOnce()); + const targetId = onOpened.mock.calls[0]![0] as string; + const selected = queryStore.tabs.find((tab) => tab.id === targetId)!; + expect(selected).toMatchObject({ mode: "data", isExecuting: true, tableMeta: { database: "app", schema: "public", tableName: "users", tableType: "VIEW" } }); + expect(targetId === previousId).toBe(mode === "active-tab"); + // A new same-name tab on another connection must not receive this load. + const otherId = queryStore.createTab("connection-2", "app", "users", "data", "public", undefined, undefined, { forceNew: true }); + release(); + await pending; + expect(queryStore.activeTabId).toBe(otherId); + expect(queryStore.tabs.find((tab) => tab.id === otherId)?.result).toBeUndefined(); + expect(selected.sql).toBe("SELECT * FROM users"); + expect(onOpened).toHaveBeenCalledExactlyOnceWith(targetId); + }); + + it("reports an existing same-table tab without launching a second query", async () => { + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" }; + await navigation.openObjectBrowserTableTarget(target); + const tabId = queryStore.activeTabId; + const onOpened = vi.fn(); + vi.mocked(queryStore.executeTabSql).mockClear(); + await navigation.openObjectBrowserTableTarget(target, { onOpened }); + expect(onOpened).toHaveBeenCalledExactlyOnceWith(tabId); + expect(queryStore.executeTabSql).not.toHaveBeenCalled(); + }); + + it.each(["always-new", "same-table", "active-tab"] as const)("does not open an obsolete target in %s mode", async (mode) => { + mocks.settingsStore.editorSettings.dataTabReuseMode = mode; + const { navigation, queryStore } = await setupNavigation(); + const onOpened = vi.fn(); + await navigation.openObjectBrowserTableTarget({ connectionId: "connection-1", database: "app", tableName: "users" }, { onOpened, isCurrent: () => false }); + expect(queryStore.tabs).toHaveLength(0); + expect(onOpened).not.toHaveBeenCalled(); + }); + + it("reports Mongo's reused tab after updating its collection identity", async () => { + mocks.connectionStore.getConfig.mockImplementation((connectionId: string) => ({ id: connectionId, db_type: "mongodb" })); + mocks.settingsStore.editorSettings.dataTabReuseMode = "active-tab"; + const { navigation, queryStore } = await setupNavigation(); + const target = { connectionId: "connection-1", database: "app", tableName: "users" }; + await navigation.openObjectBrowserTableTarget(target); + const originalId = queryStore.activeTabId; + const onOpened = vi.fn((tabId: string) => { + expect(queryStore.tabs.find((tab) => tab.id === tabId)).toMatchObject({ mode: "mongo", sql: "orders", tableMeta: { tableName: "orders" } }); + }); + await navigation.openObjectBrowserTableTarget({ ...target, tableName: "orders" }, { onOpened }); + expect(onOpened).toHaveBeenCalledExactlyOnceWith(originalId); + }); + it("opens same-table search results with different predicates in separate tabs", async () => { const { navigation, queryStore } = await setupNavigation(); const target = { connectionId: "connection-1", database: "app", schema: "public", tableName: "users" }; diff --git a/apps/desktop/src/composables/useNavigationTargets.ts b/apps/desktop/src/composables/useNavigationTargets.ts index 4e60020020..e94a229dba 100644 --- a/apps/desktop/src/composables/useNavigationTargets.ts +++ b/apps/desktop/src/composables/useNavigationTargets.ts @@ -28,6 +28,13 @@ export type NavigationTarget = { whereInput?: string; }; +export interface NavigationOpenOptions { + // Called once the selected tab has its target identity, before loading data. + onOpened?: (tabId: string) => void; + // Guards selection only; background loading uses the tab execution generation. + isCurrent?: () => boolean; +} + function openMongoCollectionTarget(target: NavigationTarget, reuseMode: DataTabReuseMode) { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); @@ -50,26 +57,32 @@ function openMongoCollectionTarget(target: NavigationTarget, reuseMode: DataTabR columns: [], primaryKeys: [], }); + return tabId; } -async function openTableTarget(target: NavigationTarget, options: { tableInfoTab?: TableInfoTab } = {}) { +async function openTableTarget(target: NavigationTarget, options: NavigationOpenOptions & { tableInfoTab?: TableInfoTab } = {}) { const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); const pageLimit = tableOpenPageLimit(settingsStore.editorSettings.tableOpenPageSize); + if (options.isCurrent?.() === false) return; + connectionStore.activeConnectionId = target.connectionId; const config = connectionStore.getConfig(target.connectionId); if (config?.db_type === "mongodb") { - openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode); + const tabId = openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode); + options.onOpened?.(tabId); return; } const tableSchema = connectionObjectTreeNodeSchema(config, target.database, target.schema); const tabTitle = target.catalog ? `${target.catalog}.${tableSchema || target.database}.${target.tableName}` : tableSchema ? `${tableSchema}.${target.tableName}` : target.tableName; if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") { await connectionStore.ensureConnected(target.connectionId); + if (options.isCurrent?.() === false) return; const tabId = queryStore.createTab(target.connectionId, target.database || "default", tabTitle, "vector"); queryStore.updateSql(tabId, target.tableName); + options.onOpened?.(tabId); return; } const tabId = queryStore.createTab(target.connectionId, target.database, tabTitle, "data", tableSchema, undefined, undefined, { forceNew: true }); @@ -129,6 +142,7 @@ async function openTableTarget(target: NavigationTarget, options: { tableInfoTab // isPreparationCurrent——停止/接管后的迟到错误不得写入;首次执行之后 // preparationId 正常失效,回到 isCurrentTarget,真实错误仍要展示 let firstExecuteStarted = false; + options.onOpened?.(tabId); try { await connectionStore.ensureConnected(target.connectionId); @@ -343,13 +357,15 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: const settingsStore = useSettingsStore(); const { openData } = useSidebarDataOpenRuntime(); - async function openObjectBrowserTableTarget(target: NavigationTarget) { + async function openObjectBrowserTableTarget(target: NavigationTarget, options: NavigationOpenOptions = {}) { + if (options.isCurrent?.() === false) return; if (connectionStore.getConfig(target.connectionId)?.db_type === "mongodb") { - openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode); + const tabId = openMongoCollectionTarget(target, settingsStore.editorSettings.dataTabReuseMode); + options.onOpened?.(tabId); return; } if (settingsStore.editorSettings.dataTabReuseMode === "always-new") { - await openTableTarget(target); + await openTableTarget(target, options); return; } connectionStore.activeConnectionId = target.connectionId; @@ -369,7 +385,7 @@ export function useNavigationTargets(dialogs: { showFieldLineageDialog: { value: }, undefined, "default", - { reuseMode: settingsStore.editorSettings.dataTabReuseMode }, + { reuseMode: settingsStore.editorSettings.dataTabReuseMode, ...options }, ); } diff --git a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts index 376f730cda..d2b9278e6c 100644 --- a/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts +++ b/apps/desktop/src/composables/useSidebarDataOpenRuntime.ts @@ -18,6 +18,7 @@ import { tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeV import { canActivateExistingDataTableTab } from "@/lib/tabs/dataTabActivation"; import { beginDataTabNavigation, endDataTabNavigation, isCurrentDataTabNavigation } from "@/lib/tabs/dataTabNavigationGeneration"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; +import type { NavigationOpenOptions } from "@/composables/useNavigationTargets"; const DATA_TAB_METADATA_TTL_MS = TABLE_METADATA_CACHE_TTL_MS; @@ -30,17 +31,20 @@ export function useSidebarDataOpenRuntime() { const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); - async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: { reuseMode?: DataTabReuseMode; revealColumn?: string } = {}) { + async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default", options: NavigationOpenOptions & { reuseMode?: DataTabReuseMode; revealColumn?: string } = {}) { + if (options.isCurrent?.() === false) return; if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return; const config = connectionStore.getConfig(node.connectionId); const reuseMode = options.reuseMode ?? settingsStore.editorSettings.dataTabReuseMode; if (config?.db_type === "hbase") { await connectionStore.ensureConnected(node.connectionId); + if (options.isCurrent?.() === false) return; const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" || reuseMode === "always-new", insertAfterActive: settingsStore.editorSettings.openDataTabsNextToActive, }); queryStore.updateSql(tabId, node.label); + options.onOpened?.(tabId); return; } const traceId = uuid().slice(0, 8); @@ -204,6 +208,7 @@ export function useSidebarDataOpenRuntime() { queryStore.requestGridRevealColumn(existingSameTableTab.id, options.revealColumn); } queryStore.switchTab(existingSameTableTab.id); + options.onOpened?.(existingSameTableTab.id); logPhase("existing-tab-activated", { table: node.label }); // 代次失配视同冷缓存(即使位于 30s TTL 窗口内也要重建):disconnect / // 关库 / 死池重连都可能不改变 timestamp 判定而改变连接生命周期 @@ -255,7 +260,7 @@ export function useSidebarDataOpenRuntime() { logPhase("previous-execution-cancelled", { tabId }); // 取消等待期间可能有更晚的导航(openTableTarget/openData)接管本 tab: // 本代次已作废,不得再写占位元数据/executionId 覆盖新导航 - if (!isCurrentDataTabNavigation(tabId, navigationToken)) { + if (!isCurrentDataTabNavigation(tabId, navigationToken) || options.isCurrent?.() === false) { logPhase("superseded-during-cancel", { tabId }); return; } @@ -322,6 +327,7 @@ export function useSidebarDataOpenRuntime() { } }); logPhase("state-prepared", { tabId }); + options.onOpened?.(tabId); // Yield to Vue's scheduler so the new tab becomes visible in the UI (tab // bar activates, content area switches) before the first blocking network diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 8fc866673e..80f18f6f11 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7811,9 +7811,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Cari görünüşün axtarış sahəsinə keç", shortcutQuickOpen: "Sürətli açılış (bütün verilənlər bazası obyektlərini axtar)", shortcutGlobalSearch: "Qlobal fayl məzmunu axtarışı", - shortcutNavigateTabHistoryBack: "Əvvəl baxılmış vərəqə qayıt", + shortcutNavigateTabHistoryBack: "Qlobal geri", shortcutTabSwitcher: "Vərəq dəyişdiricisi (göstərmək üçün basılı saxla, keçmək üçün burax)", - shortcutNavigateTabHistoryForward: "Növbəti baxılmış vərəqə keç", + shortcutNavigateTabHistoryForward: "Qlobal irəli", shortcutSwitchToPreviousTab: "Əvvəlki vərəqə keç", shortcutSwitchToNextTab: "Növbəti vərəqə keç", shortcutSwitchToTab1: "1-ci vərəqə keç", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 0e973fc9de..3cd27ba7a6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -8835,9 +8835,9 @@ export default { shortcutFocusSearch: "Focus current view search", shortcutQuickOpen: "Quick open (search all database objects)", shortcutGlobalSearch: "Global file content search", - shortcutNavigateTabHistoryBack: "Go back to the previously viewed tab", + shortcutNavigateTabHistoryBack: "Navigate back", shortcutTabSwitcher: "Tab switcher (hold to show, release to switch)", - shortcutNavigateTabHistoryForward: "Go forward to the next viewed tab", + shortcutNavigateTabHistoryForward: "Navigate forward", shortcutSwitchToPreviousTab: "Switch to previous tab", shortcutSwitchToNextTab: "Switch to next tab", shortcutSwitchToTab1: "Switch to tab 1", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a3149b53ef..5c174e932d 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -8086,9 +8086,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Enfocar búsqueda de la vista actual", shortcutQuickOpen: "Abrir rápido (buscar todos los objetos de base de datos)", shortcutGlobalSearch: "Búsqueda global de contenido de archivos", - shortcutNavigateTabHistoryBack: "Volver a la pestaña vista anteriormente", + shortcutNavigateTabHistoryBack: "Navegar atrás", shortcutTabSwitcher: "Selector de pestañas (mantén pulsado para mostrar, suelta para cambiar)", - shortcutNavigateTabHistoryForward: "Avanzar a la siguiente pestaña vista", + shortcutNavigateTabHistoryForward: "Navegar adelante", shortcutSwitchToPreviousTab: "Cambiar a la pestaña anterior", shortcutSwitchToNextTab: "Cambiar a la pestaña siguiente", shortcutSwitchToTab1: "Cambiar a la pestaña 1", diff --git a/apps/desktop/src/i18n/locales/id.ts b/apps/desktop/src/i18n/locales/id.ts index 7562212410..4e53472f37 100644 --- a/apps/desktop/src/i18n/locales/id.ts +++ b/apps/desktop/src/i18n/locales/id.ts @@ -8610,9 +8610,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Fokus pencarian tampilan saat ini", shortcutQuickOpen: "Buka cepat (cari semua objek database)", shortcutGlobalSearch: "Pencarian konten file global", - shortcutNavigateTabHistoryBack: "Kembali ke tab yang dilihat sebelumnya", + shortcutNavigateTabHistoryBack: "Navigasi mundur", shortcutTabSwitcher: "Pengalih tab (tahan untuk menampilkan, lepas untuk beralih)", - shortcutNavigateTabHistoryForward: "Maju ke tab yang dilihat berikutnya", + shortcutNavigateTabHistoryForward: "Navigasi maju", shortcutSwitchToPreviousTab: "Beralih ke tab sebelumnya", shortcutSwitchToNextTab: "Beralih ke tab berikutnya", shortcutSwitchToTab1: "Beralih ke tab 1", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index bf488535f5..afc55b1147 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7846,9 +7846,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Focalizza la ricerca nella vista corrente", shortcutQuickOpen: "Apertura rapida (ricerca tutti gli oggetti del database)", shortcutGlobalSearch: "Ricerca globale nel contenuto dei file", - shortcutNavigateTabHistoryBack: "Torna alla scheda visualizzata in precedenza", + shortcutNavigateTabHistoryBack: "Navigazione indietro", shortcutTabSwitcher: "Selettore schede (tieni premuto per mostrare, rilascia per cambiare)", - shortcutNavigateTabHistoryForward: "Vai avanti alla scheda visualizzata successiva", + shortcutNavigateTabHistoryForward: "Navigazione avanti", shortcutSwitchToPreviousTab: "Passa alla scheda precedente", shortcutSwitchToNextTab: "Passa alla scheda successiva", shortcutSwitchToTab1: "Passa alla scheda 1", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 1e3d096a1a..fd116a703a 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7840,9 +7840,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "現在のビューの検索にフォーカス", shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)", shortcutGlobalSearch: "グローバルファイル内容検索", - shortcutNavigateTabHistoryBack: "前に表示したタブに戻る", + shortcutNavigateTabHistoryBack: "グローバルに戻る", shortcutTabSwitcher: "タブスイッチャー(押し続けて表示、離して切り替え)", - shortcutNavigateTabHistoryForward: "次に表示したタブに進む", + shortcutNavigateTabHistoryForward: "グローバルに進む", shortcutSwitchToPreviousTab: "前のタブに切り替え", shortcutSwitchToNextTab: "次のタブに切り替え", shortcutSwitchToTab1: "タブ 1 に切り替え", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 4bc6360b05..5c8725734e 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7694,9 +7694,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "현재 화면 검색에 포커스", shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)", shortcutGlobalSearch: "전역 파일 내용 검색", - shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기", + shortcutNavigateTabHistoryBack: "전체 뒤로", shortcutTabSwitcher: "탭 전환기 (길게 눌러 표시, 놓으면 전환)", - shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동", + shortcutNavigateTabHistoryForward: "전체 앞으로", shortcutSwitchToPreviousTab: "이전 탭으로 전환", shortcutSwitchToNextTab: "다음 탭으로 전환", shortcutSwitchToTab1: "탭 1로 전환", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 21e0576e6e..3426ca2f3b 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7848,9 +7848,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Focar na pesquisa da tela atual", shortcutQuickOpen: "Abrir rapidamente (pesquisar todos os objetos do banco de dados)", shortcutGlobalSearch: "Pesquisa global de conteúdo de arquivos", - shortcutNavigateTabHistoryBack: "Voltar para a aba visualizada anteriormente", + shortcutNavigateTabHistoryBack: "Navegar para trás", shortcutTabSwitcher: "Alternador de abas (segure para mostrar, solte para alternar)", - shortcutNavigateTabHistoryForward: "Avançar para a próxima aba visualizada", + shortcutNavigateTabHistoryForward: "Navegar para frente", shortcutSwitchToPreviousTab: "Alternar para a aba anterior", shortcutSwitchToNextTab: "Alternar para a próxima aba", shortcutSwitchToTab1: "Alternar para a aba 1", diff --git a/apps/desktop/src/i18n/locales/ru.ts b/apps/desktop/src/i18n/locales/ru.ts index bcdebdcc12..3a2f877844 100644 --- a/apps/desktop/src/i18n/locales/ru.ts +++ b/apps/desktop/src/i18n/locales/ru.ts @@ -9135,9 +9135,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Фокус на поиске в текущем представлении", shortcutQuickOpen: "Быстрое открытие (поиск по всем объектам базы данных)", shortcutGlobalSearch: "Глобальный поиск по содержимому файлов", - shortcutNavigateTabHistoryBack: "Вернуться к предыдущей просмотренной вкладке", + shortcutNavigateTabHistoryBack: "Назад по навигации", shortcutTabSwitcher: "Переключатель вкладок (удерживайте, чтобы показать, отпустите, чтобы переключиться)", - shortcutNavigateTabHistoryForward: "Перейти к следующей просмотренной вкладке", + shortcutNavigateTabHistoryForward: "Вперёд по навигации", shortcutSwitchToPreviousTab: "Переключиться на предыдущую вкладку", shortcutSwitchToNextTab: "Переключиться на следующую вкладку", shortcutSwitchToTab1: "Переключиться на вкладку 1", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 4d3cf134c0..a6ad0ba9d4 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7712,9 +7712,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "Geçerli görünüm aramasına odaklan", shortcutQuickOpen: "Hızlı aç (tüm veritabanı nesnelerinde ara)", shortcutGlobalSearch: "Genel dosya içeriği arama", - shortcutNavigateTabHistoryBack: "Önceki görüntülenen sekmeye dön", + shortcutNavigateTabHistoryBack: "Geri git", shortcutTabSwitcher: "Sekme değiştirici (göstermek için basılı tutun, geçmek için bırakın)", - shortcutNavigateTabHistoryForward: "Sonraki görüntülenen sekmeye git", + shortcutNavigateTabHistoryForward: "İleri git", shortcutSwitchToPreviousTab: "Önceki sekmeye geç", shortcutSwitchToNextTab: "Sonraki sekmeye geç", shortcutSwitchToTab1: "1. sekmeye geç", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 0aa642beed..eef0ab87a5 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8824,9 +8824,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "聚焦当前页面搜索", shortcutQuickOpen: "快速打开 (搜索所有数据库对象)", shortcutGlobalSearch: "全局文件内容搜索", - shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页", + shortcutNavigateTabHistoryBack: "全局后退", shortcutTabSwitcher: "标签页切换器(按住显示,松开切换)", - shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页", + shortcutNavigateTabHistoryForward: "全局前进", shortcutSwitchToPreviousTab: "切换到左侧标签页", shortcutSwitchToNextTab: "切换到右侧标签页", shortcutSwitchToTab1: "切换到第 1 个标签页", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 30cd6511f9..471e7eb25d 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -7167,9 +7167,9 @@ export default withEnglishFallback({ shortcutFocusSearch: "聚焦目前頁面搜尋", shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)", shortcutGlobalSearch: "全域檔案內容搜尋", - shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁", + shortcutNavigateTabHistoryBack: "全域後退", shortcutTabSwitcher: "分頁切換器(按住顯示,放開切換)", - shortcutNavigateTabHistoryForward: "前進到下一個檢視的分頁", + shortcutNavigateTabHistoryForward: "全域前進", shortcutSwitchToPreviousTab: "切換到左側分頁", shortcutSwitchToNextTab: "切換到右側分頁", shortcutSwitchToTab1: "切換到第 1 個分頁", diff --git a/apps/desktop/src/lib/navigation/__tests__/appNavigationRestore.spec.ts b/apps/desktop/src/lib/navigation/__tests__/appNavigationRestore.spec.ts new file mode 100644 index 0000000000..b81fb2be24 --- /dev/null +++ b/apps/desktop/src/lib/navigation/__tests__/appNavigationRestore.spec.ts @@ -0,0 +1,376 @@ +import { readFileSync } from "node:fs"; +import { createPinia, setActivePinia } from "pinia"; +import { computed, nextTick, reactive, ref, watch } from "vue"; +import { restoreGlobalNavigationEntry } from "../globalNavigationExecutor"; +import type { NavigationOpenOptions, NavigationTarget } from "@/composables/useNavigationTargets"; +import { parse } from "vue/compiler-sfc"; +import ts from "typescript"; +import { describe, expect, it, vi } from "vitest"; +import { connectionObjectTreeNodeSchema } from "@/lib/database/jdbcDialect"; +import { useNavigationStore } from "@/stores/navigationStore"; +import { handleTabHistoryNavigationShortcut } from "@/lib/editor/keyboardShortcuts"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; +import type { QueryTab } from "@/types/database"; +import { navigationEntryKey, type GlobalNavigationEntry } from "../navigationEntry"; +import * as restoreTargets from "../navigationRestoreTargets"; + +// Exercise App's actual recording, keyboard admission and restore functions +// without mounting its unrelated desktop services (same harness as dialog tests). +const app = parse(readFileSync(new URL("../../../App.vue", import.meta.url), "utf8")); +const source = ts.createSourceFile("App.vue.ts", app.descriptor.scriptSetup!.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +const functionNames = ["currentGlobalNavigationEntry", "isGlobalNavigationEntryAvailable", "canRestoreGlobalNavigationEntry", "navigateGlobal", "completeGlobalNavigation", "restoreQueryNavigationEntry"]; +const declarations = functionNames.map((name) => { + const node = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === name); + if (!node) throw new Error(`Missing App function: ${name}`); + return node.getText(); +}); +const javascript = ts.transpileModule(declarations.join("\n"), { compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 } }).outputText; + +function dataTab(id: string, tableName: string, overrides: Partial = {}): QueryTab { + return { + id, + title: tableName, + connectionId: "connection", + database: "db", + schema: "public", + mode: "data", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + tableMeta: { database: "db", schema: "public", tableName, tableType: "TABLE", columns: [], primaryKeys: [] }, + ...overrides, + }; +} + +function harness(tabs: QueryTab[], reuseMode: DataTabReuseMode = "active-tab") { + setActivePinia(createPinia()); + const navigationStore = useNavigationStore(); + const queryStore = reactive({ + tabs, + activeTabId: tabs[0]?.id, + openObjectSourceTabPending: vi.fn((target) => { + const tab = dataTab("source-restored", "unused", { mode: "query", tableMeta: undefined, sourceView: true, sourceLoad: { startedAt: 1, request: target.request, initialEditing: target.initialEditing } }); + queryStore.tabs.push(tab); + return tab.id; + }), + openTableStructure: vi.fn(), + }); + const showSettingsPage = ref(false), + showDriverStore = ref(false), + showPluginCenter = ref(false); + const activateQueryTab = vi.fn((id: string) => { + if (!queryStore.tabs.some((tab) => tab.id === id)) return false; + queryStore.activeTabId = id; + showSettingsPage.value = showDriverStore.value = showPluginCenter.value = false; + return true; + }); + const openObjectBrowserTableTarget = vi.fn(async (target: NavigationTarget, options: NavigationOpenOptions = {}) => { + const tab = dataTab("restored", target.tableName, { connectionId: target.connectionId, database: target.database, catalog: target.catalog, schema: target.schema }); + Object.assign(tab.tableMeta!, target); + queryStore.tabs.push(tab); + queryStore.activeTabId = tab.id; + options.onOpened?.(tab.id); + }); + const bindings = { + ...restoreTargets, + connectionStore: { getConfig: vi.fn(() => ({ db_type: "postgres" })) }, + connectionObjectTreeNodeSchema, + navigationEntryKey, + navigationStore, + queryStore, + nextTick, + restoreGlobalNavigationEntry, + activeTab: computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId)), + settingsStore: { editorSettings: { dataTabReuseMode: reuseMode } }, + sourceNavigationIdentity: new Map(), + showSettingsPage, + showDriverStore, + showPluginCenter, + settingsPageTabOpen: ref(true), + driverStoreTabOpen: ref(true), + pluginCenterTabOpen: ref(true), + activateSettingsPage: () => { + showSettingsPage.value = true; + showDriverStore.value = showPluginCenter.value = false; + }, + openDriverStorePage: () => { + showDriverStore.value = true; + showSettingsPage.value = showPluginCenter.value = false; + }, + openPluginCenterPage: () => { + showPluginCenter.value = true; + showSettingsPage.value = showDriverStore.value = false; + }, + activateQueryTab, + openObjectBrowserTableTarget, + }; + const functions = new Function(...Object.keys(bindings), `${javascript}\nreturn { ${functionNames.join(",")} };`)(...Object.values(bindings)) as { + currentGlobalNavigationEntry: () => GlobalNavigationEntry; + navigateGlobal: (direction: -1 | 1) => boolean; + restoreQueryNavigationEntry: (entry: GlobalNavigationEntry) => Promise; + }; + // Use the real App watch expression and callback, including Vue's flush order. + const recorder = source.statements.find((node) => ts.isExpressionStatement(node) && node.getText().startsWith("watch(") && node.getText().includes("navigationStore.record(entry)")); + if (!recorder) throw new Error("Missing navigation watcher"); + const recorderJs = ts.transpileModule(recorder.getText(), { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; + const startRecording = () => new Function(...Object.keys(bindings), "watch", "currentGlobalNavigationEntry", recorderJs)(...Object.values(bindings), watch, functions.currentGlobalNavigationEntry); + return { ...functions, ...bindings, startRecording }; +} + +async function flushNavigation() { + for (let i = 0; i < 8; i++) await nextTick(); +} + +function seedBack(h: ReturnType, previous: GlobalNavigationEntry) { + h.navigationStore.record(previous); + h.navigationStore.record(h.currentGlobalNavigationEntry()); + h.startRecording(); +} + +describe("App navigation restore regressions", () => { + it.each([{ pendingDataChangeCount: 1 }, { hasPendingDataEditorDraft: true }])("does not consume Back or move history when a data draft blocks restoration: %j", (draft) => { + const h = harness([dataTab("current", "b", draft)]); + h.navigationStore.record({ id: "a", surface: "query", kind: "data", connectionId: "connection", database: "db", schema: "public", tableName: "a" }); + h.navigationStore.record(h.currentGlobalNavigationEntry()); + const before = h.navigationStore.snapshot(); + const consumed = handleTabHistoryNavigationShortcut({ key: "ArrowLeft", altKey: true }, { navigateTabHistoryBack: "Alt+ArrowLeft" }, h.navigateGlobal, "Win32"); + expect(consumed).toBe(false); + expect(h.navigationStore.snapshot()).toEqual(before); + expect(h.navigationStore.restoring).toBe(false); + expect(h.openObjectBrowserTableTarget).not.toHaveBeenCalled(); + }); + + it("admits Back synchronously when the same target is already open, even with a dirty active tab", () => { + const h = harness([dataTab("current", "b", { pendingDataChangeCount: 1 }), dataTab("previous", "a")]); + h.navigationStore.record({ id: "a", surface: "query", kind: "data", connectionId: "connection", database: "db", schema: "public", tableName: "a" }); + h.navigationStore.record(h.currentGlobalNavigationEntry()); + expect(handleTabHistoryNavigationShortcut({ key: "ArrowLeft", altKey: true }, { navigateTabHistoryBack: "Alt+ArrowLeft" }, h.navigateGlobal, "Win32")).toBe(true); + expect(h.navigationStore.currentIndex).toBe(0); + expect(h.queryStore.activeTabId).toBe("previous"); + }); + + it("does not consume a shortcut when all earlier tabs have been closed", () => { + const h = harness([dataTab("current", "b")]); + h.navigationStore.record({ id: "closed", surface: "query", kind: "query", tabId: "closed" }); + h.navigationStore.record(h.currentGlobalNavigationEntry()); + expect(h.navigateGlobal(-1)).toBe(false); + expect(h.navigationStore.currentIndex).toBe(1); + expect(h.openObjectBrowserTableTarget).not.toHaveBeenCalled(); + }); + + it.each([false, true])("preserves sequence initialEditing=%s after loading and reopening a closed source tab", async (initialEditing) => { + const tab = dataTab("source", "unused", { mode: "query", tableMeta: undefined, sourceView: true, sourceLoad: { startedAt: 1, initialEditing, request: { name: "SEQ_USERS", objectType: "SEQUENCE" } } }); + const h = harness([tab]); + const pending = h.currentGlobalNavigationEntry(); + tab.sourceLoad = undefined; + if (initialEditing) tab.objectSource = { name: "SEQ_USERS", objectType: "SEQUENCE", schema: "public" }; + const loaded = h.currentGlobalNavigationEntry(); + expect(loaded.initialEditing).toBe(initialEditing); + expect(loaded.id).toBe(pending.id); + h.queryStore.tabs.length = 0; + expect(await h.restoreQueryNavigationEntry(loaded)).toBe(true); + expect(h.queryStore.openObjectSourceTabPending).toHaveBeenCalledWith(expect.objectContaining({ initialEditing, request: { name: "SEQ_USERS", objectType: "SEQUENCE", signature: undefined } })); + }); + + it.each(["TABLE", "VIEW", "MATERIALIZED_VIEW"])("reopens data with its original %s type", async (tableType) => { + const tab = dataTab("original", "target"); + tab.tableMeta!.tableType = tableType; + const h = harness([tab]); + const recorded = h.currentGlobalNavigationEntry(); + h.queryStore.tabs.length = 0; + expect(await h.restoreQueryNavigationEntry(recorded)).toBe(true); + expect(h.openObjectBrowserTableTarget).toHaveBeenCalledWith(expect.objectContaining({ tableName: "target", tableType }), expect.objectContaining({ onOpened: expect.any(Function) })); + }); + it.each(["always-new", "same-table", "active-tab"] as const)("returns to the original duplicate tab in %s mode", async (mode) => { + const h = harness([dataTab("second", "a"), dataTab("first", "a"), dataTab("other", "b")], mode); + const target = h.currentGlobalNavigationEntry(); + h.queryStore.tabs.reverse(); // first match is now the wrong duplicate + h.activateQueryTab("other"); + seedBack(h, target); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.activeTabId).toBe("second"); + expect(h.navigationStore.entries[h.navigationStore.currentIndex]?.tabId).toBe("second"); + expect(h.navigateGlobal(1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.activeTabId).toBe("other"); + }); + + it.each(["showSettingsPage", "showDriverStore", "showPluginCenter"] as const)("leaves %s when the underlying active tab is restored", async (surface) => { + const h = harness([dataTab("same", "a")]); + const target = h.currentGlobalNavigationEntry(); + h[surface].value = true; + seedBack(h, target); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h[surface].value).toBe(false); + expect(h.queryStore.activeTabId).toBe("same"); + expect(h.navigationStore.currentIndex).toBe(0); + expect(h.navigateGlobal(1)).toBe(true); + await flushNavigation(); + expect(h[surface].value).toBe(true); + }); + + it.each(["dynamodb", "elasticsearch", "mongodb"])("keeps ordinary %s tabs navigable without SQL restoration", async (dbType) => { + const h = harness([dataTab("document", "a", { mode: "mongo", tableMeta: dbType === "mongodb" ? undefined : dataTab("meta", "a").tableMeta }), dataTab("other", "b")]); + h.connectionStore.getConfig.mockReturnValue({ db_type: dbType }); + const entry = h.currentGlobalNavigationEntry(); + expect(entry.kind).toBe("query"); + h.activateQueryTab("other"); + seedBack(h, entry); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.activeTabId).toBe("document"); + expect(h.openObjectBrowserTableTarget).not.toHaveBeenCalled(); + }); + + it("restores Mongo collections A → B → A within the same tab", async () => { + const h = harness([dataTab("mongo", "a", { mode: "mongo", sql: "a", schema: undefined })]); + h.queryStore.tabs[0]!.tableMeta!.schema = undefined; + h.connectionStore.getConfig.mockReturnValue({ db_type: "mongodb" }); + const a = h.currentGlobalNavigationEntry(); + expect(a.kind).toBe("data"); + h.queryStore.tabs[0]!.sql = "b"; + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + h.openObjectBrowserTableTarget.mockImplementation(async (target, options) => { + h.queryStore.tabs[0]!.sql = target.tableName; + h.queryStore.tabs[0]!.tableMeta!.tableName = target.tableName; + options?.onOpened?.("mongo"); + }); + seedBack(h, a); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.tabs[0]!.sql).toBe("a"); + expect(h.navigateGlobal(1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.tabs[0]!.sql).toBe("b"); + }); + + it.each(["resolve", "reject"])("keeps a new user visit after a background load %s", async (outcome) => { + const h = harness([dataTab("original", "a"), dataTab("other", "a", { connectionId: "different" })]); + const a = h.currentGlobalNavigationEntry(); + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + let finish!: () => void; + h.openObjectBrowserTableTarget.mockImplementation(async (target, options) => { + h.queryStore.tabs[0]!.tableMeta!.tableName = target.tableName; + options?.onOpened?.("original"); + await new Promise((resolve, reject) => { + finish = () => (outcome === "resolve" ? resolve() : reject(new Error("late load"))); + }); + }); + seedBack(h, { ...a, tableInfoTab: "indexes" }); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.navigationStore.restoring).toBe(false); // no wait for database I/O + h.activateQueryTab("other"); + await flushNavigation(); + const history = h.navigationStore.snapshot(); + finish(); + await flushNavigation(); + expect(h.queryStore.activeTabId).toBe("other"); + expect(h.queryStore.tabs[1]!.tableInfoTab).toBeUndefined(); + expect(h.navigationStore.snapshot()).toEqual(history); + }); + + it.each(["switch", "close", "clear"])("invalidates a pending restore after %s without rolling back newer history", async (action) => { + const h = harness([dataTab("original", "a"), dataTab("other", "c")]); + const a = h.currentGlobalNavigationEntry(); + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + let finish!: () => void; + h.openObjectBrowserTableTarget.mockImplementation(async (_target, options) => { + await new Promise((resolve) => { + finish = resolve; + }); + if (options?.isCurrent?.()) throw new Error("obsolete restore still current"); + }); + seedBack(h, a); + expect(h.navigateGlobal(-1)).toBe(true); + if (action === "switch") h.activateQueryTab("other"); + if (action === "close") { + h.queryStore.tabs.length = 0; + h.queryStore.activeTabId = undefined; + } + if (action === "clear") h.navigationStore.clear(); + await flushNavigation(); + const history = h.navigationStore.snapshot(); + expect(h.navigationStore.restoring).toBe(false); + finish(); + await flushNavigation(); + expect(h.navigationStore.snapshot()).toEqual(history); + }); + + it.each(["connectionId", "database", "schema", "catalog"] as const)("does not activate a same-name table from another %s", async (field) => { + const h = harness([dataTab("original", "a"), dataTab("wrong", "a")]); + const target = h.currentGlobalNavigationEntry(); + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + if (field === "connectionId") h.queryStore.tabs[1]!.connectionId = "different"; + else { + h.queryStore.tabs[1]![field] = "different"; + h.queryStore.tabs[1]!.tableMeta![field] = "different"; + } + seedBack(h, target); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.queryStore.activeTabId).toBe("restored"); + expect(h.queryStore.tabs[1]!.tableInfoTab).toBeUndefined(); + expect(h.navigationStore.entries[0]).toMatchObject({ tabId: "restored", tableName: "a", connectionId: "connection", database: "db", schema: "public" }); + }); + + it("accepts canonical MySQL schema when reopening a legacy history entry", async () => { + const h = harness([dataTab("original", "a")]); + h.connectionStore.getConfig.mockReturnValue({ db_type: "mysql" }); + h.queryStore.tabs[0]!.schema = "db"; + h.queryStore.tabs[0]!.tableMeta!.schema = "db"; + const target = h.currentGlobalNavigationEntry(); + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + h.openObjectBrowserTableTarget.mockImplementation(async (_target, options) => { + const tab = h.queryStore.tabs[0]!; + tab.schema = undefined; + tab.tableMeta!.schema = undefined; + tab.tableMeta!.tableName = "a"; + options?.onOpened?.(tab.id); + }); + seedBack(h, target); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.navigationStore.currentIndex).toBe(0); + expect(h.navigationStore.entries[0]?.schema).toBeUndefined(); + expect(h.navigationStore.entries[0]?.tableName).toBe("a"); + }); + + it("does not let an obsolete failure undo a subsequent Back navigation", async () => { + const h = harness([dataTab("original", "a"), dataTab("other", "c")]); + const target = h.currentGlobalNavigationEntry(); + h.queryStore.tabs[0]!.tableMeta!.tableName = "b"; + let rejectOld!: (error: Error) => void; + h.openObjectBrowserTableTarget.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectOld = reject; + }), + ); + seedBack(h, target); + expect(h.navigateGlobal(-1)).toBe(true); + h.activateQueryTab("other"); + await flushNavigation(); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + const snapshot = h.navigationStore.snapshot(); + rejectOld(new Error("obsolete failure")); + await flushNavigation(); + expect(h.navigationStore.snapshot()).toEqual(snapshot); + expect(h.queryStore.activeTabId).toBe("original"); + }); + + it("rolls back an open that fails before selecting a target", async () => { + const h = harness([dataTab("current", "b")]); + seedBack(h, { ...h.currentGlobalNavigationEntry(), id: "missing", tabId: "missing", tableName: "a" }); + const before = h.navigationStore.snapshot(); + h.openObjectBrowserTableTarget.mockRejectedValueOnce(new Error("cannot open")); + expect(h.navigateGlobal(-1)).toBe(true); + await flushNavigation(); + expect(h.navigationStore.snapshot()).toEqual(before); + expect(h.navigationStore.restoring).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/navigation/__tests__/globalNavigationExecutor.spec.ts b/apps/desktop/src/lib/navigation/__tests__/globalNavigationExecutor.spec.ts new file mode 100644 index 0000000000..e76b57cbb1 --- /dev/null +++ b/apps/desktop/src/lib/navigation/__tests__/globalNavigationExecutor.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { restoreGlobalNavigationEntry } from "../globalNavigationExecutor"; + +describe("global navigation executor", () => { + it("dispatches query and special-page entries to their handlers", async () => { + const handlers = { + restoreQuery: vi.fn(async () => true), + activateSettings: vi.fn(), + activateDriverStore: vi.fn(), + activatePluginCenter: vi.fn(), + }; + await restoreGlobalNavigationEntry({ id: "q", surface: "query", tabId: "tab-1" }, handlers); + await restoreGlobalNavigationEntry({ id: "s", surface: "settings" }, handlers); + expect(handlers.restoreQuery).toHaveBeenCalledWith({ id: "q", surface: "query", tabId: "tab-1" }); + expect(handlers.activateSettings).toHaveBeenCalledOnce(); + }); + + it("reports a closed query tab as unrestorable", async () => { + const restored = await restoreGlobalNavigationEntry({ id: "q", surface: "query", tabId: "closed" }, { restoreQuery: async () => false, activateSettings: () => {}, activateDriverStore: () => {}, activatePluginCenter: () => {} }); + expect(restored).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/navigation/__tests__/globalNavigationHistory.spec.ts b/apps/desktop/src/lib/navigation/__tests__/globalNavigationHistory.spec.ts new file mode 100644 index 0000000000..b98695803d --- /dev/null +++ b/apps/desktop/src/lib/navigation/__tests__/globalNavigationHistory.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createGlobalNavigationHistory, moveGlobalNavigation, recordGlobalNavigation } from "../globalNavigationHistory"; +import type { GlobalNavigationEntry } from "../navigationEntry"; + +const entry = (id: string): GlobalNavigationEntry => ({ id, surface: "query", tabId: id }); + +describe("global navigation history", () => { + it("records visits without duplicating the current entry", () => { + let history = createGlobalNavigationHistory(); + history = recordGlobalNavigation(history, entry("a")); + history = recordGlobalNavigation(history, entry("a")); + expect(history.entries.map((item) => item.id)).toEqual(["a"]); + expect(history.index).toBe(0); + }); + + it("clears the forward branch after a new visit", () => { + let history = createGlobalNavigationHistory(); + for (const id of ["a", "b", "c"]) history = recordGlobalNavigation(history, entry(id)); + const back = moveGlobalNavigation(history, -1, () => true); + expect(back?.entry.id).toBe("b"); + history = back!.history; + history = recordGlobalNavigation(history, entry("d")); + expect(history.entries.map((item) => item.id)).toEqual(["a", "b", "d"]); + expect(moveGlobalNavigation(history, 1, () => true)).toBeNull(); + }); + + it("skips invalid entries while moving", () => { + let history = createGlobalNavigationHistory(); + for (const id of ["a", "closed", "c"]) history = recordGlobalNavigation(history, entry(id)); + const back = moveGlobalNavigation(history, -1, (item) => item.id !== "closed"); + expect(back?.entry.id).toBe("a"); + }); + it("coalesces an asynchronous source identity resolution into the same visit", () => { + let history = recordGlobalNavigation(createGlobalNavigationHistory(), entry("previous")); + history = recordGlobalNavigation(history, { id: "pending", surface: "query", kind: "objectSource", tabId: "source", objectType: "PROCEDURE" }); + history = recordGlobalNavigation(history, { id: "loaded", surface: "query", kind: "objectSource", tabId: "source", objectType: "FUNCTION" }); + expect(history.entries.map((item) => item.id)).toEqual(["previous", "loaded"]); + expect(moveGlobalNavigation(history, -1, () => true)?.entry.id).toBe("previous"); + }); +}); diff --git a/apps/desktop/src/lib/navigation/__tests__/navigationEntry.spec.ts b/apps/desktop/src/lib/navigation/__tests__/navigationEntry.spec.ts new file mode 100644 index 0000000000..ab8a1a094c --- /dev/null +++ b/apps/desktop/src/lib/navigation/__tests__/navigationEntry.spec.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { navigationEntryKey } from "../navigationEntry"; + +describe("navigation entry keys", () => { + it("does not confuse delimiters within adjacent object identity fields", () => { + const base = { id: "", surface: "query" as const }; + expect(navigationEntryKey({ ...base, objectSignature: "a|b", mode: "c" })).not.toBe(navigationEntryKey({ ...base, objectSignature: "a", mode: "b|c" })); + }); +}); diff --git a/apps/desktop/src/lib/navigation/globalNavigationExecutor.ts b/apps/desktop/src/lib/navigation/globalNavigationExecutor.ts new file mode 100644 index 0000000000..d4c3c8a5fb --- /dev/null +++ b/apps/desktop/src/lib/navigation/globalNavigationExecutor.ts @@ -0,0 +1,26 @@ +import type { GlobalNavigationEntry } from "./navigationEntry"; + +export interface GlobalNavigationExecutorHandlers { + restoreQuery: (entry: GlobalNavigationEntry) => Promise; + activateSettings: () => void; + activateDriverStore: () => void; + activatePluginCenter: () => void; +} + +export async function restoreGlobalNavigationEntry(entry: GlobalNavigationEntry, handlers: GlobalNavigationExecutorHandlers): Promise { + switch (entry.surface) { + case "query": + return handlers.restoreQuery(entry); + case "settings": + handlers.activateSettings(); + return true; + case "driverStore": + handlers.activateDriverStore(); + return true; + case "pluginCenter": + handlers.activatePluginCenter(); + return true; + default: + return false; + } +} diff --git a/apps/desktop/src/lib/navigation/globalNavigationHistory.ts b/apps/desktop/src/lib/navigation/globalNavigationHistory.ts new file mode 100644 index 0000000000..8505d67566 --- /dev/null +++ b/apps/desktop/src/lib/navigation/globalNavigationHistory.ts @@ -0,0 +1,40 @@ +import type { GlobalNavigationEntry } from "./navigationEntry"; + +export interface GlobalNavigationHistory { + entries: GlobalNavigationEntry[]; + index: number; +} + +export interface GlobalNavigationMove { + history: GlobalNavigationHistory; + entry: GlobalNavigationEntry; +} + +export const MAX_GLOBAL_NAVIGATION_HISTORY = 100; + +export function createGlobalNavigationHistory(): GlobalNavigationHistory { + return { entries: [], index: -1 }; +} + +export function recordGlobalNavigation(history: GlobalNavigationHistory, entry: GlobalNavigationEntry): GlobalNavigationHistory { + if (!entry.id) return history; + const current = history.entries[history.index]; + if (current && current.id === entry.id) return history; + // Loading can resolve a routine to a different object kind. It is still the + // same visit, not a second navigation step within the source tab. + if (current?.kind === "objectSource" && entry.kind === "objectSource" && current.tabId === entry.tabId) { + const entries = [...history.entries]; + entries[history.index] = entry; + return { entries, index: history.index }; + } + const entries = [...history.entries.slice(0, history.index + 1), entry].slice(-MAX_GLOBAL_NAVIGATION_HISTORY); + return { entries, index: entries.length - 1 }; +} + +export function moveGlobalNavigation(history: GlobalNavigationHistory, direction: -1 | 1, isValid: (entry: GlobalNavigationEntry) => boolean): GlobalNavigationMove | null { + for (let index = history.index + direction; index >= 0 && index < history.entries.length; index += direction) { + const entry = history.entries[index]; + if (entry && isValid(entry)) return { history: { entries: history.entries, index }, entry }; + } + return null; +} diff --git a/apps/desktop/src/lib/navigation/navigationEntry.ts b/apps/desktop/src/lib/navigation/navigationEntry.ts new file mode 100644 index 0000000000..8e6295331f --- /dev/null +++ b/apps/desktop/src/lib/navigation/navigationEntry.ts @@ -0,0 +1,46 @@ +import type { ObjectSourceKind } from "@/types/database"; + +export type GlobalNavigationSurface = "query" | "settings" | "driverStore" | "pluginCenter"; +export type GlobalNavigationKind = "query" | "data" | "ddl" | "structure" | "objectSource" | "special"; + +export interface GlobalNavigationEntry { + id: string; + surface: GlobalNavigationSurface; + kind?: GlobalNavigationKind; + tabId?: string; + title?: string; + mode?: string; + tableInfoTab?: string; + sourceView?: boolean; + initialEditing?: boolean; + connectionId?: string; + database?: string; + catalog?: string; + schema?: string; + tableName?: string; + tableType?: string; + objectName?: string; + objectType?: ObjectSourceKind; + objectSignature?: string; +} + +export function navigationEntryKey(entry: GlobalNavigationEntry): string { + return JSON.stringify([ + entry.surface, + entry.kind ?? "", + entry.tabId ?? "", + entry.connectionId ?? "", + entry.database ?? "", + entry.catalog ?? "", + entry.schema ?? "", + entry.tableName ?? "", + entry.objectName ?? "", + entry.objectType ?? "", + entry.objectSignature ?? "", + entry.mode ?? "", + entry.tableInfoTab ?? "", + entry.sourceView ? "source" : "", + entry.initialEditing ?? true, + entry.tableType ?? "", + ]); +} diff --git a/apps/desktop/src/lib/navigation/navigationRestoreTargets.ts b/apps/desktop/src/lib/navigation/navigationRestoreTargets.ts new file mode 100644 index 0000000000..581bde890b --- /dev/null +++ b/apps/desktop/src/lib/navigation/navigationRestoreTargets.ts @@ -0,0 +1,51 @@ +import type { NavigationTarget } from "@/composables/useNavigationTargets"; +import type { DataTabReuseMode } from "@/lib/tabs/dataTabReuseMode"; +import type { QueryTab } from "@/types/database"; +import type { GlobalNavigationEntry } from "./navigationEntry"; + +export type SourceNavigationIdentity = NonNullable["request"] & { initialEditing: boolean }; + +export function captureSourceNavigationIdentity(tab: Pick, previous?: SourceNavigationIdentity): SourceNavigationIdentity | undefined { + const identity = tab.objectSource ?? tab.sourceLoad?.request ?? previous; + if (!identity) return undefined; + // sourceLoad disappears after loading, including for read-only sequences. + // Preserve false explicitly; falling back to true changes CREATE to ALTER. + return { ...identity, initialEditing: tab.sourceLoad?.initialEditing ?? previous?.initialEditing ?? true }; +} + +export function navigationSourceTarget(entry: GlobalNavigationEntry) { + if (!entry.connectionId || !entry.database || !entry.objectName || !entry.objectType) return null; + return { + connectionId: entry.connectionId, + database: entry.database, + title: entry.title || `Source - ${entry.objectName}`, + schema: entry.schema, + catalog: entry.catalog, + initialEditing: entry.initialEditing ?? true, + request: { name: entry.objectName, objectType: entry.objectType, signature: entry.objectSignature }, + }; +} + +export function navigationTableTarget(entry: GlobalNavigationEntry): NavigationTarget | null { + if (!entry.connectionId || !entry.database || !entry.tableName) return null; + return { connectionId: entry.connectionId, database: entry.database, catalog: entry.catalog, schema: entry.schema, tableName: entry.tableName, tableType: entry.tableType }; +} + +export function matchesNavigationDataTab(tab: QueryTab, entry: GlobalNavigationEntry): boolean { + return ( + tab.mode === (entry.mode === "mongo" ? "mongo" : "data") && + tab.connectionId === entry.connectionId && + tab.database === entry.database && + (tab.tableMeta?.catalog ?? tab.catalog ?? "") === (entry.catalog ?? "") && + (tab.tableMeta?.schema ?? tab.schema ?? "") === (entry.schema ?? "") && + tab.tableMeta?.tableName === entry.tableName + ); +} + +export function canRestoreQueryNavigationEntry(entry: GlobalNavigationEntry, tabs: readonly QueryTab[], activeTab: QueryTab | undefined, reuseMode: DataTabReuseMode): boolean { + if (entry.surface !== "query" || entry.kind !== "data") return true; + if (!navigationTableTarget(entry)) return false; + // Activating an existing target never overwrites the current data draft. + if (tabs.some((tab) => matchesNavigationDataTab(tab, entry))) return true; + return reuseMode !== "active-tab" || !activeTab || (!(activeTab.pendingDataChangeCount ?? 0) && !activeTab.hasPendingDataEditorDraft); +} diff --git a/apps/desktop/src/lib/tabs/tabNavigationHistory.ts b/apps/desktop/src/lib/tabs/tabNavigationHistory.ts deleted file mode 100644 index d8e159976c..0000000000 --- a/apps/desktop/src/lib/tabs/tabNavigationHistory.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface TabNavigationHistory { - entries: string[]; - index: number; -} - -export interface TabNavigationHistoryMove { - history: TabNavigationHistory; - tabId: string; -} - -const MAX_TAB_NAVIGATION_HISTORY = 100; - -export function createTabNavigationHistory(): TabNavigationHistory { - return { entries: [], index: -1 }; -} - -export function recordTabVisit(history: TabNavigationHistory, tabId: string): TabNavigationHistory { - if (!tabId || history.entries[history.index] === tabId) return history; - - const entries = [...history.entries.slice(0, history.index + 1), tabId].slice(-MAX_TAB_NAVIGATION_HISTORY); - return { entries, index: entries.length - 1 }; -} - -export function moveInTabNavigationHistory(history: TabNavigationHistory, direction: -1 | 1, openTabIds: ReadonlySet, currentTabId: string | null = null): TabNavigationHistoryMove | null { - for (let index = history.index + direction; index >= 0 && index < history.entries.length; index += direction) { - const tabId = history.entries[index]; - if (tabId !== currentTabId && openTabIds.has(tabId)) return { history: { entries: history.entries, index }, tabId }; - } - return null; -} diff --git a/apps/desktop/src/stores/navigationStore.ts b/apps/desktop/src/stores/navigationStore.ts new file mode 100644 index 0000000000..46bbc9e73e --- /dev/null +++ b/apps/desktop/src/stores/navigationStore.ts @@ -0,0 +1,89 @@ +import { computed, ref } from "vue"; +import { defineStore } from "pinia"; +import { createGlobalNavigationHistory, moveGlobalNavigation, recordGlobalNavigation } from "@/lib/navigation/globalNavigationHistory"; +import type { GlobalNavigationEntry } from "@/lib/navigation/navigationEntry"; + +export const useNavigationStore = defineStore("navigation", () => { + const history = ref(createGlobalNavigationHistory()); + const restoring = ref(false); + let restoreSerial = 0; + let expectedEntryId: string | undefined; + let rollbackHistory: ReturnType | undefined; + + const entries = computed(() => history.value.entries); + const currentIndex = computed(() => history.value.index); + const canGoBack = computed(() => history.value.index > 0); + const canGoForward = computed(() => history.value.index >= 0 && history.value.index < history.value.entries.length - 1); + + function record(entry: GlobalNavigationEntry | null): void { + if (restoring.value) { + if (entry?.id === expectedEntryId) return; + // A user navigation supersedes the pending restore. Before a target has + // opened, undo the speculative history move before recording that visit. + if (rollbackHistory) restoreSnapshot(rollbackHistory); + endRestore(restoreSerial); + ++restoreSerial; + } + if (entry) history.value = recordGlobalNavigation(history.value, entry); + } + + function beginRestore(entry: GlobalNavigationEntry, previousHistory: ReturnType): number { + restoring.value = true; + expectedEntryId = entry.id; + rollbackHistory = previousHistory; + return ++restoreSerial; + } + + function acceptRestore(serial: number, entry: GlobalNavigationEntry): boolean { + if (!isCurrentRestore(serial)) return false; + expectedEntryId = entry.id; + rollbackHistory = undefined; + replaceCurrent(entry); + return true; + } + + function isCurrentRestore(serial: number): boolean { + return restoring.value && serial === restoreSerial; + } + + function endRestore(serial: number): void { + if (serial !== restoreSerial) return; + restoring.value = false; + expectedEntryId = undefined; + rollbackHistory = undefined; + } + + function move(direction: -1 | 1, isValid: (entry: GlobalNavigationEntry) => boolean): GlobalNavigationEntry | null { + const result = moveGlobalNavigation(history.value, direction, isValid); + if (!result) return null; + history.value = result.history; + return result.entry; + } + + function peek(direction: -1 | 1, isValid: (entry: GlobalNavigationEntry) => boolean): GlobalNavigationEntry | null { + return moveGlobalNavigation(history.value, direction, isValid)?.entry ?? null; + } + + function snapshot() { + return { entries: [...history.value.entries], index: history.value.index }; + } + + function restoreSnapshot(snapshotValue: { entries: GlobalNavigationEntry[]; index: number }): void { + history.value = { entries: [...snapshotValue.entries], index: snapshotValue.index }; + } + + function replaceCurrent(entry: GlobalNavigationEntry): void { + if (history.value.index < 0) return; + const entries = [...history.value.entries]; + entries[history.value.index] = entry; + history.value = { entries, index: history.value.index }; + } + + function clear(): void { + endRestore(restoreSerial); + ++restoreSerial; + history.value = createGlobalNavigationHistory(); + } + + return { entries, currentIndex, canGoBack, canGoForward, restoring, record, beginRestore, acceptRestore, isCurrentRestore, endRestore, move, peek, snapshot, restoreSnapshot, replaceCurrent, clear }; +}); diff --git a/packages/app-tests/tabNavigationHistory.test.ts b/packages/app-tests/tabNavigationHistory.test.ts deleted file mode 100644 index 6b21c87728..0000000000 --- a/packages/app-tests/tabNavigationHistory.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { strict as assert } from "node:assert"; -import { test } from "vitest"; -import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "../../apps/desktop/src/lib/tabs/tabNavigationHistory.ts"; - -test("navigates backward and forward in tab visit order", () => { - let history = createTabNavigationHistory(); - history = recordTabVisit(history, "A"); - history = recordTabVisit(history, "D"); - history = recordTabVisit(history, "B"); - const openTabs = new Set(["A", "B", "C", "D"]); - - const backToD = moveInTabNavigationHistory(history, -1, openTabs); - assert.equal(backToD?.tabId, "D"); - history = backToD!.history; - - const backToA = moveInTabNavigationHistory(history, -1, openTabs); - assert.equal(backToA?.tabId, "A"); - history = backToA!.history; - - const forwardToD = moveInTabNavigationHistory(history, 1, openTabs); - assert.equal(forwardToD?.tabId, "D"); - history = forwardToD!.history; - - assert.equal(moveInTabNavigationHistory(history, 1, openTabs)?.tabId, "B"); -}); - -test("clears the forward branch after visiting a tab manually", () => { - let history = createTabNavigationHistory(); - history = recordTabVisit(history, "A"); - history = recordTabVisit(history, "D"); - history = recordTabVisit(history, "B"); - - history = moveInTabNavigationHistory(history, -1, new Set(["A", "B", "C", "D"]))!.history; - history = recordTabVisit(history, "C"); - - assert.deepEqual(history, { entries: ["A", "D", "C"], index: 2 }); - assert.equal(moveInTabNavigationHistory(history, 1, new Set(["A", "B", "C", "D"])), null); -}); - -test("skips closed tabs while navigating", () => { - let history = createTabNavigationHistory(); - history = recordTabVisit(history, "A"); - history = recordTabVisit(history, "B"); - history = recordTabVisit(history, "C"); - const openTabs = new Set(["A", "C"]); - - const backToA = moveInTabNavigationHistory(history, -1, openTabs); - assert.equal(backToA?.tabId, "A"); - assert.equal(moveInTabNavigationHistory(backToA!.history, 1, openTabs)?.tabId, "C"); -}); - -test("skips history entries matching the active tab", () => { - let history = createTabNavigationHistory(); - history = recordTabVisit(history, "A"); - history = recordTabVisit(history, "D"); - history = recordTabVisit(history, "B"); - history = recordTabVisit(history, "D"); - - assert.equal(moveInTabNavigationHistory(history, -1, new Set(["A", "D"]), "D")?.tabId, "A"); -});