diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx index 7d0b09a5..6a53cf09 100644 --- a/frontend/src/components/QueryEditor.tsx +++ b/frontend/src/components/QueryEditor.tsx @@ -47,7 +47,12 @@ import { ORACLE_ROWID_LOCATOR_COLUMN, type EditRowLocator, } from '../utils/rowLocator'; -import { getQueryTabDraft, hasQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from '../utils/sqlFileTabDrafts'; +import { + clearQueryTabDraft, + getQueryTabDraft, + hasQueryTabDraft, + persistQueryTabDraftSnapshot, +} from '../utils/sqlFileTabDrafts'; import { buildEditableTriggerSql } from '../utils/triggerEditSql'; import { getColumnDefinitionComment, @@ -778,6 +783,15 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const savedQueries = useStore(state => state.savedQueries); const currentConnectionIdRef = useRef(currentConnectionId); const currentDbRef = useRef(currentDb); + const draftSnapshotTab = useMemo(() => ({ + id: tab.id, + title: tab.title, + connectionId: tab.connectionId, + dbName: tab.dbName, + filePath: tab.filePath, + savedQueryId: tab.savedQueryId, + readOnly: tab.readOnly, + }), [tab.connectionId, tab.dbName, tab.filePath, tab.id, tab.readOnly, tab.savedQueryId, tab.title]); const connectionsRef = useRef(connections); const columnsCacheRef = useRef>({}); const saveQuery = useStore(state => state.saveQuery); @@ -948,12 +962,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc const syncQueryDraft = useCallback((nextQuery: string) => { const next = String(nextQuery ?? ''); lastLocalQueryRef.current = next; - if (isExternalSQLFileTab) { - setSQLFileTabDraft(tab.id, next); - return; - } - setQueryTabDraft(tab.id, next); - }, [isExternalSQLFileTab, tab.id]); + persistQueryTabDraftSnapshot(draftSnapshotTab, next, { + connectionId: currentConnectionIdRef.current, + dbName: currentDbRef.current, + }); + }, [draftSnapshotTab]); const applyQueryState = useCallback((nextQuery: string) => { const next = String(nextQuery ?? ''); @@ -964,8 +977,11 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }, [isExternalSQLFileTab, syncQueryDraft]); useEffect(() => { - setQueryTabDraft(tab.id, query); - }, [query, tab.id]); + persistQueryTabDraftSnapshot(draftSnapshotTab, query, { + connectionId: currentConnectionIdRef.current || currentConnectionId, + dbName: currentDbRef.current || currentDb, + }); + }, [currentConnectionId, currentDb, draftSnapshotTab, query]); useEffect(() => { currentConnectionIdRef.current = currentConnectionId; @@ -1017,13 +1033,25 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc }); }, [currentConnectionId, currentDb, isExternalSQLFileTab, tab.id, updateQueryTabDraft]); + const getCurrentQuery = useCallback(() => { + const val = editorRef.current?.getValue?.(); + if (typeof val === 'string') return val; + return query || ''; + }, [query]); + useEffect(() => { if (!isExternalSQLFileTab) return; - setSQLFileTabDraft(tab.id, getCurrentQuery()); + persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { + connectionId: currentConnectionIdRef.current, + dbName: currentDbRef.current, + }); return () => { - setSQLFileTabDraft(tab.id, getCurrentQuery()); + persistQueryTabDraftSnapshot(draftSnapshotTab, getCurrentQuery(), { + connectionId: currentConnectionIdRef.current, + dbName: currentDbRef.current, + }); }; - }, [isExternalSQLFileTab, tab.id]); + }, [draftSnapshotTab, getCurrentQuery, isExternalSQLFileTab]); // 当此 Tab 成为活跃 Tab 时,将本实例的状态同步到模块级共享变量 // 确保 completion provider 始终使用当前活跃 Tab 的上下文 @@ -1198,12 +1226,6 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc refreshObjectDecorations(QUERY_EDITOR_LIVE_DECORATION_MAX_TEXT_LENGTH); }, [currentDb, refreshObjectDecorations]); - const getCurrentQuery = () => { - const val = editorRef.current?.getValue?.(); - if (typeof val === 'string') return val; - return query || ''; - }; - const insertTextIntoEditorAtPosition = useCallback((text: string, position?: { lineNumber: number; column: number } | null) => { const editor = editorRef.current; const monaco = monacoRef.current; @@ -5187,6 +5209,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc dbName: currentDb || tab.dbName || '', savedQueryId: persisted.id, }); + clearQueryTabDraft(tab.id); return persisted; }; @@ -5216,7 +5239,7 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc filePath, savedQueryId: undefined, }); - setSQLFileTabDraft(tab.id, sql); + clearQueryTabDraft(tab.id); message.success(translate('query_editor.message.sql_file_saved')); } catch (error) { message.error(translate('query_editor.message.save_sql_file_failed', { diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 437cb8ec..1c669e5d 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -111,6 +111,39 @@ describe('store appearance persistence', () => { expect(appearance.tableDoubleClickAction).toBe('open-design'); }); + it('restores query tabs from crash-recovery snapshots even when persisted tabs are missing', async () => { + storage.setItem('gonavi-query-tab-drafts-v1', JSON.stringify([ + { + tabId: 'query-recovery-1', + title: '异常恢复 SQL', + query: 'select 1;', + connectionId: 'conn-1', + dbName: 'main', + updatedAt: 1719655200000, + }, + ])); + storage.setItem('lite-db-storage', JSON.stringify({ + state: { + theme: 'dark', + }, + version: 13, + })); + + const { useStore } = await importStore(); + const tabs = useStore.getState().tabs; + + expect(tabs).toHaveLength(1); + expect(tabs[0]).toMatchObject({ + id: 'query-recovery-1', + title: '异常恢复 SQL', + type: 'query', + connectionId: 'conn-1', + dbName: 'main', + query: 'select 1;', + }); + expect(useStore.getState().activeTabId).toBe('query-recovery-1'); + }); + it('sanitizes invalid table double-click appearance settings', async () => { storage.setItem('lite-db-storage', JSON.stringify({ state: { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 22df3d87..38d23cab 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -73,6 +73,11 @@ import { saveSavedQueryToBackend, type SavedQueryBackend, } from "./utils/savedQueryPersistence"; +import { + clearQueryTabDraft, + getPersistedQueryTabDraftEntry, + listPersistedQueryTabDraftEntries, +} from "./utils/sqlFileTabDrafts"; import { deriveLegacyConnectionReadOnlyFlag, normalizeConnectionProtectionConfig, @@ -1681,18 +1686,29 @@ const sanitizeTableExportHistories = ( }; const sanitizeQueryTabs = (value: unknown): TabData[] => { - if (!Array.isArray(value)) return []; + const entries = Array.isArray(value) ? value : []; const result: TabData[] = []; const seenIds = new Set(); - value.forEach((entry, index) => { + entries.forEach((entry, index) => { if (!entry || typeof entry !== "object") return; const raw = entry as Record; if (raw.type !== "query") return; - const query = typeof raw.query === "string" ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) : ""; - const filePath = toTrimmedString(raw.filePath); - const savedQueryId = toTrimmedString(raw.savedQueryId); + let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; + const persistedDraft = getPersistedQueryTabDraftEntry(id); + const query = + typeof raw.query === "string" && raw.query.trim() + ? raw.query.slice(0, MAX_PERSISTED_QUERY_LENGTH) + : String(persistedDraft?.query || "").slice( + 0, + MAX_PERSISTED_QUERY_LENGTH, + ); + const filePath = toTrimmedString(raw.filePath, persistedDraft?.filePath); + const savedQueryId = toTrimmedString( + raw.savedQueryId, + persistedDraft?.savedQueryId, + ); const rawFormatRestoreSnapshot = raw.formatRestoreSnapshot && typeof raw.formatRestoreSnapshot === "object" ? (raw.formatRestoreSnapshot as Record) @@ -1704,7 +1720,6 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => { const formatRestoreCreatedAt = Number(rawFormatRestoreSnapshot?.createdAt); if (!query.trim() && !filePath && !savedQueryId) return; - let id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`; if (seenIds.has(id)) { id = `${id}-${index + 1}`; } @@ -1713,11 +1728,20 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => { result.push({ id, title: - toTrimmedString(raw.title, translate("sidebar.tab.new_query")) || + toTrimmedString( + raw.title, + toTrimmedString( + persistedDraft?.title, + translate("sidebar.tab.new_query"), + ), + ) || translate("sidebar.tab.new_query"), type: "query", - connectionId: toTrimmedString(raw.connectionId), - dbName: toTrimmedString(raw.dbName), + connectionId: toTrimmedString( + raw.connectionId, + persistedDraft?.connectionId, + ), + dbName: toTrimmedString(raw.dbName, persistedDraft?.dbName), query, resultPanelVisible: typeof raw.resultPanelVisible === "boolean" @@ -1725,7 +1749,7 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => { : undefined, filePath: filePath || undefined, savedQueryId: savedQueryId || undefined, - readOnly: raw.readOnly === true, + readOnly: raw.readOnly === true || persistedDraft?.readOnly === true, formatRestoreSnapshot: formatRestoreQuery ? { query: formatRestoreQuery, @@ -1737,6 +1761,31 @@ const sanitizeQueryTabs = (value: unknown): TabData[] => { }); }); + listPersistedQueryTabDraftEntries().forEach((entry) => { + if (seenIds.has(entry.tabId)) { + return; + } + const filePath = toTrimmedString(entry.filePath); + const savedQueryId = toTrimmedString(entry.savedQueryId); + if (!entry.query.trim() && !filePath && !savedQueryId) { + return; + } + seenIds.add(entry.tabId); + result.push({ + id: entry.tabId, + title: + toTrimmedString(entry.title, translate("sidebar.tab.new_query")) || + translate("sidebar.tab.new_query"), + type: "query", + connectionId: toTrimmedString(entry.connectionId), + dbName: toTrimmedString(entry.dbName) || undefined, + query: entry.query.slice(0, MAX_PERSISTED_QUERY_LENGTH), + filePath: filePath || undefined, + savedQueryId: savedQueryId || undefined, + readOnly: entry.readOnly === true, + }); + }); + return result.slice(0, MAX_PERSISTED_QUERY_TABS); }; @@ -2930,6 +2979,9 @@ export const useStore = create()( closeTab: (id) => set((state) => { const closedTab = state.tabs.find((t) => t.id === id); + if (closedTab?.type === "query") { + clearQueryTabDraft(closedTab.id); + } const newTabs = state.tabs.filter((t) => t.id !== id); let newActiveId = state.activeTabId; if (state.activeTabId === id) { @@ -2950,6 +3002,9 @@ export const useStore = create()( set((state) => { const keep = state.tabs.find((t) => t.id === id); if (!keep) return state; + state.tabs + .filter((tab) => tab.id !== id && tab.type === "query") + .forEach((tab) => clearQueryTabDraft(tab.id)); return { tabs: [keep], activeTabId: id, @@ -2961,6 +3016,10 @@ export const useStore = create()( set((state) => { const index = state.tabs.findIndex((t) => t.id === id); if (index === -1) return state; + state.tabs + .slice(0, index) + .filter((tab) => tab.type === "query") + .forEach((tab) => clearQueryTabDraft(tab.id)); const newTabs = state.tabs.slice(index); const activeStillExists = state.activeTabId ? newTabs.some((t) => t.id === state.activeTabId) @@ -2980,6 +3039,10 @@ export const useStore = create()( set((state) => { const index = state.tabs.findIndex((t) => t.id === id); if (index === -1) return state; + state.tabs + .slice(index + 1) + .filter((tab) => tab.type === "query") + .forEach((tab) => clearQueryTabDraft(tab.id)); const newTabs = state.tabs.slice(0, index + 1); const activeStillExists = state.activeTabId ? newTabs.some((t) => t.id === state.activeTabId) @@ -2999,6 +3062,13 @@ export const useStore = create()( set((state) => { const targetConnectionId = String(connectionId || "").trim(); if (!targetConnectionId) return state; + state.tabs + .filter( + (tab) => + tab.type === "query" && + String(tab.connectionId || "").trim() === targetConnectionId, + ) + .forEach((tab) => clearQueryTabDraft(tab.id)); const newTabs = state.tabs.filter( (t) => String(t.connectionId || "").trim() !== targetConnectionId, ); @@ -3030,6 +3100,15 @@ export const useStore = create()( const targetConnectionId = String(connectionId || "").trim(); const targetDbName = String(dbName || "").trim(); if (!targetConnectionId || !targetDbName) return state; + state.tabs + .filter((tab) => { + if (tab.type !== "query") return false; + const sameConnection = + String(tab.connectionId || "").trim() === targetConnectionId; + const sameDb = String(tab.dbName || "").trim() === targetDbName; + return sameConnection && sameDb; + }) + .forEach((tab) => clearQueryTabDraft(tab.id)); const newTabs = state.tabs.filter((tab) => { const sameConnection = String(tab.connectionId || "").trim() === targetConnectionId; @@ -3080,7 +3159,13 @@ export const useStore = create()( return { tabs: nextTabs }; }), - closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null, activeContext: null })), + closeAllTabs: () => + set((state) => { + state.tabs + .filter((tab) => tab.type === "query") + .forEach((tab) => clearQueryTabDraft(tab.id)); + return { tabs: [], activeTabId: null, activeContext: null }; + }), setActiveTab: (id) => set((state) => ({ diff --git a/frontend/src/utils/sqlFileTabDrafts.test.ts b/frontend/src/utils/sqlFileTabDrafts.test.ts index 82e68290..e609ab41 100644 --- a/frontend/src/utils/sqlFileTabDrafts.test.ts +++ b/frontend/src/utils/sqlFileTabDrafts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearQueryTabDraft, @@ -11,7 +11,47 @@ import { setSQLFileTabDraft, } from './sqlFileTabDrafts'; +class MemoryStorage implements Storage { + private data = new Map(); + + get length(): number { + return this.data.size; + } + + clear(): void { + this.data.clear(); + } + + getItem(key: string): string | null { + return this.data.has(key) ? this.data.get(key)! : null; + } + + key(index: number): string | null { + return Array.from(this.data.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.data.delete(key); + } + + setItem(key: string, value: string): void { + this.data.set(key, String(value)); + } +} + describe('sqlFileTabDrafts', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', new MemoryStorage()); + localStorage.removeItem('gonavi-query-tab-drafts-v1'); + clearQueryTabDraft('query-tab-1'); + clearQueryTabDraft('query-recovery-1'); + clearSQLFileTabDraft('tab-1'); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('stores query editor drafts outside the persisted tab state', () => { clearQueryTabDraft('query-tab-1'); @@ -43,4 +83,27 @@ describe('sqlFileTabDrafts', () => { expect(hasSQLFileTabDraft('tab-1')).toBe(false); }); + + it('persists crash recovery snapshots and hydrates them after module reload', async () => { + const drafts = await import('./sqlFileTabDrafts'); + drafts.persistQueryTabDraftSnapshot({ + id: 'query-recovery-1', + title: '临时 SQL', + connectionId: 'conn-1', + dbName: 'main', + }, 'select * from large_table;'); + + vi.resetModules(); + const reloaded = await import('./sqlFileTabDrafts'); + expect(reloaded.getQueryTabDraft('query-recovery-1')).toBe('select * from large_table;'); + expect(reloaded.getPersistedQueryTabDraftEntry('query-recovery-1')).toEqual( + expect.objectContaining({ + tabId: 'query-recovery-1', + title: '临时 SQL', + connectionId: 'conn-1', + dbName: 'main', + query: 'select * from large_table;', + }), + ); + }); }); diff --git a/frontend/src/utils/sqlFileTabDrafts.ts b/frontend/src/utils/sqlFileTabDrafts.ts index 36397aed..545514be 100644 --- a/frontend/src/utils/sqlFileTabDrafts.ts +++ b/frontend/src/utils/sqlFileTabDrafts.ts @@ -1,14 +1,214 @@ +import type { TabData } from '../types'; + const drafts = new Map(); +const QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY = 'gonavi-query-tab-drafts-v1'; +const QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT = 30; +const QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH = 1024 * 1024; +const QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS = 160; + +type PersistedQueryTabDraftEntry = { + tabId: string; + title: string; + query: string; + connectionId: string; + dbName: string; + filePath?: string; + savedQueryId?: string; + readOnly?: boolean; + updatedAt: number; +}; + +type QueryTabDraftSnapshotTab = Pick< + TabData, + 'id' | 'title' | 'connectionId' | 'dbName' | 'filePath' | 'savedQueryId' | 'readOnly' +>; + +const persistedDrafts = new Map(); + +let persistedDraftsHydrated = false; +let persistTimer: number | null = null; +let flushListenersBound = false; + const toTabId = (value: unknown): string => String(value ?? '').trim(); +const toTrimmedString = (value: unknown, fallback = ''): string => { + const text = String(value ?? '').trim(); + return text || fallback; +}; + +const getDraftSnapshotStorage = (): Storage | null => { + if (typeof localStorage === 'undefined') { + return null; + } + return localStorage; +}; + +const normalizePersistedDraftEntry = ( + value: unknown, +): PersistedQueryTabDraftEntry | null => { + if (!value || typeof value !== 'object') { + return null; + } + const raw = value as Record; + const tabId = toTabId(raw.tabId); + if (!tabId) { + return null; + } + const query = String(raw.query ?? '').slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH); + const filePath = toTrimmedString(raw.filePath); + const savedQueryId = toTrimmedString(raw.savedQueryId); + if (!query.trim() && !filePath && !savedQueryId) { + return null; + } + const updatedAt = Number(raw.updatedAt); + return { + tabId, + title: toTrimmedString(raw.title, 'SQL Query'), + query, + connectionId: toTrimmedString(raw.connectionId), + dbName: toTrimmedString(raw.dbName), + filePath: filePath || undefined, + savedQueryId: savedQueryId || undefined, + readOnly: raw.readOnly === true, + updatedAt: Number.isFinite(updatedAt) && updatedAt > 0 ? Math.trunc(updatedAt) : Date.now(), + }; +}; + +const ensurePersistedDraftsHydrated = (): void => { + if (persistedDraftsHydrated) { + return; + } + persistedDraftsHydrated = true; + const storage = getDraftSnapshotStorage(); + if (!storage) { + return; + } + try { + const raw = storage.getItem(QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY); + if (!raw) { + return; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return; + } + parsed + .map((entry) => normalizePersistedDraftEntry(entry)) + .filter((entry): entry is PersistedQueryTabDraftEntry => !!entry) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT) + .forEach((entry) => { + persistedDrafts.set(entry.tabId, entry); + drafts.set(entry.tabId, entry.query); + }); + } catch { + // ignore invalid crash-recovery payloads + } +}; + +const flushPersistedDrafts = (): void => { + if (persistTimer !== null && typeof window !== 'undefined') { + window.clearTimeout(persistTimer); + persistTimer = null; + } + const storage = getDraftSnapshotStorage(); + if (!storage) { + return; + } + try { + if (persistedDrafts.size === 0) { + storage.removeItem(QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY); + return; + } + const payload = Array.from(persistedDrafts.values()) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_COUNT); + storage.setItem( + QUERY_TAB_DRAFT_SNAPSHOT_STORAGE_KEY, + JSON.stringify(payload), + ); + } catch { + // ignore storage quota or serialization failures + } +}; + +const bindFlushListeners = (): void => { + if (flushListenersBound || typeof window === 'undefined') { + return; + } + flushListenersBound = true; + const handleFlush = () => { + flushPersistedDrafts(); + }; + window.addEventListener('pagehide', handleFlush, { capture: true }); + window.addEventListener('beforeunload', handleFlush, { capture: true }); + if (typeof document !== 'undefined') { + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + flushPersistedDrafts(); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + } +}; + +const schedulePersistedDraftFlush = (): void => { + bindFlushListeners(); + if (typeof window === 'undefined') { + flushPersistedDrafts(); + return; + } + if (persistTimer !== null) { + window.clearTimeout(persistTimer); + } + persistTimer = window.setTimeout(() => { + flushPersistedDrafts(); + }, QUERY_TAB_DRAFT_SNAPSHOT_DEBOUNCE_MS); +}; + +const upsertPersistedDraftEntry = ( + tab: QueryTabDraftSnapshotTab, + content: string, + overrides?: { connectionId?: string; dbName?: string }, +): void => { + ensurePersistedDraftsHydrated(); + const tabId = toTabId(tab.id); + if (!tabId) { + return; + } + const query = String(content ?? '').slice(0, QUERY_TAB_DRAFT_SNAPSHOT_MAX_TEXT_LENGTH); + const filePath = toTrimmedString(tab.filePath); + const savedQueryId = toTrimmedString(tab.savedQueryId); + const shouldKeep = Boolean(query.trim() || filePath || savedQueryId); + if (!shouldKeep) { + persistedDrafts.delete(tabId); + schedulePersistedDraftFlush(); + return; + } + persistedDrafts.set(tabId, { + tabId, + title: toTrimmedString(tab.title, 'SQL Query'), + query, + connectionId: toTrimmedString(overrides?.connectionId, toTrimmedString(tab.connectionId)), + dbName: toTrimmedString(overrides?.dbName, toTrimmedString(tab.dbName)), + filePath: filePath || undefined, + savedQueryId: savedQueryId || undefined, + readOnly: tab.readOnly === true, + updatedAt: Date.now(), + }); + schedulePersistedDraftFlush(); +}; + export const setQueryTabDraft = (tabId: string, content: string): void => { + ensurePersistedDraftsHydrated(); const id = toTabId(tabId); if (!id) return; drafts.set(id, String(content ?? '')); }; export const getQueryTabDraft = (tabId: string, fallback = ''): string => { + ensurePersistedDraftsHydrated(); const id = toTabId(tabId); if (!id || !drafts.has(id)) { return fallback; @@ -17,16 +217,50 @@ export const getQueryTabDraft = (tabId: string, fallback = ''): string => { }; export const clearQueryTabDraft = (tabId: string): void => { + ensurePersistedDraftsHydrated(); const id = toTabId(tabId); if (!id) return; drafts.delete(id); + if (persistedDrafts.delete(id)) { + schedulePersistedDraftFlush(); + } }; export const hasQueryTabDraft = (tabId: string): boolean => { + ensurePersistedDraftsHydrated(); const id = toTabId(tabId); return Boolean(id && drafts.has(id)); }; +export const persistQueryTabDraftSnapshot = ( + tab: QueryTabDraftSnapshotTab, + content: string, + overrides?: { connectionId?: string; dbName?: string }, +): void => { + const tabId = toTabId(tab.id); + if (!tabId) { + return; + } + setQueryTabDraft(tabId, content); + upsertPersistedDraftEntry(tab, content, overrides); +}; + +export const getPersistedQueryTabDraftEntry = ( + tabId: string, +): PersistedQueryTabDraftEntry | null => { + ensurePersistedDraftsHydrated(); + const id = toTabId(tabId); + if (!id) { + return null; + } + return persistedDrafts.get(id) || null; +}; + +export const listPersistedQueryTabDraftEntries = (): PersistedQueryTabDraftEntry[] => { + ensurePersistedDraftsHydrated(); + return Array.from(persistedDrafts.values()).sort((a, b) => b.updatedAt - a.updatedAt); +}; + export const setSQLFileTabDraft = (tabId: string, content: string): void => { setQueryTabDraft(tabId, content); };