mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 10:28:44 +00:00
feat: add global back and forward navigation
This commit is contained in:
+219
-24
@@ -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
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7840,9 +7840,9 @@ export default withEnglishFallback({
|
||||
shortcutFocusSearch: "現在のビューの検索にフォーカス",
|
||||
shortcutQuickOpen: "クイックオープン (すべてのデータベースオブジェクトを検索)",
|
||||
shortcutGlobalSearch: "グローバルファイル内容検索",
|
||||
shortcutNavigateTabHistoryBack: "前に表示したタブに戻る",
|
||||
shortcutNavigateTabHistoryBack: "グローバルに戻る",
|
||||
shortcutTabSwitcher: "タブスイッチャー(押し続けて表示、離して切り替え)",
|
||||
shortcutNavigateTabHistoryForward: "次に表示したタブに進む",
|
||||
shortcutNavigateTabHistoryForward: "グローバルに進む",
|
||||
shortcutSwitchToPreviousTab: "前のタブに切り替え",
|
||||
shortcutSwitchToNextTab: "次のタブに切り替え",
|
||||
shortcutSwitchToTab1: "タブ 1 に切り替え",
|
||||
|
||||
@@ -7694,9 +7694,9 @@ export default withEnglishFallback({
|
||||
shortcutFocusSearch: "현재 화면 검색에 포커스",
|
||||
shortcutQuickOpen: "빠른 열기 (모든 데이터베이스 객체 검색)",
|
||||
shortcutGlobalSearch: "전역 파일 내용 검색",
|
||||
shortcutNavigateTabHistoryBack: "이전에 본 탭으로 돌아가기",
|
||||
shortcutNavigateTabHistoryBack: "전체 뒤로",
|
||||
shortcutTabSwitcher: "탭 전환기 (길게 눌러 표시, 놓으면 전환)",
|
||||
shortcutNavigateTabHistoryForward: "다음에 본 탭으로 이동",
|
||||
shortcutNavigateTabHistoryForward: "전체 앞으로",
|
||||
shortcutSwitchToPreviousTab: "이전 탭으로 전환",
|
||||
shortcutSwitchToNextTab: "다음 탭으로 전환",
|
||||
shortcutSwitchToTab1: "탭 1로 전환",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9135,9 +9135,9 @@ export default withEnglishFallback({
|
||||
shortcutFocusSearch: "Фокус на поиске в текущем представлении",
|
||||
shortcutQuickOpen: "Быстрое открытие (поиск по всем объектам базы данных)",
|
||||
shortcutGlobalSearch: "Глобальный поиск по содержимому файлов",
|
||||
shortcutNavigateTabHistoryBack: "Вернуться к предыдущей просмотренной вкладке",
|
||||
shortcutNavigateTabHistoryBack: "Назад по навигации",
|
||||
shortcutTabSwitcher: "Переключатель вкладок (удерживайте, чтобы показать, отпустите, чтобы переключиться)",
|
||||
shortcutNavigateTabHistoryForward: "Перейти к следующей просмотренной вкладке",
|
||||
shortcutNavigateTabHistoryForward: "Вперёд по навигации",
|
||||
shortcutSwitchToPreviousTab: "Переключиться на предыдущую вкладку",
|
||||
shortcutSwitchToNextTab: "Переключиться на следующую вкладку",
|
||||
shortcutSwitchToTab1: "Переключиться на вкладку 1",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -8824,9 +8824,9 @@ export default withEnglishFallback({
|
||||
shortcutFocusSearch: "聚焦当前页面搜索",
|
||||
shortcutQuickOpen: "快速打开 (搜索所有数据库对象)",
|
||||
shortcutGlobalSearch: "全局文件内容搜索",
|
||||
shortcutNavigateTabHistoryBack: "返回到上一个查看的标签页",
|
||||
shortcutNavigateTabHistoryBack: "全局后退",
|
||||
shortcutTabSwitcher: "标签页切换器(按住显示,松开切换)",
|
||||
shortcutNavigateTabHistoryForward: "前进到下一个查看的标签页",
|
||||
shortcutNavigateTabHistoryForward: "全局前进",
|
||||
shortcutSwitchToPreviousTab: "切换到左侧标签页",
|
||||
shortcutSwitchToNextTab: "切换到右侧标签页",
|
||||
shortcutSwitchToTab1: "切换到第 1 个标签页",
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user