🐛 fix(query-editor): 修复保存关闭后 SQL 恢复旧内容

- 保存关闭时重新读取当前编辑器中的最新 SQL
- 保存成功后同步标签页快照并清理已落盘草稿
- 保存期间 SQL 再次变化时取消退出并保留新内容
- 补充已保存查询、新建查询及外部 SQL 文件回归测试
This commit is contained in:
Syngnat
2026-08-11 15:54:37 +08:00
parent bd5fde990d
commit 887bfd5acf
3 changed files with 260 additions and 7 deletions

View File

@@ -226,7 +226,7 @@ import {
import {
buildApplicationQuitUnsavedSQLLabel,
collectApplicationQuitUnsavedSQLTargets,
saveApplicationQuitUnsavedSQLTargets,
saveLatestApplicationQuitUnsavedSQLState,
} from './utils/sqlEditorApplicationQuit';
import { prepareApplicationQuitPersistence } from './utils/applicationQuitPersistence';
import { flushQueryTabDraftSnapshots } from './utils/sqlFileTabDrafts';
@@ -2894,7 +2894,19 @@ function App() {
},
onOk: async () => {
try {
await saveApplicationQuitUnsavedSQLTargets(targets, saveQuery);
await saveLatestApplicationQuitUnsavedSQLState({
getState: () => {
const latestState = useStore.getState();
return {
tabs: latestState.tabs,
savedQueries: latestState.savedQueries,
};
},
updateTabs: (update) => {
useStore.setState((state) => ({ tabs: update(state.tabs) }));
},
saveQuery,
});
message.success(t('app.quit.unsaved_sql.saved'));
} catch (error) {
cancelRequest();

View File

@@ -1,8 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SavedQuery, TabData } from '../types';
import { clearQueryTabDraft, setQueryTabDraft, setSQLFileTabDraft } from './sqlFileTabDrafts';
import {
clearQueryTabDraft,
getQueryTabDraft,
hasQueryTabDraft,
setQueryTabDraft,
setSQLFileTabDraft,
} from './sqlFileTabDrafts';
import {
collectApplicationQuitUnsavedSQLTargets,
saveLatestApplicationQuitUnsavedSQLState,
saveApplicationQuitUnsavedSQLTargets,
} from './sqlEditorApplicationQuit';
@@ -184,4 +191,149 @@ describe('sqlEditorApplicationQuit', () => {
expect(savedQuery.id).toBe(tab.id);
expect(targetsAfterRestart).toEqual([]);
});
it('persists the latest saved-query draft into the restored tab before application quit', async () => {
const savedQuery = createSavedQuery({ sql: 'select stale_snapshot;' });
let tabs = [createQueryTab({
id: 'tab-3',
title: savedQuery.name,
query: 'select stale_snapshot;',
savedQueryId: savedQuery.id,
})];
const savedQueries: SavedQuery[] = [savedQuery];
setQueryTabDraft('tab-3', 'select prompt_snapshot;');
const promptTargets = await collectApplicationQuitUnsavedSQLTargets(
tabs,
savedQueries,
vi.fn(),
);
expect(promptTargets[0]).toMatchObject({ draft: 'select prompt_snapshot;' });
setQueryTabDraft('tab-3', 'select latest_before_click;');
const saveQuery = vi.fn(async (query: SavedQuery) => query);
await saveLatestApplicationQuitUnsavedSQLState({
getState: () => ({ tabs, savedQueries }),
updateTabs: (update) => {
tabs = update(tabs);
},
saveQuery,
readSQLFile: vi.fn(),
});
expect(saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'saved-1',
sql: 'select latest_before_click;',
}));
expect(tabs[0]).toMatchObject({
id: 'tab-3',
query: 'select latest_before_click;',
savedQueryId: 'saved-1',
});
expect(hasQueryTabDraft('tab-3')).toBe(false);
});
it('turns the latest unnamed query draft into the saved query restored after application quit', async () => {
let tabs = [createQueryTab({
id: 'tab-3',
title: 'New query',
query: 'select stale_snapshot;',
})];
setQueryTabDraft('tab-3', 'select latest_before_click;');
const saveQuery = vi.fn(async (query: SavedQuery) => query);
await saveLatestApplicationQuitUnsavedSQLState({
getState: () => ({ tabs, savedQueries: [] }),
updateTabs: (update) => {
tabs = update(tabs);
},
saveQuery,
readSQLFile: vi.fn(),
});
expect(saveQuery).toHaveBeenCalledWith(expect.objectContaining({
id: 'tab-3',
name: 'New query',
sql: 'select latest_before_click;',
}));
expect(tabs[0]).toMatchObject({
id: 'tab-3',
query: 'select latest_before_click;',
savedQueryId: 'tab-3',
});
expect(hasQueryTabDraft('tab-3')).toBe(false);
});
it('persists the latest external SQL file draft into the restored tab after writing it', async () => {
let tabs = [createQueryTab({
id: 'tab-3',
title: 'report.sql',
filePath: '/tmp/report.sql',
query: 'select stale_snapshot;',
})];
setSQLFileTabDraft('tab-3', 'select latest_before_click;');
const saveQuery = vi.fn(async (query: SavedQuery) => query);
const readSQLFile = vi.fn(async () => ({
success: true,
data: { content: 'select stale_snapshot;' },
}));
const writeSQLFile = vi.fn(async () => ({ success: true }));
await saveLatestApplicationQuitUnsavedSQLState({
getState: () => ({ tabs, savedQueries: [] }),
updateTabs: (update) => {
tabs = update(tabs);
},
saveQuery,
readSQLFile,
writeSQLFile,
});
expect(writeSQLFile).toHaveBeenCalledWith(
'/tmp/report.sql',
'select latest_before_click;',
);
expect(saveQuery).not.toHaveBeenCalled();
expect(tabs[0]?.query).toBe('select latest_before_click;');
expect(hasQueryTabDraft('tab-3')).toBe(false);
});
it('keeps the newer draft and cancels quit when SQL changes while saving', async () => {
const savedQuery = createSavedQuery({ sql: 'select stale_snapshot;' });
let tabs = [createQueryTab({
id: 'tab-3',
title: savedQuery.name,
query: 'select stale_snapshot;',
savedQueryId: savedQuery.id,
})];
setQueryTabDraft('tab-3', 'select before_save;');
let releaseSave!: () => void;
let markSaveStarted!: () => void;
const saveStarted = new Promise<void>((resolve) => {
markSaveStarted = resolve;
});
const saveQuery = vi.fn((query: SavedQuery) => new Promise<SavedQuery>((resolve) => {
releaseSave = () => resolve(query);
markSaveStarted();
}));
const savePromise = saveLatestApplicationQuitUnsavedSQLState({
getState: () => ({ tabs, savedQueries: [savedQuery] }),
updateTabs: (update) => {
tabs = update(tabs);
},
saveQuery,
readSQLFile: vi.fn(),
});
await saveStarted;
setQueryTabDraft('tab-3', 'select changed_during_save;');
releaseSave();
await expect(savePromise).rejects.toThrow('SQL changed while saving: Saved query');
expect(tabs[0]?.query).toBe('select stale_snapshot;');
expect(getQueryTabDraft('tab-3')).toBe('select changed_during_save;');
expect(hasQueryTabDraft('tab-3')).toBe(true);
});
});

