feat(editor): preserve unsaved SQL drafts on exit

This commit is contained in:
Justin Gao authored and GitHub committed 2026-08-21 18:38:53 +08:00
1 parent 52c892676c
commit 31a50fb460
17 files changed
+313 -3

No files matched your search

+18 -1
View File
@@ -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);
}
@@ -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<AppCloseUnsavedTabsMode>(settingsStore.editorSettings.appCloseUnsavedTabsMode);
const editSavedSqlOpenTargetMode = ref<SavedSqlOpenTargetMode>(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(() => {
<Switch id="editor-confirm-unsaved-sql-close" v-model="editConfirmUnsavedSqlClose" class="mt-0.5" />
</div>
<div v-if="editConfirmUnsavedSqlClose" class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="app-close-unsaved-tabs-mode">{{ t("settings.appCloseUnsavedTabsMode") }}</Label>
<HelpTooltip :label="t('settings.appCloseUnsavedTabsMode')">
{{ t("settings.appCloseUnsavedTabsModeDescription") }}
</HelpTooltip>
</div>
<Select v-model="editAppCloseUnsavedTabsMode">
<SelectTrigger id="app-close-unsaved-tabs-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prompt">{{ t("settings.appCloseUnsavedTabsModePrompt") }}</SelectItem>
<SelectItem value="keep-drafts">{{ t("settings.appCloseUnsavedTabsModeKeepDrafts") }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{{ t("settings.appCloseUnsavedTabsModeHint") }}
</p>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-click-table-navigation-ddl">{{ t("settings.clickTableNavigationTarget") }}</Label>
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+6
View File
@@ -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ファイルに保存された接続、データベース、スキーマを使用します。",
+6
View File
@@ -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 파일에 저장된 연결, 데이터베이스 및 스키마를 사용합니다.",
+6
View File
@@ -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.",
+6
View File
@@ -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。",
+6
View File
@@ -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。",
@@ -0,0 +1,19 @@
interface FinishAppCloseWithRequiredPersistOptions {
persist: () => Promise<void>;
beforeClose?: () => Promise<void>;
close: () => Promise<void>;
onPersistError: (error: unknown) => void;
}
export async function finishAppCloseWithRequiredPersist(options: FinishAppCloseWithRequiredPersistOptions): Promise<boolean> {
try {
await options.persist();
} catch (error) {
options.onPersistError(error);
return false;
}
await options.beforeClose?.();
await options.close();
return true;
}
@@ -27,6 +27,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"sqlSemanticDiagnosticsMode",
"confirmDangerousSqlExecution",
"confirmUnsavedSqlClose",
"appCloseUnsavedTabsMode",
"savedSqlOpenTargetMode",
"appLayout",
"tabLayout",
@@ -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" },
@@ -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<typeof import("@/lib/backend/api")>()),
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<ReturnType<typeof createStoreWithDirtyQueryTab>>["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"]);
});
});
@@ -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");
+10 -2
View File
@@ -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,
+9
View File
@@ -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<EditorSettings>, 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);