feat(editor): preserve unsaved SQL drafts on exit
This commit is contained in:
1 parent
52c892676c
commit
31a50fb460
17 files changed
+313
-3
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ファイルに保存された接続、データベース、スキーマを使用します。",
|
||||
|
||||
@@ -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 파일에 저장된 연결, 데이터베이스 및 스키마를 사용합니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user