View File

@@ -7,7 +7,11 @@ import {
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from './sqlFileTabDirty';
import { getQueryTabDraft, getSQLFileTabDraft } from './sqlFileTabDrafts';
import {
clearQueryTabDraft,
getQueryTabDraft,
getSQLFileTabDraft,
} from './sqlFileTabDrafts';
type QueryResultLike = {
success?: boolean;
@@ -45,6 +49,24 @@ export type ReadSQLFileForQuit = (filePath: string) => Promise<QueryResultLike>;
export type WriteSQLFileForQuit = (filePath: string, content: string) => Promise<QueryResultLike>;
export type SaveQueryForQuit = (query: SavedQuery) => Promise<SavedQuery>;
export type ApplicationQuitSavedSQLTarget = {
target: ApplicationQuitUnsavedSQLTarget;
savedQuery?: SavedQuery;
};
type ApplicationQuitSQLStateSnapshot = {
tabs: TabData[];
savedQueries: SavedQuery[];
};
type SaveLatestApplicationQuitUnsavedSQLStateArgs = {
getState: () => ApplicationQuitSQLStateSnapshot;
updateTabs: (update: (tabs: TabData[]) => TabData[]) => void;
saveQuery: SaveQueryForQuit;
readSQLFile?: ReadSQLFileForQuit;
writeSQLFile?: WriteSQLFileForQuit;
};
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const resolveTabTitle = (tab: TabData, fallback: string): string =>
@@ -146,27 +168,30 @@ export const saveApplicationQuitUnsavedSQLTargets = async (
targets: ApplicationQuitUnsavedSQLTarget[],
saveQuery: SaveQueryForQuit,
writeSQLFile: WriteSQLFileForQuit = WriteSQLFile,
): Promise<void> => {
): Promise<ApplicationQuitSavedSQLTarget[]> => {
const savedTargets: ApplicationQuitSavedSQLTarget[] = [];
for (const target of targets) {
if (target.kind === 'sql-file') {
const res = await writeSQLFile(target.filePath, target.draft);
if (!res?.success) {
throw new Error(res?.message || target.filePath);
}
savedTargets.push({ target });
continue;
}
if (target.kind === 'saved-query') {
await saveQuery({
const savedQuery = await saveQuery({
...target.savedQuery,
sql: target.draft,
connectionId: target.connectionId,
dbName: target.dbName,
});
savedTargets.push({ target, savedQuery });
continue;
}
await saveQuery({
const savedQuery = await saveQuery({
// Keep the tab identity so a restored tab resolves this saved query on
// later exits instead of creating a new history copy every time.
id: target.tabId,
@@ -176,5 +201,69 @@ export const saveApplicationQuitUnsavedSQLTargets = async (
dbName: target.dbName,
createdAt: Date.now(),
});
savedTargets.push({ target, savedQuery });
}
return savedTargets;
};
export const reconcileApplicationQuitSavedSQLTargets = (
tabs: TabData[],
savedTargets: ApplicationQuitSavedSQLTarget[],
): TabData[] => {
const savedByTabId = new Map(
savedTargets.map((savedTarget) => [savedTarget.target.tabId, savedTarget]),
);
return tabs.map((tab) => {
const savedTarget = savedByTabId.get(tab.id);
if (!savedTarget) return tab;
if (savedTarget.target.kind === 'sql-file') {
return {
...tab,
query: savedTarget.target.draft,
};
}
const savedQuery = savedTarget.savedQuery;
if (!savedQuery) return tab;
return {
...tab,
title: savedQuery.name,
query: savedQuery.sql,
connectionId: savedQuery.connectionId,
dbName: savedQuery.dbName,
savedQueryId: savedQuery.id,
};
});
};
export const saveLatestApplicationQuitUnsavedSQLState = async ({
getState,
updateTabs,
saveQuery,
readSQLFile = ReadSQLFile,
writeSQLFile = WriteSQLFile,
}: SaveLatestApplicationQuitUnsavedSQLStateArgs): Promise<ApplicationQuitSavedSQLTarget[]> => {
const latestState = getState();
const targets = await collectApplicationQuitUnsavedSQLTargets(
latestState.tabs,
latestState.savedQueries,
readSQLFile,
);
const savedTargets = await saveApplicationQuitUnsavedSQLTargets(
targets,
saveQuery,
writeSQLFile,
);
const changedWhileSaving = savedTargets.find(({ target }) => (
getQueryTabDraft(target.tabId, target.draft) !== target.draft
));
if (changedWhileSaving) {
throw new Error(`SQL changed while saving: ${changedWhileSaving.target.title}`);
}
updateTabs((tabs) => reconcileApplicationQuitSavedSQLTargets(tabs, savedTargets));
savedTargets.forEach(({ target }) => clearQueryTabDraft(target.tabId));
return savedTargets;
};