Files
dbx/apps/desktop/src/lib/app/openTabsPersistence.ts
T

316 lines
14 KiB
TypeScript

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<string>;
}
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<TabOutputView>(["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<QueryTab["uiState"]> = {};
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<string, unknown>;
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 };
}
}