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"');
});
});