fix(settings): confirm before discarding unsaved settings

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-08-17 14:52:00 +08:00
1 parent 9dbb2ef72f
commit 6a25bd1909
12 files changed
+160 -3

No files matched your search

@@ -126,7 +126,7 @@ import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePi
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
@@ -221,11 +221,31 @@ const settingsContentClass = computed(() =>
);
const settingsTitleComponent = computed(() => (isSettingsPage.value ? "h2" : DialogTitle));
const showUnsavedSettingsCloseConfirm = ref(false);
function requestCloseSettings(nextOpen: boolean) {
if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {
showUnsavedSettingsCloseConfirm.value = true;
return;
}
emit("update:open", nextOpen);
}
function onSettingsRootOpenChange(value: boolean) {
if (!isSettingsPage.value) emit("update:open", value);
if (isSettingsPage.value) return;
requestCloseSettings(value);
}
function closeSettings() {
requestCloseSettings(false);
}
function cancelUnsavedSettingsClose() {
showUnsavedSettingsCloseConfirm.value = false;
}
function discardUnsavedSettingsAndClose() {
showUnsavedSettingsCloseConfirm.value = false;
emit("update:open", false);
}
@@ -6928,6 +6948,24 @@ onUnmounted(() => {
</div>
</component>
<!-- Unsaved settings confirmation: guards every close path (Escape, outside click, the X button, and the footer "Close" button) so an unapplied draft is never silently discarded. -->
<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">
<DialogContent class="sm:max-w-[420px]" @interact-outside.prevent>
<DialogHeader>
<DialogTitle>{{ t("settings.unsavedChangesCloseTitle") }}</DialogTitle>
</DialogHeader>
<p class="text-sm text-muted-foreground">{{ t("settings.unsavedChangesCloseMessage") }}</p>
<DialogFooter class="gap-2">
<Button variant="outline" size="sm" @click="cancelUnsavedSettingsClose">
{{ t("settings.unsavedChangesCloseCancel") }}
</Button>
<Button variant="destructive" size="sm" @click="discardUnsavedSettingsAndClose">
{{ t("settings.unsavedChangesCloseDiscard") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Theme Customizer Dialog -->
<ThemeCustomizerDialog v-model:open="showThemeCustomizer" :themes="editCustomThemes" :active-theme-id="editActiveCustomThemeId" @save="handleThemeSave" />
@@ -0,0 +1,51 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
// Regression for https://github.com/t8y2/dbx/issues/5905: changing a shortcut
// or the sidebar activation mode (and every other draft-gated editor
// setting) was silently discarded whenever the dialog closed any way other
// than the explicit "Apply"/"Apply and Close" buttons — Escape, clicking
// outside, the X button, and every tab's "Close" footer button all bypassed
// the draft entirely. Users only noticed after a later app restart (e.g. to
// install an update) surfaced the still-default values, making it look like
// the update itself had reset their settings.
describe("EditorSettingsDialog unsaved-changes close guard", () => {
it("imports the shared unsaved-changes decision helper", () => {
expect(dialogSource).toContain('shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft"');
});
it("routes every close path through the same guard instead of emitting update:open directly", () => {
expect(dialogSource).toContain("function requestCloseSettings(nextOpen: boolean) {");
expect(dialogSource).toContain("if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {");
expect(dialogSource).toContain("showUnsavedSettingsCloseConfirm.value = true;");
// Escape / outside click / the dialog's own X button
expect(dialogSource).toContain("function onSettingsRootOpenChange(value: boolean) {\n if (isSettingsPage.value) return;\n requestCloseSettings(value);\n}");
// Every tab footer's "Close" button — must work in both dialog and page
// (variant="page") mode, since App.vue relies on closeSettings() emitting
// update:open(false) in page mode too (see @update:open on EditorSettingsPage).
expect(dialogSource).toContain("function closeSettings() {\n requestCloseSettings(false);\n}");
// Neither close entrypoint may emit update:open directly anymore - only requestCloseSettings does.
const closeSettingsBody = dialogSource.slice(dialogSource.indexOf("function closeSettings()"), dialogSource.indexOf("function cancelUnsavedSettingsClose()"));
expect(closeSettingsBody).not.toContain('emit("update:open"');
});
it("lets the user cancel back into the dialog without losing the draft", () => {
expect(dialogSource).toContain("function cancelUnsavedSettingsClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n}");
});
it("only actually closes (dropping the draft) once the user explicitly confirms discarding", () => {
expect(dialogSource).toContain('function discardUnsavedSettingsAndClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n emit("update:open", false);\n}');
});
it("renders a confirmation dialog wired to the guard state and both actions", () => {
expect(dialogSource).toContain('<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">');
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseTitle") }}');
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseMessage") }}');
expect(dialogSource).toContain('@click="cancelUnsavedSettingsClose"');
expect(dialogSource).toContain('@click="discardUnsavedSettingsAndClose"');
});
});
+4
View File
@@ -5807,6 +5807,10 @@ export default {
duckDbWorkerProcessIsolationRestartRequired: "Restart DBX for this change to take effect.",
restartDbx: "Restart DBX",
restartDbxFailed: "Failed to restart DBX: {error}",
unsavedChangesCloseTitle: "Unsaved changes",
unsavedChangesCloseMessage: "You have unsaved settings changes that will be lost if you close now. Discard these changes?",
unsavedChangesCloseCancel: "Keep editing",
unsavedChangesCloseDiscard: "Discard changes",
debugLogsCopy: "Copy logs",
debugLogsCopied: "Copied",
debugLogsDownload: "Download logs",
+4
View File
@@ -5548,6 +5548,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "Reinicia DBX para que este cambio surta efecto.",
restartDbx: "Reiniciar DBX",
restartDbxFailed: "No se pudo reiniciar DBX: {error}",
unsavedChangesCloseTitle: "Cambios sin guardar",
unsavedChangesCloseMessage: "Tienes cambios de configuración sin guardar que se perderán si cierras ahora. ¿Descartar estos cambios?",
unsavedChangesCloseCancel: "Seguir editando",
unsavedChangesCloseDiscard: "Descartar cambios",
debugLogsCopy: "Copiar logs",
debugLogsCopied: "Copiado",
debugLogsDownload: "Descargar logs",
+4
View File
@@ -5548,6 +5548,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "Riavvia DBX affinché questa modifica abbia effetto.",
restartDbx: "Riavvia DBX",
restartDbxFailed: "Impossibile riavviare DBX: {error}",
unsavedChangesCloseTitle: "Modifiche non salvate",
unsavedChangesCloseMessage: "Ci sono modifiche alle impostazioni non salvate che andranno perse se chiudi ora. Vuoi scartare queste modifiche?",
unsavedChangesCloseCancel: "Continua a modificare",
unsavedChangesCloseDiscard: "Scarta modifiche",
debugLogsCopy: "Copia log",
debugLogsCopied: "Copiati",
debugLogsDownload: "Scarica log",
+4
View File
@@ -5583,6 +5583,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "この変更を有効にするには DBX を再起動してください。",
restartDbx: "DBX を再起動",
restartDbxFailed: "DBX の再起動に失敗しました: {error}",
unsavedChangesCloseTitle: "未保存の変更",
unsavedChangesCloseMessage: "未保存の設定変更があります。今すぐ閉じると失われます。これらの変更を破棄しますか?",
unsavedChangesCloseCancel: "編集を続ける",
unsavedChangesCloseDiscard: "変更を破棄",
debugLogsCopy: "ログをコピー",
debugLogsCopied: "コピーしました",
debugLogsDownload: "ログをダウンロード",
+4
View File
@@ -5277,6 +5277,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "이 변경 사항을 적용하려면 DBX를 다시 시작하세요.",
restartDbx: "DBX 다시 시작",
restartDbxFailed: "DBX 다시 시작 실패: {error}",
unsavedChangesCloseTitle: "저장하지 않은 변경 사항",
unsavedChangesCloseMessage: "저장하지 않은 설정 변경 사항이 있습니다. 지금 닫으면 사라집니다. 이 변경 사항을 취소하시겠습니까?",
unsavedChangesCloseCancel: "계속 편집",
unsavedChangesCloseDiscard: "변경 사항 취소",
debugLogsCopy: "로그 복사",
debugLogsCopied: "복사했습니다",
debugLogsDownload: "로그 다운로드",
+4
View File
@@ -5550,6 +5550,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "Reinicie o DBX para que esta alteração entre em vigor.",
restartDbx: "Reiniciar DBX",
restartDbxFailed: "Falha ao reiniciar o DBX: {error}",
unsavedChangesCloseTitle: "Alterações não salvas",
unsavedChangesCloseMessage: "Você tem alterações de configuração não salvas que serão perdidas se fechar agora. Descartar essas alterações?",
unsavedChangesCloseCancel: "Continuar editando",
unsavedChangesCloseDiscard: "Descartar alterações",
debugLogsCopy: "Copiar logs",
debugLogsCopied: "Copiado",
debugLogsDownload: "Baixar logs",
+4
View File
@@ -5794,6 +5794,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "重启 DBX 后生效。",
restartDbx: "重启 DBX",
restartDbxFailed: "重启 DBX 失败:{error}",
unsavedChangesCloseTitle: "未保存的更改",
unsavedChangesCloseMessage: "当前设置有未保存的更改,关闭后将丢失。是否放弃这些更改?",
unsavedChangesCloseCancel: "继续编辑",
unsavedChangesCloseDiscard: "放弃更改",
debugLogsCopy: "复制日志",
debugLogsCopied: "已复制",
debugLogsDownload: "下载日志",
+4
View File
@@ -4885,6 +4885,10 @@ export default withEnglishFallback({
duckDbWorkerProcessIsolationRestartRequired: "重啟 DBX 後生效。",
restartDbx: "重啟 DBX",
restartDbxFailed: "重啟 DBX 失敗:{error}",
unsavedChangesCloseTitle: "未儲存的變更",
unsavedChangesCloseMessage: "目前設定有未儲存的變更,關閉後將遺失。是否放棄這些變更?",
unsavedChangesCloseCancel: "繼續編輯",
unsavedChangesCloseDiscard: "放棄變更",
debugLogsCopy: "複製日誌",
debugLogsCopied: "已複製",
debugLogsDownload: "下載日誌",
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft } from "../editorSettingsDraft";
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose } from "../editorSettingsDraft";
import type { EditorSettings } from "@/stores/settingsStore";
function makeSettings(overrides: Partial<EditorSettings> = {}): EditorSettings {
@@ -345,6 +345,33 @@ describe("editorSettingsDraftChanged - tabLayout", () => {
});
});
describe("shouldConfirmEditorSettingsDialogClose", () => {
// Regression for https://github.com/t8y2/dbx/issues/5905: customizing a
// shortcut or the sidebar activation mode and then dismissing the dialog
// via Escape/outside-click/the "Close" button (anything other than Apply)
// must not silently drop the draft.
it("requests confirmation when the dialog is closing with an unsaved shortcut/sidebarActivation edit", () => {
const settings = makeSettings({ sidebarActivation: "single" } as Partial<EditorSettings>);
const base = editorSettingsDraftFromSettings(settings);
const draft = editorSettingsDraftFromSettings(settings);
draft.sidebarActivation = "double";
expect(shouldConfirmEditorSettingsDialogClose(false, editorSettingsDraftChanged(draft, base))).toBe(true);
});
it("does not block closing when there is no unsaved draft", () => {
const settings = makeSettings();
const base = editorSettingsDraftFromSettings(settings);
const draft = editorSettingsDraftFromSettings(settings);
expect(shouldConfirmEditorSettingsDialogClose(false, editorSettingsDraftChanged(draft, base))).toBe(false);
});
it("never blocks opening the dialog, even with a stale dirty flag", () => {
expect(shouldConfirmEditorSettingsDialogClose(true, true)).toBe(false);
});
});
describe("editorSettingsPatchFromDraft - tabLayout", () => {
it("includes tabLayout in patch when changed", () => {
const settings = makeSettings({ tabLayout: "scroll" });
@@ -134,3 +134,12 @@ export function editorSettingsPatchFromDraft(draft: EditorSettingsDraft, base: E
export function editorSettingsDraftChanged(draft: EditorSettingsDraft, base: EditorSettingsDraft): boolean {
return EDITOR_SETTINGS_DRAFT_KEYS.some((key) => draftValueChanged(key, draft[key], base[key]));
}
// Closing the settings dialog (Escape, clicking outside, the X button, or the
// "Close" footer button) must never silently drop an unapplied draft — the
// dialog only persists shortcuts/sidebarActivation/etc. to the store on an
// explicit Apply. Route every close attempt through this check so an unsaved
// draft always surfaces a confirmation instead of vanishing.
export function shouldConfirmEditorSettingsDialogClose(nextOpen: boolean, hasUnsavedChanges: boolean): boolean {
return nextOpen === false && hasUnsavedChanges;
}