import type { QueryTab, TabOutputView } from "@/types/database"; import { sanitizeTabUiState } from "@/lib/tabs/tabUiState"; export const OPEN_TABS_STORAGE_KEY = "dbx-open-tabs"; export const ACTIVE_TAB_STORAGE_KEY = "dbx-active-tab"; export interface SavedQueryResultRun { id: string; title: string; sequence: number; sql: string; createdAt: number; pinned?: boolean; activeResultIndex?: number; resultCacheKey?: string; resultEvicted?: boolean; } export interface SavedOpenTab { id: string; createdAt?: number; title: string; customTitle?: boolean; connectionId: string; database: string; /** 原连接已被删除但页签被保留时记录的原连接名,用于新建同名连接后重新绑定。 */ detachedConnectionName?: string; catalog?: string; schema?: string; sql: string; editorViewport?: QueryTab["editorViewport"]; editorSelection?: QueryTab["editorSelection"]; originalSql?: string; savedSqlId?: string; externalSqlPath?: string; externalSqlFileVersion?: QueryTab["externalSqlFileVersion"]; externalSqlIgnoredFileVersion?: QueryTab["externalSqlIgnoredFileVersion"]; externalSqlFileMissing?: boolean; lastExecutedSql?: string; resultBaseSql?: string; resultSortedSql?: string; resultSortColumn?: string; resultSortColumnIndex?: number; resultSortDirection?: QueryTab["resultSortDirection"]; resultSortMode?: QueryTab["resultSortMode"]; orderByInput?: string; resultPageLimit?: number; resultPageOffset?: number; whereInput?: string; pinned?: boolean; mode?: QueryTab["mode"]; pluginWorkbench?: QueryTab["pluginWorkbench"]; pluginFilesystem?: QueryTab["pluginFilesystem"]; autoCommit?: boolean; mqTenant?: string; mqInitialTab?: QueryTab["mqInitialTab"]; nacosNamespace?: string; nacosNamespaceName?: string; structureTableName?: string; structureDraft?: QueryTab["structureDraft"]; objectBrowser?: QueryTab["objectBrowser"]; objectSource?: QueryTab["objectSource"]; sourceView?: boolean; tableComment?: QueryTab["tableComment"]; tableMeta?: QueryTab["tableMeta"]; mongoEditTarget?: QueryTab["mongoEditTarget"]; resultEvicted?: boolean; resultCacheKey?: string; resultRuns?: SavedQueryResultRun[]; activeResultRunId?: string; resultAutoSave?: boolean; uiState?: QueryTab["uiState"]; } export interface RestoredOpenTabs { tabs: QueryTab[]; activeTabId: string | null; } /** Group membership persisted with the open-tabs payload. */ export interface PersistedEditorGroup { id: string; tabIds: string[]; activeTabId: string | null; } /** Shared open-tabs transport payload — the single definition both backend adapters persist. */ export interface OpenTabsStatePayload { tabs: unknown[]; activeTabId: string | null; groups?: PersistedEditorGroup[]; focusedGroupId?: string; orientation?: "vertical" | "horizontal"; sizes?: number[]; } export type OpenTabsRestoreFilter = "all" | "pinned"; export interface OpenTabsRestoreOptions { queryOnly?: boolean; filter?: OpenTabsRestoreFilter; validConnectionIds?: Iterable; } function shouldPersistTabSql(tab: QueryTab) { if (!tab.savedSqlId) return true; return tab.originalSql !== undefined && tab.sql !== tab.originalSql; } // Pending object-source tabs are transient work surfaces. Persisting one while // its request is in flight would restore an empty source tab after restart, // because the request itself is intentionally not durable. function shouldPersistOpenTab(tab: QueryTab): boolean { return !tab.sourceLoad; } function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) { if (mode !== "query") return undefined; if (tab.externalSqlPath) return tab.originalSql ?? sql; if (tab.savedSqlId) return tab.originalSql ?? (sql ? "" : undefined); // Prefer the persisted originalSql so a clean prefilled query tab (sql === originalSql) // restores clean instead of being marked dirty. Older saved state without this field // falls through to "" (preserving prior behavior for user-edited scratch tabs). if (tab.originalSql !== undefined) return tab.originalSql; return ""; } function restoredEditorSelection(tab: SavedOpenTab, docLength: number): QueryTab["editorSelection"] { const selection = tab.editorSelection; if (!selection || !Number.isFinite(selection.anchor) || !Number.isFinite(selection.head)) return undefined; return { anchor: Math.min(Math.max(0, Math.trunc(selection.anchor)), docLength), head: Math.min(Math.max(0, Math.trunc(selection.head)), docLength), }; } function restoredEditorViewport(tab: SavedOpenTab): QueryTab["editorViewport"] { const viewport = tab.editorViewport; if (!viewport || !Number.isFinite(viewport.scrollTop) || !Number.isFinite(viewport.scrollLeft)) return undefined; return { scrollTop: Math.max(0, viewport.scrollTop), scrollLeft: Math.max(0, viewport.scrollLeft), }; } const TAB_OUTPUT_VIEWS = new Set(["result", "summary", "explain", "chart", "messages", "profile"]); function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] { const activeOutputView = tab.uiState?.activeOutputView; const resultPaneOpen = tab.uiState?.resultPaneOpen; const restored: NonNullable = {}; if (activeOutputView && TAB_OUTPUT_VIEWS.has(activeOutputView)) restored.activeOutputView = activeOutputView; if (typeof resultPaneOpen === "boolean") restored.resultPaneOpen = resultPaneOpen; if (tab.uiState?.page) { const sanitized = sanitizeTabUiState({ page: tab.uiState.page }); if (sanitized?.page) restored.page = sanitized.page; } return Object.keys(restored).length > 0 ? restored : undefined; } export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { return tabs.filter(shouldPersistOpenTab).map((tab) => ({ id: tab.id, ...(typeof tab.createdAt === "number" ? { createdAt: tab.createdAt } : {}), title: tab.title, ...(tab.customTitle ? { customTitle: true } : {}), connectionId: tab.connectionId, database: tab.database, ...(tab.detachedConnectionName ? { detachedConnectionName: tab.detachedConnectionName } : {}), ...(tab.catalog !== undefined ? { catalog: tab.catalog } : {}), schema: tab.schema, sql: shouldPersistTabSql(tab) ? tab.sql : "", ...(tab.editorViewport ? { editorViewport: tab.editorViewport } : {}), ...(tab.editorSelection ? { editorSelection: tab.editorSelection } : {}), // File-backed drafts retain their baseline, including when the edited SQL is empty. // Clean file-backed tabs omit it so startup can hydrate the current file content. ...(tab.originalSql !== undefined && ((!tab.savedSqlId && !tab.externalSqlPath) || tab.sql !== tab.originalSql) ? { originalSql: tab.originalSql } : {}), savedSqlId: tab.savedSqlId, externalSqlPath: tab.externalSqlPath, ...(tab.externalSqlFileVersion ? { externalSqlFileVersion: tab.externalSqlFileVersion } : {}), ...(tab.externalSqlIgnoredFileVersion ? { externalSqlIgnoredFileVersion: tab.externalSqlIgnoredFileVersion } : {}), ...(tab.externalSqlFileMissing ? { externalSqlFileMissing: true } : {}), ...(tab.lastExecutedSql !== undefined ? { lastExecutedSql: tab.lastExecutedSql } : {}), ...(tab.resultBaseSql !== undefined ? { resultBaseSql: tab.resultBaseSql } : {}), ...(tab.resultSortedSql !== undefined ? { resultSortedSql: tab.resultSortedSql } : {}), ...(tab.resultSortColumn !== undefined ? { resultSortColumn: tab.resultSortColumn } : {}), ...(tab.resultSortColumnIndex !== undefined ? { resultSortColumnIndex: tab.resultSortColumnIndex } : {}), ...(tab.resultSortDirection !== undefined ? { resultSortDirection: tab.resultSortDirection } : {}), ...(tab.resultSortMode !== undefined ? { resultSortMode: tab.resultSortMode } : {}), ...(tab.orderByInput !== undefined ? { orderByInput: tab.orderByInput } : {}), ...(tab.resultPageLimit !== undefined ? { resultPageLimit: tab.resultPageLimit } : {}), ...(tab.resultPageOffset !== undefined ? { resultPageOffset: tab.resultPageOffset } : {}), ...(tab.whereInput !== undefined ? { whereInput: tab.whereInput } : {}), pinned: tab.pinned, mode: tab.mode, ...(tab.pluginWorkbench ? { pluginWorkbench: tab.pluginWorkbench } : {}), ...(tab.pluginFilesystem ? { pluginFilesystem: tab.pluginFilesystem } : {}), ...(tab.mode === "query" && tab.autoCommit !== undefined ? { autoCommit: tab.autoCommit } : {}), ...(tab.mqTenant !== undefined ? { mqTenant: tab.mqTenant } : {}), ...(tab.mqInitialTab !== undefined ? { mqInitialTab: tab.mqInitialTab } : {}), ...(tab.nacosNamespace !== undefined ? { nacosNamespace: tab.nacosNamespace } : {}), ...(tab.nacosNamespaceName !== undefined ? { nacosNamespaceName: tab.nacosNamespaceName } : {}), ...(tab.structureTableName !== undefined ? { structureTableName: tab.structureTableName } : {}), ...(tab.structureDraft ? { structureDraft: JSON.parse(JSON.stringify(tab.structureDraft)) } : {}), objectBrowser: tab.objectBrowser, objectSource: tab.objectSource, ...(tab.sourceView ? { sourceView: true } : {}), ...(tab.tableComment !== undefined ? { tableComment: tab.tableComment } : {}), tableMeta: tab.tableMeta, ...(tab.mongoEditTarget !== undefined ? { mongoEditTarget: tab.mongoEditTarget } : {}), ...(tab.mode !== "data" && tab.resultEvicted ? { resultEvicted: true } : {}), ...(tab.mode !== "data" && tab.resultEvicted && tab.resultCacheKey !== undefined ? { resultCacheKey: tab.resultCacheKey } : {}), ...(tab.mode === "query" && tab.resultRuns?.length ? { resultRuns: tab.resultRuns.map((run) => ({ id: run.id, title: run.title, sequence: run.sequence, sql: run.sql, createdAt: run.createdAt, ...(run.pinned ? { pinned: true } : {}), activeResultIndex: run.activeResultIndex, ...(run.resultCacheKey !== undefined ? { resultCacheKey: run.resultCacheKey } : {}), ...(run.resultEvicted ? { resultEvicted: true } : {}), })), } : {}), ...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}), ...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}), ...(tab.pluginWorkbench ? { pluginWorkbench: tab.pluginWorkbench } : {}), ...(tab.pluginFilesystem ? { pluginFilesystem: tab.pluginFilesystem } : {}), ...(tab.uiState ? { uiState: sanitizeTabUiState(tab.uiState) } : {}), ...(tab.mode === "query" && tab.resultAutoSave ? { resultAutoSave: true } : {}), })); } function isSavedOpenTab(value: unknown): value is SavedOpenTab { if (!value || typeof value !== "object") return false; const tab = value as Record; return typeof tab.id === "string" && typeof tab.title === "string" && typeof tab.connectionId === "string" && typeof tab.database === "string" && (typeof tab.sql === "string" || typeof tab.savedSqlId === "string"); } function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!Array.isArray(parsed)) return { tabs: [], activeTabId: null }; try { const validConnectionIds = options.validConnectionIds ? new Set(options.validConnectionIds) : undefined; const saved = parsed.filter(isSavedOpenTab); const filtered = saved.filter((tab) => { const mode = tab.mode ?? "query"; if (options.queryOnly && mode !== "query") return false; if (options.filter === "pinned" && !tab.pinned) return false; if (mode !== "query" && validConnectionIds && !validConnectionIds.has(tab.connectionId)) return false; return true; }); const tabs: QueryTab[] = filtered.map((tab) => { const mode = tab.mode ?? "query"; const resultRuns = mode === "query" ? tab.resultRuns?.map((run) => ({ ...run, result: undefined, results: undefined, resultCacheState: run.resultCacheKey ? ("disk" as const) : undefined, })) : undefined; return { ...tab, mode, sql: typeof tab.sql === "string" ? tab.sql : "", isExecuting: false, redisMonitorActive: false, isCancelling: false, queryExecutionStartedAt: undefined, // sourceLoad 是纯运行期态(serializeOpenTabs 不落盘)。这里显式清空, // 让「恢复后的 tab 不会停在加载中」成为不变量,而不是依赖白名单的副作用。 sourceLoad: undefined, executingResultRunId: undefined, editorViewport: restoredEditorViewport(tab), editorSelection: restoredEditorSelection(tab, typeof tab.sql === "string" ? tab.sql.length : 0), isExplaining: false, originalSql: restoredOriginalSql(tab, mode, typeof tab.sql === "string" ? tab.sql : ""), resultEvicted: mode === "data" ? undefined : tab.resultEvicted, resultCacheKey: mode === "data" ? undefined : tab.resultCacheKey, resultCacheState: mode !== "data" && tab.resultCacheKey ? "disk" : undefined, resultRuns, activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id, resultAutoSave: mode === "query" && typeof tab.resultAutoSave === "boolean" ? tab.resultAutoSave : undefined, uiState: restoredTabUiState(tab), }; }); const activeTabId = rawActiveTabId || null; return { tabs, activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id || null, }; } catch { return { tabs: [], activeTabId: null }; } } export function restoreOpenTabsPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!payload) return { tabs: [], activeTabId: null }; return restoreOpenTabsArray(payload.tabs, typeof payload.activeTabId === "string" ? payload.activeTabId : null, options); } export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: OpenTabsRestoreOptions = {}): RestoredOpenTabs { if (!rawTabs) return { tabs: [], activeTabId: null }; try { return restoreOpenTabsArray(JSON.parse(rawTabs), rawActiveTabId, options); } catch { return { tabs: [], activeTabId: null }; } }