diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5a59e457f9..73ded6da14 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -40,6 +40,7 @@ import { useWebDavAutoUpload } from "@/composables/useWebDavAutoUpload"; import { useScheduledDatabaseBackups } from "@/composables/useScheduledDatabaseBackups"; import { shouldDrawDesktopWindowFrame } from "@/composables/useWindowControls"; import { createOpenTabsRestorationBarrier, initializeDesktopOpenTabs, initializeOpenTabs, type OpenTabsRestorationBarrier } from "@/lib/app/openTabsStartup"; +import { finishAppCloseWithRequiredPersist } from "@/lib/app/appClosePersistence"; import { useSaveSqlFolderSelection } from "@/composables/useSaveSqlFolderSelection"; import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -1069,7 +1070,23 @@ async function finishPendingAppClose(action: AppCloseAction) { } pendingAppCloseAction.value = null; pendingSaveShouldCloseTab.value = true; - if (action === "quit") await disposeAllSqlServerActivityTraces().catch(() => undefined); + const disposeRuntimeBeforeClose = () => (action === "quit" ? disposeAllSqlServerActivityTraces().catch(() => undefined) : Promise.resolve()); + if (queryStore.requiresAppCloseDraftPersist) { + await finishAppCloseWithRequiredPersist({ + persist: () => queryStore.flushPendingPersist(), + beforeClose: disposeRuntimeBeforeClose, + close: () => performCloseAction(action), + onPersistError: (error) => + toast( + t("settings.appCloseDraftPersistFailed", { + message: error instanceof Error ? error.message : String(error), + }), + 8000, + ), + }); + return; + } + await disposeRuntimeBeforeClose(); await queryStore.flushPendingPersist().catch(() => undefined); await performCloseAction(action); } diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index dda4ac2071..6ebbd0c062 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -40,6 +40,7 @@ import { type DataTabReuseMode, type DataGridFilterEditorView, type OpenTabsRestoreMode, + type AppCloseUnsavedTabsMode, type SidebarObjectInfoMode, type SqlSemanticDiagnosticsMode, type SavedSqlOpenTargetMode, @@ -335,6 +336,7 @@ const editSqlSemanticDiagnosticsEnabled = ref(settingsStore.editorSettings.sqlSe const editConfirmDangerousSqlExecution = ref(settingsStore.editorSettings.confirmDangerousSqlExecution); const editContinueOnErrorOnBatch = ref(settingsStore.editorSettings.continueOnErrorOnBatch); const editConfirmUnsavedSqlClose = ref(settingsStore.editorSettings.confirmUnsavedSqlClose); +const editAppCloseUnsavedTabsMode = ref(settingsStore.editorSettings.appCloseUnsavedTabsMode); const editSavedSqlOpenTargetMode = ref(settingsStore.editorSettings.savedSqlOpenTargetMode); const editAppLayout = ref(settingsStore.editorSettings.appLayout); const editTabLayout = ref(settingsStore.editorSettings.tabLayout); @@ -533,6 +535,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { confirmDangerousSqlExecution: editConfirmDangerousSqlExecution.value, continueOnErrorOnBatch: editContinueOnErrorOnBatch.value, confirmUnsavedSqlClose: editConfirmUnsavedSqlClose.value, + appCloseUnsavedTabsMode: editAppCloseUnsavedTabsMode.value, savedSqlOpenTargetMode: editSavedSqlOpenTargetMode.value, appLayout: editAppLayout.value, tabLayout: editTabLayout.value, @@ -818,6 +821,7 @@ function syncEditorSettingsDraftFromStore() { editConfirmDangerousSqlExecution.value = settingsStore.editorSettings.confirmDangerousSqlExecution; editContinueOnErrorOnBatch.value = settingsStore.editorSettings.continueOnErrorOnBatch; editConfirmUnsavedSqlClose.value = settingsStore.editorSettings.confirmUnsavedSqlClose; + editAppCloseUnsavedTabsMode.value = settingsStore.editorSettings.appCloseUnsavedTabsMode; editSavedSqlOpenTargetMode.value = settingsStore.editorSettings.savedSqlOpenTargetMode; editAppLayout.value = settingsStore.editorSettings.appLayout; editTabLayout.value = settingsStore.editorSettings.tabLayout; @@ -1067,6 +1071,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution; editContinueOnErrorOnBatch.value = DEFAULT_EDITOR_SETTINGS.continueOnErrorOnBatch; editConfirmUnsavedSqlClose.value = DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose; + editAppCloseUnsavedTabsMode.value = DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode; editSavedSqlOpenTargetMode.value = DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode; editClickTableNavigationTarget.value = DEFAULT_EDITOR_SETTINGS.clickTableNavigationTarget; editSqlVariableSubstitutionEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlVariableSubstitutionEnabled; @@ -1175,6 +1180,7 @@ function resetAllDefaults() { editSqlSemanticDiagnosticsEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsEnabled; editConfirmDangerousSqlExecution.value = DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution; editConfirmUnsavedSqlClose.value = DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose; + editAppCloseUnsavedTabsMode.value = DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode; editSavedSqlOpenTargetMode.value = DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode; editSqlVariableSubstitutionEnabled.value = DEFAULT_EDITOR_SETTINGS.sqlVariableSubstitutionEnabled; editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(DEFAULT_EDITOR_SETTINGS.sqlVariableSyntaxOverrides); @@ -4048,6 +4054,27 @@ onUnmounted(() => { +
+
+ + + {{ t("settings.appCloseUnsavedTabsModeDescription") }} + +
+ +

+ {{ t("settings.appCloseUnsavedTabsModeHint") }} +

+
+
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 88ed340ee5..6d53e3bc5d 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -6304,6 +6304,12 @@ export default { continueOnErrorOnBatchDescription: "When enabled, multi-statement execution continues after an error instead of stopping at the first failure.", confirmUnsavedSqlClose: "Confirm before closing unsaved SQL", confirmUnsavedSqlCloseDescription: "When disabled, SQL tabs with unsaved edits close or quit without the save confirmation dialog.", + appCloseUnsavedTabsMode: "Unsaved SQL on quit", + appCloseUnsavedTabsModeDescription: "Choose what happens when quitting DBX while SQL tabs have unsaved edits.", + appCloseUnsavedTabsModePrompt: "Ask whether to save", + appCloseUnsavedTabsModeKeepDrafts: "Quit and keep drafts", + appCloseUnsavedTabsModeHint: 'Drafts are kept as unsaved tabs and restored the next time you start DBX. This requires "Restore tabs on launch" to not be set to "Do not keep tabs".', + appCloseDraftPersistFailed: "DBX could not save the open tab drafts, so quitting was cancelled. Check disk space and file permissions, then try again. Details: {message}", savedSqlOpenTarget: "Default target for saved SQL", savedSqlOpenTargetSaved: "Saved target", savedSqlOpenTargetSavedDescription: "Use the connection, database, and schema stored with the SQL file.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index a0119d56fa..76b97ea8c9 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -5993,6 +5993,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "Cuando está habilitado, la ejecución de múltiples sentencias continúa después de un error en lugar de detenerse en el primer fallo.", confirmUnsavedSqlClose: "Confirmar antes de cerrar SQL sin guardar", confirmUnsavedSqlCloseDescription: "Cuando se desactiva, las pestañas SQL con ediciones sin guardar se cierran o salen sin el diálogo de confirmación de guardado.", + appCloseUnsavedTabsMode: "SQL sin guardar al salir", + appCloseUnsavedTabsModeDescription: "Elige qué ocurre al salir de DBX con pestañas SQL que tienen ediciones sin guardar.", + appCloseUnsavedTabsModePrompt: "Preguntar si guardar", + appCloseUnsavedTabsModeKeepDrafts: "Salir y conservar borradores", + appCloseUnsavedTabsModeHint: 'Los borradores se conservan como pestañas sin guardar y se restauran la próxima vez que inicies DBX. Requiere que "Restaurar pestañas al iniciar" no esté en "No conservar pestañas".', + appCloseDraftPersistFailed: "DBX no pudo guardar los borradores de las pestañas abiertas, por lo que se canceló la salida. Comprueba el espacio en disco y los permisos de los archivos, y vuelve a intentarlo. Detalles: {message}", savedSqlOpenTarget: "Destino predeterminado del SQL guardado", savedSqlOpenTargetSaved: "Destino guardado", savedSqlOpenTargetSavedDescription: "Usar la conexión, la base de datos y el esquema guardados con el archivo SQL.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a48b64148b..889e51fdeb 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -5993,6 +5993,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "Se abilitato, l'esecuzione di più istruzioni continua dopo un errore invece di interrompersi al primo fallimento.", confirmUnsavedSqlClose: "Conferma prima di chiudere SQL non salvato", confirmUnsavedSqlCloseDescription: "Se disattivato, le schede SQL con modifiche non salvate verranno chiuse o usciranno senza la finestra di conferma di salvataggio.", + appCloseUnsavedTabsMode: "SQL non salvato all'uscita", + appCloseUnsavedTabsModeDescription: "Scegli cosa succede quando esci da DBX con schede SQL che contengono modifiche non salvate.", + appCloseUnsavedTabsModePrompt: "Chiedi se salvare", + appCloseUnsavedTabsModeKeepDrafts: "Esci e conserva le bozze", + appCloseUnsavedTabsModeHint: 'Le bozze vengono conservate come schede non salvate e ripristinate al prossimo avvio di DBX. Richiede che "Ripristina schede all\'avvio" non sia impostato su "Non mantenere schede".', + appCloseDraftPersistFailed: "DBX non ha potuto salvare le bozze delle schede aperte, quindi l'uscita è stata annullata. Controlla lo spazio su disco e i permessi dei file, quindi riprova. Dettagli: {message}", savedSqlOpenTarget: "Destinazione predefinita per SQL salvato", savedSqlOpenTargetSaved: "Destinazione salvata", savedSqlOpenTargetSavedDescription: "Usa la connessione, il database e lo schema salvati con il file SQL.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 55445caa7a..c409fde3e1 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -6015,6 +6015,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "有効にすると、複数SQL文の実行時にエラーが発生しても、最初の失敗で停止せずに後続の文を実行し続けます。", confirmUnsavedSqlClose: "未保存のSQLを閉じる前に確認", confirmUnsavedSqlCloseDescription: "無効時、未保存の編集があるSQLタブは保存確認ダイアログなしで閉じるか終了します。", + appCloseUnsavedTabsMode: "終了時の未保存SQL", + appCloseUnsavedTabsModeDescription: "未保存の編集があるSQLタブが開いたままDBXを終了するときの動作を選択します。", + appCloseUnsavedTabsModePrompt: "保存するかどうか確認する", + appCloseUnsavedTabsModeKeepDrafts: "確認せず終了して下書きを保持", + appCloseUnsavedTabsModeHint: "下書きは未保存のタブとして保持され、次回DBXを起動したときに復元されます。「起動時にタブを復元」が「タブを保持しない」になっていない必要があります。", + appCloseDraftPersistFailed: "開いているタブの下書きを保存できなかったため、DBX の終了を中止しました。ディスク容量とファイル権限を確認してから、もう一度終了してください。詳細: {message}", savedSqlOpenTarget: "保存済みSQLのデフォルト実行先", savedSqlOpenTargetSaved: "保存時の実行先", savedSqlOpenTargetSavedDescription: "SQLファイルに保存された接続、データベース、スキーマを使用します。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 5967a723a0..0b1bf1c1ca 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -5723,6 +5723,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "활성화하면 다중 구문 실행이 첫 번째 실패에서 중지하는 대신 오류 후에도 계속됩니다.", confirmUnsavedSqlClose: "저장되지 않은 SQL 닫기 전 확인", confirmUnsavedSqlCloseDescription: "비활성화하면 저장되지 않은 편집이 있는 SQL 탭이 저장 확인 대화상자 없이 닫히거나 종료됩니다.", + appCloseUnsavedTabsMode: "종료 시 저장되지 않은 SQL", + appCloseUnsavedTabsModeDescription: "저장되지 않은 편집이 있는 SQL 탭을 열어 둔 채 DBX를 종료할 때 동작을 선택합니다.", + appCloseUnsavedTabsModePrompt: "저장 여부 묻기", + appCloseUnsavedTabsModeKeepDrafts: "묻지 않고 종료 후 초안 유지", + appCloseUnsavedTabsModeHint: '초안은 저장되지 않은 탭으로 유지되며 다음에 DBX를 시작할 때 복원됩니다. "실행 시 탭 복원"이 "탭 유지 안 함"으로 설정되어 있지 않아야 합니다.', + appCloseDraftPersistFailed: "열린 탭의 초안을 저장하지 못해 DBX 종료를 취소했습니다. 디스크 공간과 파일 권한을 확인한 후 다시 종료하세요. 세부 정보: {message}", savedSqlOpenTarget: "저장된 SQL의 기본 실행 대상", savedSqlOpenTargetSaved: "저장된 대상", savedSqlOpenTargetSavedDescription: "SQL 파일에 저장된 연결, 데이터베이스 및 스키마를 사용합니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 548116a2b1..bad62220e6 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -5995,6 +5995,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "Quando ativado, a execução de múltiplas instruções continua após um erro em vez de parar na primeira falha.", confirmUnsavedSqlClose: "Confirmar antes de fechar SQL não salvo", confirmUnsavedSqlCloseDescription: "Quando desativado, abas SQL com edições não salvas são fechadas ou o app é encerrado sem a caixa de diálogo de confirmação de salvamento.", + appCloseUnsavedTabsMode: "SQL não salvo ao sair", + appCloseUnsavedTabsModeDescription: "Escolha o que acontece ao sair do DBX com abas SQL contendo edições não salvas.", + appCloseUnsavedTabsModePrompt: "Perguntar se deseja salvar", + appCloseUnsavedTabsModeKeepDrafts: "Sair e manter rascunhos", + appCloseUnsavedTabsModeHint: 'Os rascunhos são mantidos como abas não salvas e restaurados na próxima vez que você iniciar o DBX. Requer que "Restaurar abas ao iniciar" não esteja definido como "Não manter abas".', + appCloseDraftPersistFailed: "O DBX não conseguiu salvar os rascunhos das abas abertas, então a saída foi cancelada. Verifique o espaço em disco e as permissões dos arquivos e tente novamente. Detalhes: {message}", savedSqlOpenTarget: "Destino padrão do SQL salvo", savedSqlOpenTargetSaved: "Destino salvo", savedSqlOpenTargetSavedDescription: "Usar a conexão, o banco de dados e o esquema salvos com o arquivo SQL.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 07e436b227..38fbaf0dc3 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -6289,6 +6289,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "开启后,多条 SQL 批量执行时遇到错误将继续执行后续语句,而非中断。", confirmUnsavedSqlClose: "关闭未保存 SQL 前弹出确认", confirmUnsavedSqlCloseDescription: "关闭后,有未保存内容的 SQL 标签页会直接关闭或退出,不再弹出保存确认。", + appCloseUnsavedTabsMode: "退出时的未保存 SQL", + appCloseUnsavedTabsModeDescription: "选择退出 DBX 时如何处理有未保存内容的 SQL 标签页。", + appCloseUnsavedTabsModePrompt: "退出前询问是否保存", + appCloseUnsavedTabsModeKeepDrafts: "直接退出并保留草稿", + appCloseUnsavedTabsModeHint: "草稿会以未保存标签页的形式保留,下次启动 DBX 时恢复;需要“启动时恢复标签页”不是“不保留标签”。", + appCloseDraftPersistFailed: "DBX 无法保存打开的标签页草稿,因此已取消退出。请检查磁盘空间和文件权限后重试。详情:{message}", savedSqlOpenTarget: "打开已保存 SQL 时的默认目标", savedSqlOpenTargetSaved: "保存时的目标", savedSqlOpenTargetSavedDescription: "使用 SQL 文件保存时记录的连接、数据库和 Schema。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index ea6bab4f96..82b813bbad 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -5314,6 +5314,12 @@ export default withEnglishFallback({ continueOnErrorOnBatchDescription: "開啟後,多條 SQL 批次執行時遇到錯誤將繼續執行後續語句,而非中斷。", confirmUnsavedSqlClose: "關閉未儲存 SQL 前彈出確認", confirmUnsavedSqlCloseDescription: "關閉後,有未儲存內容的 SQL 分頁會直接關閉或結束,不再彈出儲存確認。", + appCloseUnsavedTabsMode: "結束時的未儲存 SQL", + appCloseUnsavedTabsModeDescription: "選擇結束 DBX 時如何處理有未儲存內容的 SQL 分頁。", + appCloseUnsavedTabsModePrompt: "結束前詢問是否儲存", + appCloseUnsavedTabsModeKeepDrafts: "直接結束並保留草稿", + appCloseUnsavedTabsModeHint: "草稿會以未儲存分頁的形式保留,下次啟動 DBX 時還原;需要「啟動時還原分頁」不是「不保留分頁」。", + appCloseDraftPersistFailed: "DBX 無法儲存開啟的分頁草稿,因此已取消退出。請檢查磁碟空間和檔案權限後重試。詳細資料:{message}", savedSqlOpenTarget: "開啟已儲存 SQL 時的預設目標", savedSqlOpenTargetSaved: "儲存時的目標", savedSqlOpenTargetSavedDescription: "使用 SQL 檔案儲存時記錄的連線、資料庫和 Schema。", diff --git a/apps/desktop/src/lib/app/appClosePersistence.ts b/apps/desktop/src/lib/app/appClosePersistence.ts new file mode 100644 index 0000000000..91b05875c0 --- /dev/null +++ b/apps/desktop/src/lib/app/appClosePersistence.ts @@ -0,0 +1,19 @@ +interface FinishAppCloseWithRequiredPersistOptions { + persist: () => Promise; + beforeClose?: () => Promise; + close: () => Promise; + onPersistError: (error: unknown) => void; +} + +export async function finishAppCloseWithRequiredPersist(options: FinishAppCloseWithRequiredPersistOptions): Promise { + try { + await options.persist(); + } catch (error) { + options.onPersistError(error); + return false; + } + + await options.beforeClose?.(); + await options.close(); + return true; +} diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index 11492f7eec..6b5df737f9 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -27,6 +27,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "sqlSemanticDiagnosticsMode", "confirmDangerousSqlExecution", "confirmUnsavedSqlClose", + "appCloseUnsavedTabsMode", "savedSqlOpenTargetMode", "appLayout", "tabLayout", diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 66fb7d8d4e..69cddd8e36 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -118,6 +118,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] = { id: "editor-completion-trigger-mode", category: "editor", titleKey: "settings.completionTriggerMode", descriptionKey: "settings.completionTriggerModeDescription", targetId: "editor" }, { id: "editor-auto-alias", category: "editor", titleKey: "settings.autoAliasTables", descriptionKey: "settings.autoAliasTablesDescription", targetId: "editor" }, { id: "editor-unsaved-close", category: "editor", titleKey: "settings.confirmUnsavedSqlClose", descriptionKey: "settings.confirmUnsavedSqlCloseDescription", targetId: "editor" }, + { id: "editor-app-close-unsaved-tabs", category: "editor", titleKey: "settings.appCloseUnsavedTabsMode", descriptionKey: "settings.appCloseUnsavedTabsModeDescription", targetId: "editor" }, { id: "editor-prefill-query", category: "editor", titleKey: "settings.prefillNewQueryWithSelect", descriptionKey: "settings.prefillNewQueryWithSelectDescription", targetId: "editor" }, { id: "editor-diagnostics", category: "editor", titleKey: "settings.sqlSemanticDiagnosticsEnabled", descriptionKey: "settings.sqlSemanticDiagnosticsEnabledDescription", targetId: "editor" }, { id: "editor-sql-variables", category: "editor", titleKey: "settings.sqlVariableSyntax", descriptionKey: "settings.sqlVariableSyntaxDescription", targetId: "editor" }, diff --git a/apps/desktop/src/stores/__tests__/queryStore.appCloseDrafts.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.appCloseDrafts.spec.ts new file mode 100644 index 0000000000..663ebdf480 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.appCloseDrafts.spec.ts @@ -0,0 +1,174 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { finishAppCloseWithRequiredPersist } from "@/lib/app/appClosePersistence"; + +const mocks = vi.hoisted(() => ({ + saveOpenTabsState: vi.fn(), +})); + +vi.mock("@/lib/backend/api", async (importOriginal) => ({ + ...(await importOriginal()), + saveOpenTabsState: mocks.saveOpenTabsState, +})); + +describe("queryStore app close unsaved drafts", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + mocks.saveOpenTabsState.mockReset(); + mocks.saveOpenTabsState.mockResolvedValue(undefined); + vi.stubGlobal("localStorage", { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + }); + setActivePinia(createPinia()); + }); + + async function createStoreWithDirtyQueryTab() { + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + const tabId = queryStore.createTab("conn-1", "db"); + queryStore.updateSql(tabId, "select 1;"); + return { queryStore, tabId }; + } + + async function createDirtyStructureTab(queryStore: Awaited>["queryStore"]) { + const tabId = queryStore.openTableStructure("conn-1", "db", undefined, "users"); + const tab = queryStore.tabs.find((item) => item.id === tabId)!; + tab.structureDraft = { dirty: true } as typeof tab.structureDraft; + return tabId; + } + + it("prompts for unsaved SQL when quitting by default", async () => { + const { queryStore } = await createStoreWithDirtyQueryTab(); + + const confirmed = queryStore.requestAppCloseConfirmation(); + + expect(confirmed).toBe(true); + expect(queryStore.showCloseConfirm).toBe(true); + expect(queryStore.closeConfirmContext).toBe("app"); + }); + + it("skips the quit prompt and keeps unsaved drafts in keep-drafts mode", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + + const { queryStore } = await createStoreWithDirtyQueryTab(); + + const confirmed = queryStore.requestAppCloseConfirmation(); + + expect(confirmed).toBe(false); + expect(queryStore.showCloseConfirm).toBe(false); + expect(queryStore.tabs[0].sql).toBe("select 1;"); + expect(queryStore.isTabDirty(queryStore.tabs[0])).toBe(true); + expect(queryStore.requiresAppCloseDraftPersist).toBe(true); + }); + + it("does not require draft persistence when unsaved SQL confirmation is disabled", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ + confirmUnsavedSqlClose: false, + appCloseUnsavedTabsMode: "keep-drafts", + }); + const { queryStore } = await createStoreWithDirtyQueryTab(); + + expect(queryStore.requestAppCloseConfirmation()).toBe(false); + expect(queryStore.requiresAppCloseDraftPersist).toBe(false); + }); + + it("does not require draft persistence without dirty SQL", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + queryStore.createTab("conn-1", "db"); + + expect(queryStore.requiresAppCloseDraftPersist).toBe(false); + }); + + it("still confirms individual dirty tab closes in keep-drafts mode", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + + const { queryStore, tabId } = await createStoreWithDirtyQueryTab(); + + queryStore.closeTab(tabId); + + expect(queryStore.showCloseConfirm).toBe(true); + expect(queryStore.closeConfirmContext).toBe("tab"); + expect(queryStore.tabs[0].sql).toBe("select 1;"); + }); + + it("still protects a dirty structure tab during app close in keep-drafts mode", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + const structureTabId = await createDirtyStructureTab(queryStore); + + expect(queryStore.requestAppCloseConfirmation()).toBe(true); + expect(queryStore.closeConfirmContext).toBe("app"); + expect(queryStore.closeConfirmDirtyTabIds).toEqual([structureTabId]); + }); + + it("keeps dirty SQL drafts while protecting structure edits during app close", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + const { queryStore, tabId: queryTabId } = await createStoreWithDirtyQueryTab(); + const structureTabId = await createDirtyStructureTab(queryStore); + + expect(queryStore.requestAppCloseConfirmation()).toBe(true); + expect(queryStore.closeConfirmDirtyTabIds).toEqual([structureTabId]); + + queryStore.forceCloseAllPendingTabs(); + + const queryTab = queryStore.tabs.find((tab) => tab.id === queryTabId)!; + expect(queryTab.sql).toBe("select 1;"); + expect(queryStore.isTabDirty(queryTab)).toBe(true); + expect(queryStore.requestAppCloseConfirmation()).toBe(false); + }); + + it("blocks app close when saving keep-drafts state fails", async () => { + const { useSettingsStore } = await import("@/stores/settingsStore"); + useSettingsStore().updateEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }); + const { queryStore } = await createStoreWithDirtyQueryTab(); + const beforeClose = vi.fn().mockResolvedValue(undefined); + const close = vi.fn().mockResolvedValue(undefined); + const onPersistError = vi.fn(); + mocks.saveOpenTabsState.mockRejectedValueOnce(new Error("disk full")); + + const closed = await finishAppCloseWithRequiredPersist({ + persist: () => queryStore.flushPendingPersist(), + beforeClose, + close, + onPersistError, + }); + + expect(closed).toBe(false); + expect(mocks.saveOpenTabsState).toHaveBeenCalledOnce(); + expect(beforeClose).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + expect(onPersistError).toHaveBeenCalledWith(expect.objectContaining({ message: "disk full" })); + }); + + it("disposes runtime only after draft state is persisted", async () => { + const calls: string[] = []; + + const closed = await finishAppCloseWithRequiredPersist({ + persist: async () => { + calls.push("persist"); + }, + beforeClose: async () => { + calls.push("dispose"); + }, + close: async () => { + calls.push("close"); + }, + onPersistError: vi.fn(), + }); + + expect(closed).toBe(true); + expect(calls).toEqual(["persist", "dispose", "close"]); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index c162be6394..f2d0394b97 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -188,6 +188,12 @@ describe("normalizeEditorSettings", () => { expect(normalizeEditorSettings({ restoreOpenTabsOnLaunch: true } as any).openTabsRestoreMode).toBe("all"); }); + it("prompts for unsaved SQL on quit by default and preserves explicit modes", () => { + expect(normalizeEditorSettings({}).appCloseUnsavedTabsMode).toBe("prompt"); + expect(normalizeEditorSettings({ appCloseUnsavedTabsMode: "keep-drafts" }).appCloseUnsavedTabsMode).toBe("keep-drafts"); + expect(normalizeEditorSettings({ appCloseUnsavedTabsMode: "invalid" as any }).appCloseUnsavedTabsMode).toBe("prompt"); + }); + it("preserves CNB, migrates AtomGit to CNB, and rejects invalid values", () => { expect(normalizeEditorSettings({ updateDownloadSource: "cnb" }).updateDownloadSource).toBe("cnb"); expect(normalizeEditorSettings({ updateDownloadSource: "atomgit" as any }).updateDownloadSource).toBe("cnb"); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 466c9b5bc3..a246dbd653 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -2300,9 +2300,16 @@ export const useQueryStore = defineStore("query", () => { const hasDirtyTabs = computed(() => tabs.value.some((tab) => isTabDirty(tab))); const shouldConfirmUnsavedSqlClose = computed(() => useSettingsStore().editorSettings.confirmUnsavedSqlClose); + const keepUnsavedTabsDraftsOnAppClose = computed(() => useSettingsStore().editorSettings.appCloseUnsavedTabsMode === "keep-drafts"); + const requiresAppCloseDraftPersist = computed(() => shouldConfirmUnsavedSqlClose.value && keepUnsavedTabsDraftsOnAppClose.value && tabs.value.some((tab) => tab.mode === "query" && isTabDirty(tab))); + + function shouldConfirmTabOnAppClose(tab: QueryTab): boolean { + if (!shouldConfirmTabClose(tab)) return false; + return !keepUnsavedTabsDraftsOnAppClose.value || tab.mode !== "query"; + } const closeConfirmDirtyTabIds = computed(() => { - if (isConfirmingAppClose.value) return tabs.value.filter((tab) => isTabDirty(tab)).map((tab) => tab.id); + if (isConfirmingAppClose.value) return tabs.value.filter((tab) => shouldConfirmTabOnAppClose(tab)).map((tab) => tab.id); if (pendingBatchCloseTabIds.value) { return pendingBatchCloseTabIds.value .map((id) => tabs.value.find((tab) => tab.id === id)) @@ -2670,7 +2677,7 @@ export const useQueryStore = defineStore("query", () => { } function requestAppCloseConfirmation() { - const dirtyTab = tabs.value.find((tab) => shouldConfirmTabClose(tab)); + const dirtyTab = tabs.value.find((tab) => shouldConfirmTabOnAppClose(tab)); if (!dirtyTab) return false; isConfirmingAppClose.value = true; showDirtyTabCloseConfirm(dirtyTab, "app"); @@ -6290,6 +6297,7 @@ export const useQueryStore = defineStore("query", () => { closeConfirmContext, closeConfirmDirtyTabIds, hasDirtyTabs, + requiresAppCloseDraftPersist, isConfirmingAppClose, createTab, openObjectSourceTab, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 9a046ec8ce..625a35efb3 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -59,6 +59,7 @@ export type InterfaceLayout = "separated" | "classic"; export type UpdateDownloadSource = "official" | "cnb"; export type SqlSemanticDiagnosticsMode = "auto" | "enabled" | "disabled"; export type OpenTabsRestoreMode = "all" | "pinned" | "none"; +export type AppCloseUnsavedTabsMode = "prompt" | "keep-drafts"; export const DEFAULT_SIDEBAR_TABLE_PAGE_SIZE = 1000; export const DUCKDB_WORKER_MAX_PROCESSES_MIN = 1; @@ -537,6 +538,7 @@ export interface EditorSettings { sqlSemanticDiagnosticsEnabled: boolean; confirmDangerousSqlExecution: boolean; confirmUnsavedSqlClose: boolean; + appCloseUnsavedTabsMode: AppCloseUnsavedTabsMode; savedSqlOpenTargetMode: SavedSqlOpenTargetMode; compactTabTitle: boolean; tabLayout: TabLayoutMode; @@ -743,6 +745,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { sqlSemanticDiagnosticsEnabled: SQL_SEMANTIC_DIAGNOSTICS_AUTO_ENABLED, confirmDangerousSqlExecution: true, confirmUnsavedSqlClose: true, + appCloseUnsavedTabsMode: "prompt", savedSqlOpenTargetMode: "saved", compactTabTitle: false, tabLayout: "scroll", @@ -951,6 +954,10 @@ function normalizeOpenTabsRestoreMode(value: unknown, legacyRestoreOpenTabsOnLau return DEFAULT_EDITOR_SETTINGS.openTabsRestoreMode; } +function normalizeAppCloseUnsavedTabsMode(value: unknown): AppCloseUnsavedTabsMode { + return value === "prompt" || value === "keep-drafts" ? value : DEFAULT_EDITOR_SETTINGS.appCloseUnsavedTabsMode; +} + function normalizeConnectionListSortMode(value: unknown): ConnectionListSortMode { return value === "asc" || value === "desc" ? value : "manual"; } @@ -1109,6 +1116,7 @@ export function normalizeEditorSettings(settings: Partial, exist sqlSemanticDiagnosticsEnabled: sqlSemanticDiagnosticsEnabledForMode(sqlSemanticDiagnosticsMode), confirmDangerousSqlExecution: settings.confirmDangerousSqlExecution ?? DEFAULT_EDITOR_SETTINGS.confirmDangerousSqlExecution, confirmUnsavedSqlClose: settings.confirmUnsavedSqlClose ?? DEFAULT_EDITOR_SETTINGS.confirmUnsavedSqlClose, + appCloseUnsavedTabsMode: normalizeAppCloseUnsavedTabsMode(settings.appCloseUnsavedTabsMode), savedSqlOpenTargetMode: settings.savedSqlOpenTargetMode === "current" ? "current" : DEFAULT_EDITOR_SETTINGS.savedSqlOpenTargetMode, compactTabTitle: settings.compactTabTitle ?? DEFAULT_EDITOR_SETTINGS.compactTabTitle, tabLayout: normalizeTabLayout(settings.tabLayout), @@ -1699,6 +1707,7 @@ export const useSettingsStore = defineStore("settings", () => { } if (partial.confirmDangerousSqlExecution !== undefined) editorSettings.value.confirmDangerousSqlExecution = partial.confirmDangerousSqlExecution; if (partial.confirmUnsavedSqlClose !== undefined) editorSettings.value.confirmUnsavedSqlClose = partial.confirmUnsavedSqlClose; + if (partial.appCloseUnsavedTabsMode !== undefined) editorSettings.value.appCloseUnsavedTabsMode = normalizeAppCloseUnsavedTabsMode(partial.appCloseUnsavedTabsMode); if (partial.savedSqlOpenTargetMode !== undefined) editorSettings.value.savedSqlOpenTargetMode = partial.savedSqlOpenTargetMode === "current" ? "current" : "saved"; if (partial.compactTabTitle !== undefined) editorSettings.value.compactTabTitle = partial.compactTabTitle; if (partial.tabLayout !== undefined) editorSettings.value.tabLayout = normalizeTabLayout(partial.tabLayout);