mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-09 22:42:55 +08:00
🐛 fix(query-editor): 修复异常退出后SQL草稿丢失
- 为查询页签增加 crash-recovery 草稿快照并按 160ms 落盘 - 启动恢复时补齐缺失的 query tabs 与连接上下文 - 保存或关闭查询后清理临时草稿并补充恢复回归测试
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user