feat: add global back and forward navigation

This commit is contained in:
二丫讲梵
2026-10-07 04:04:46 +08:00
committed by GitHub
parent 3d9e590e94
commit 4e111225ee
27 changed files with 1041 additions and 146 deletions
+219 -24
View File
@@ -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<unknown> };
const pluginWorkbenchTabRefs = new Map<string, PluginWorkbenchTabHandle>();
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<string, SourceNavigationIdentity>();
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<typeof navigationStore.snapshot>, restoreSerial: number): Promise<boolean> {
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<boolean> {
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<boolean>((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;
@@ -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<typeof import("@/lib/tabs/tabResultCache")>()),
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<void>((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" };
@@ -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 },
);
}
@@ -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
+2 -2
View File
@@ -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ç",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -7840,9 +7840,9 @@ export default withEnglishFallback({
shortcutFocusSearch: "現在のビューの検索にフォーカス",
shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)",
shortcutGlobalSearch: "グローバルファイル内容検索",
shortcutNavigateTabHistoryBack: "前に表示したタブに戻る",
shortcutNavigateTabHistoryBack: "グローバルに戻る",
shortcutTabSwitcher: "タブスイッチャー(押し続けて表示、離して切り替え)",
shortcutNavigateTabHistoryForward: "次に表示したタブに進む",
shortcutNavigateTabHistoryForward: "グローバルに進む",
shortcutSwitchToPreviousTab: "前のタブに切り替え",
shortcutSwitchToNextTab: "次のタブに切り替え",
shortcutSwitchToTab1: "タブ 1 に切り替え",
+2 -2
View File
@@ -7694,9 +7694,9 @@ export default withEnglishFallback({
shortcutFocusSearch: "현재 화면 검색에 포커스",
shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)",
shortcutGlobalSearch: "전역 파일 내용 검색",
shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기",
shortcutNavigateTabHistoryBack: "전체 뒤로",
shortcutTabSwitcher: "탭 전환기 (길게 눌러 표시, 놓으면 전환)",
shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동",
shortcutNavigateTabHistoryForward: "전체 앞으로",
shortcutSwitchToPreviousTab: "이전 탭으로 전환",
shortcutSwitchToNextTab: "다음 탭으로 전환",
shortcutSwitchToTab1: "탭 1로 전환",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -9135,9 +9135,9 @@ export default withEnglishFallback({
shortcutFocusSearch: "Фокус на поиске в текущем представлении",
shortcutQuickOpen: "Быстрое открытие (поиск по всем объектам базы данных)",
shortcutGlobalSearch: "Глобальный поиск по содержимому файлов",
shortcutNavigateTabHistoryBack: "Вернуться к предыдущей просмотренной вкладке",
shortcutNavigateTabHistoryBack: "Назад по навигации",
shortcutTabSwitcher: "Переключатель вкладок (удерживайте, чтобы показать, отпустите, чтобы переключиться)",
shortcutNavigateTabHistoryForward: "Перейти к следующей просмотренной вкладке",
shortcutNavigateTabHistoryForward: "Вперёд по навигации",
shortcutSwitchToPreviousTab: "Переключиться на предыдущую вкладку",
shortcutSwitchToNextTab: "Переключиться на следующую вкладку",
shortcutSwitchToTab1: "Переключиться на вкладку 1",
+2 -2
View File
@@ -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ç",
+2 -2
View File
@@ -8824,9 +8824,9 @@ export default withEnglishFallback({
shortcutFocusSearch: "聚焦当前页面搜索",
shortcutQuickOpen: "快速打开 (搜索所有数据库对象)",
shortcutGlobalSearch: "全局文件内容搜索",
shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页",
shortcutNavigateTabHistoryBack: "全局后退",
shortcutTabSwitcher: "标签页切换器(按住显示,松开切换)",
shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页",
shortcutNavigateTabHistoryForward: "全局前进",
shortcutSwitchToPreviousTab: "切换到左侧标签页",
shortcutSwitchToNextTab: "切换到右侧标签页",
shortcutSwitchToTab1: "切换到第 1 个标签页",
+2 -2
View File
@@ -7167,9 +7167,9 @@ export default withEnglishFallback({
shortcutFocusSearch: "聚焦目前頁面搜尋",
shortcutQuickOpen: "快速開啟 (搜尋所有資料庫物件)",
shortcutGlobalSearch: "全域檔案內容搜尋",
shortcutNavigateTabHistoryBack: "返回到上一個檢視的分頁",
shortcutNavigateTabHistoryBack: "全域後退",
shortcutTabSwitcher: "分頁切換器(按住顯示,放開切換)",
shortcutNavigateTabHistoryForward: "前進到下一個檢視的分頁",
shortcutNavigateTabHistoryForward: "全域前進",
shortcutSwitchToPreviousTab: "切換到左側分頁",
shortcutSwitchToNextTab: "切換到右側分頁",
shortcutSwitchToTab1: "切換到第 1 個分頁",
@@ -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> = {}): 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<boolean>;
};
// 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<typeof harness>, 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<void>((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<void>((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<void>((_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);
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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" }));
});
});
@@ -0,0 +1,26 @@
import type { GlobalNavigationEntry } from "./navigationEntry";
export interface GlobalNavigationExecutorHandlers {
restoreQuery: (entry: GlobalNavigationEntry) => Promise<boolean>;
activateSettings: () => void;
activateDriverStore: () => void;
activatePluginCenter: () => void;
}
export async function restoreGlobalNavigationEntry(entry: GlobalNavigationEntry, handlers: GlobalNavigationExecutorHandlers): Promise<boolean> {
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;
}
}
@@ -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;
}
@@ -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 ?? "",
]);
}
@@ -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<QueryTab["sourceLoad"]>["request"] & { initialEditing: boolean };
export function captureSourceNavigationIdentity(tab: Pick<QueryTab, "sourceLoad" | "objectSource">, 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);
}
@@ -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<string>, 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;
}
@@ -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<typeof snapshot> | 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<typeof snapshot>): 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 };
});
-60
View File
@@ -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");
});