diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4126184d..a26ddeda 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(); diff --git a/frontend/src/utils/sqlEditorApplicationQuit.test.ts b/frontend/src/utils/sqlEditorApplicationQuit.test.ts index c342a0c7..72bfbb4e 100644 --- a/frontend/src/utils/sqlEditorApplicationQuit.test.ts +++ b/frontend/src/utils/sqlEditorApplicationQuit.test.ts @@ -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((resolve) => { + markSaveStarted = resolve; + }); + const saveQuery = vi.fn((query: SavedQuery) => new Promise((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); + }); }); diff --git a/frontend/src/utils/sqlEditorApplicationQuit.ts b/frontend/src/utils/sqlEditorApplicationQuit.ts index 244b8890..95fffb08 100644 --- a/frontend/src/utils/sqlEditorApplicationQuit.ts +++ b/frontend/src/utils/sqlEditorApplicationQuit.ts @@ -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; export type WriteSQLFileForQuit = (filePath: string, content: string) => Promise; export type SaveQueryForQuit = (query: SavedQuery) => Promise; +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 => { +): Promise => { + 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 => { + 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; };