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

250 lines
10 KiB
TypeScript

import type { QueryTab } from "@/types/database";
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;
catalog?: string;
schema?: string;
sql: string;
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"];
autoCommit?: boolean;
mqTenant?: string;
mqInitialTab?: QueryTab["mqInitialTab"];
nacosNamespace?: string;
nacosNamespaceName?: string;
structureTableName?: string;
objectBrowser?: QueryTab["objectBrowser"];
objectSource?: QueryTab["objectSource"];
sourceView?: boolean;
tableMeta?: QueryTab["tableMeta"];
mongoEditTarget?: QueryTab["mongoEditTarget"];
resultEvicted?: boolean;
resultCacheKey?: string;
resultRuns?: SavedQueryResultRun[];
activeResultRunId?: string;
resultAutoSave?: boolean;
}
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;
}
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 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 "";
}
export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
return tabs.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.catalog !== undefined ? { catalog: tab.catalog } : {}),
schema: tab.schema,
sql: shouldPersistTabSql(tab) ? tab.sql : "",
// Plain query tabs always round-trip originalSql. External-file tabs only persist it
// while dirty so their disk baseline survives restart without duplicating clean SQL.
...(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.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 } : {}),
objectBrowser: tab.objectBrowser,
objectSource: tab.objectSource,
...(tab.sourceView ? { sourceView: true } : {}),
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" && 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,
isCancelling: false,
queryExecutionStartedAt: undefined,
executingResultRunId: undefined,
editorViewport: undefined,
editorSelection: undefined,
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" && tab.resultAutoSave ? true : undefined,
};
});
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 };
}
}