fix(settings): confirm unsaved changes before closing

This commit is contained in:
efeire
2026-10-15 22:05:06 +08:00
committed by GitHub
parent 92e6ad0b5e
commit 16a3806ace
3 changed files with 161 additions and 3 deletions
+15 -3
View File
@@ -494,6 +494,7 @@ provide(OPEN_PLUGIN_AI_CONVERSATION, (request) => {
});
const appSidebarRef = ref<InstanceType<typeof AppSidebar> | null>(null);
const appTabBarRef = ref<InstanceType<typeof AppTabBar> | null>(null);
const settingsPageRef = ref<InstanceType<typeof EditorSettingsPage> | null>(null);
const contentAreaRef = ref<InstanceType<typeof SqlEditorWorkspace> | null>(null);
const lastFocusedAuxiliarySurface = ref<AuxiliarySearchSurface>(null);
const lastFocusedSidebarSurface = ref(false);
@@ -1379,6 +1380,16 @@ function activateOpenSpecialPageFallback() {
}
function closeSettingsPage() {
if (settingsPageRef.value) {
activateSettingsPage();
settingsPageRef.value.requestClose();
return;
}
// An async settings page that has not mounted cannot have an edited draft.
finishCloseSettingsPage();
}
function finishCloseSettingsPage() {
settingsPageTabOpen.value = false;
if (settingsReturnSurface.value === "driverStore" && driverStoreTabOpen.value) {
activateMainContentSurface("driverStore");
@@ -1473,7 +1484,7 @@ async function checkAllUpdates() {
}
function openDriverStoreFromUpdate(target?: DriverStoreTab) {
closeSettingsPage();
finishCloseSettingsPage();
openDriverStorePage(target);
}
@@ -5250,6 +5261,7 @@ onUnmounted(() => {
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
ref="settingsPageRef"
variant="page"
:open="settingsPageTabOpen"
:initial-tab="settingsInitialTab"
@@ -5267,13 +5279,13 @@ onUnmounted(() => {
:mcp-update-available="toolbarMcpUpdateAvailable"
:plugin-update-count="componentUpdates.pluginUpdateCount.value"
class="flex-1 min-h-0"
@update:open="(open: boolean) => (open ? activateSettingsPage() : closeSettingsPage())"
@update:open="(open: boolean) => (open ? activateSettingsPage() : finishCloseSettingsPage())"
@check-updates="checkAllUpdates"
@update-all="updateAllAvailable"
@open-update-center="handleToolbarUpdateClick"
@open-driver-store="openDriverStoreFromUpdate"
@open-plugin-center="
closeSettingsPage();
finishCloseSettingsPage();
openPluginCenterPage();
"
@open-cc-switch-plugin-center="
@@ -620,6 +620,8 @@ function closeSettings() {
requestCloseSettings(false);
}
defineExpose({ requestClose: closeSettings });
function cancelUnsavedSettingsClose() {
showUnsavedSettingsCloseConfirm.value = false;
}
@@ -0,0 +1,144 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "@/i18n";
import { useSettingsStore } from "@/stores/settingsStore";
import EditorSettingsDialog from "../EditorSettingsDialog.vue";
const backend = vi.hoisted(() => ({ saved: null as unknown, saves: vi.fn() }));
vi.mock("@/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
return {
...actual,
loadEditorSettings: async () => backend.saved,
saveEditorSettings: async (value: unknown) => {
backend.saved = structuredClone(value);
backend.saves(value);
},
loadHistoryRetentionLimit: async () => 5000,
loadMcpHistoryRetentionLimit: async () => 5000,
loadMcpGlobalPolicy: async () => null,
loadDesktopSettings: async () => null,
loadAiConfigs: async () => [],
loadAiConfig: async () => null,
loadAiProviderConfigs: async () => ({}),
loadAiChatSelection: async () => null,
webdavSyncSecretsStatus: async () => ({}),
snippetSavedTokenStatus: async () => ({}),
snippetSyncSettings: async () => ({}),
};
});
let app: App | undefined;
let host: HTMLElement;
async function settle() {
for (let i = 0; i < 10; i++) {
await Promise.resolve();
await nextTick();
}
}
beforeEach(() => {
backend.saved = null;
backend.saves.mockClear();
localStorage.clear();
});
afterEach(() => {
app?.unmount();
host?.remove();
document.body.innerHTML = "";
});
async function mountPage() {
const pinia = createPinia();
setActivePinia(pinia);
const store = useSettingsStore();
await store.initEditorSettings();
await store.initDesktopSettings();
backend.saves.mockClear();
const page = ref<{ requestClose(): void } | null>(null);
const open = ref(true);
host = document.createElement("div");
document.body.append(host);
app = createApp(
defineComponent({
setup: () => () =>
open.value
? h(EditorSettingsDialog, {
ref: page,
variant: "page",
open: true,
initialTab: "editor",
"onUpdate:open": (value: boolean) => {
open.value = value;
},
})
: null,
}),
);
app.use(pinia);
app.use(i18n);
app.mount(host);
await settle();
const changeFontSize = async () => {
const input = host.querySelector<HTMLInputElement>('input[type="range"][min="10"][max="24"]')!;
input.value = "19";
input.dispatchEvent(new Event("input", { bubbles: true }));
await settle();
};
return { page, open, store, changeFontSize };
}
function clickText(key: string) {
const text = i18n.global.t(key);
const button = Array.from(document.querySelectorAll("button")).find((item) => item.textContent?.trim() === text);
expect(button, key).toBeTruthy();
button!.click();
}
describe("settings page external close request", () => {
it("keeps a dirty draft when continuing, and closes without saving only after discarding", async () => {
const { page, open, store, changeFontSize } = await mountPage();
const originalFontSize = store.editorSettings.fontSize;
await changeFontSize();
page.value!.requestClose();
await settle();
expect(open.value).toBe(true);
clickText("settings.unsavedChangesCloseCancel");
await settle();
expect(host.querySelector<HTMLInputElement>('input[type="range"][min="10"][max="24"]')?.value).toBe("19");
expect(backend.saves).not.toHaveBeenCalled();
page.value!.requestClose();
await settle();
clickText("settings.unsavedChangesCloseDiscard");
await settle();
expect(open.value).toBe(false);
expect(store.editorSettings.fontSize).toBe(originalFontSize);
expect(backend.saves).not.toHaveBeenCalled();
});
it("closes an unchanged page directly", async () => {
const { page, open } = await mountPage();
page.value!.requestClose();
await settle();
expect(open.value).toBe(false);
expect(backend.saves).not.toHaveBeenCalled();
});
it("closes directly after applying the draft", async () => {
const { page, open, store, changeFontSize } = await mountPage();
await changeFontSize();
clickText("settings.apply");
await settle();
expect(store.editorSettings.fontSize).toBe(19);
expect(backend.saves).toHaveBeenCalled();
page.value!.requestClose();
await settle();
expect(open.value).toBe(false);
});
});