🐛 fix(query-editor): 修复异常退出后SQL草稿丢失

- 为查询页签增加 crash-recovery 草稿快照并按 160ms 落盘
- 启动恢复时补齐缺失的 query tabs 与连接上下文
- 保存或关闭查询后清理临时草稿并补充恢复回归测试
This commit is contained in:
Syngnat
2026-06-29 19:21:37 +08:00
parent dac9e1f571
commit bedd1eceb5
5 changed files with 469 additions and 31 deletions

View File

@@ -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<Record<string, ColumnDefinition[]>>({});
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', {

View File

@@ -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: {

View File

@@ -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<string>();
value.forEach((entry, index) => {
entries.forEach((entry, index) => {
if (!entry || typeof entry !== "object") return;
const raw = entry as Record<string, unknown>;
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<string, unknown>)
@@ -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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
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<AppState>()(
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) => ({

View File

@@ -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<string, string>();
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;',
}),
);
});
});

View File

@@ -1,14 +1,214 @@
import type { TabData } from '../types';
const drafts = new Map<string, string>();
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<string, PersistedQueryTabDraftEntry>();
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<string, unknown>;
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);
};