fix(editor): exit shortcut capture state when restoring defaults

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-15 15:59:50 +08:00
1 parent 2b1c5fa71e
commit 2ee04c51ec
3 files changed
+259

No files matched your search

@@ -1666,6 +1666,11 @@ async function restartDbxForDuckDbIsolation() {
}
function resetDefaultsForTab(tab: SettingsCategory) {
// Restoring defaults exits any in-progress shortcut capture so the affected
// rows return to their initial (non-editing) state instead of staying stuck
// in edit mode (#9066). Cleared unconditionally because the edit state can
// leak across tab switches (shortcuts/formatter tabs share the table).
editingShortcutId.value = null;
if (tab === "editor") {
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
@@ -1794,6 +1799,9 @@ function resetDefaultsForTab(tab: SettingsCategory) {
}
function resetAllDefaults() {
// Same contract as resetDefaultsForTab: a full reset also exits any
// in-progress shortcut capture (#9066).
editingShortcutId.value = null;
editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;
editFontSize.value = DEFAULT_EDITOR_SETTINGS.fontSize;
editTableFontFamily.value = DEFAULT_EDITOR_SETTINGS.tableFontFamily;
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPinia } from "pinia";
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
// Reviewer follow-up to the source-contract spec
// (EditorSettingsDialog.shortcutResetEditState.spec.ts): prove the actual UI
// state transition for #9066 — entering a shortcut row's capture state and
// clicking restore-defaults must return the row to its initial pill display,
// for both the per-tab footer button and the About tab's full reset (whose
// click path is reachable because the capture state survives tab switches).
vi.mock("vue-i18n", async () => {
const { ref } = await import("vue");
const locale = ref("en");
const t = (key: string) => key;
return {
// The real @/i18n index builds its instance at module scope; satisfy it.
createI18n: () => ({ global: { t, locale }, install: () => undefined }),
useI18n: () => ({ t, locale }),
};
});
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return {
Dialog: passthrough,
DialogContent: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
DialogTitle: passthrough,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h("button", attrs, slots.default?.());
},
}),
};
});
// ChangelogPanel fires a changelog network fetch on mount (About tab); the
// network is irrelevant to the reset contract under test.
vi.mock("@/components/settings/ChangelogPanel.vue", async () => {
const { defineComponent } = await import("vue");
return { default: defineComponent({ setup: () => () => null }) };
});
// Keep every real constant/normalizer export, but back the store composable
// with a plain stub so the dialog boots without the Tauri backend. A custom
// formatSql binding makes the value reset observable in the row pill.
vi.mock("@/stores/settingsStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/stores/settingsStore")>();
const store = {
settingsPageActive: false,
editorSettings: {
...actual.DEFAULT_EDITOR_SETTINGS,
shortcuts: { ...actual.DEFAULT_EDITOR_SETTINGS.shortcuts, formatSql: "Ctrl+Alt+F" },
},
desktopSettings: actual.DEFAULT_DESKTOP_SETTINGS,
mcpGlobalPolicy: { configured: false, readOnly: false },
aiConfigs: [],
aiDefaultTemplatesByDbType: {},
defaultAiMode: "ask",
restoreLastConversation: false,
initMcpGlobalPolicy: vi.fn(async () => undefined),
updateMcpGlobalPolicy: vi.fn(async () => undefined),
initAiConfigs: vi.fn(async () => undefined),
initDesktopSettings: vi.fn(async () => undefined),
updateEditorSettingsAndPersist: vi.fn(async () => undefined),
updateEditorSettings: vi.fn(),
persistEditorSettings: vi.fn(async () => undefined),
updateDesktopSettings: vi.fn(async () => undefined),
reloadAiConfigs: vi.fn(async () => undefined),
removeTemplateFromDefaultAndLastUsed: vi.fn(),
setDefaultTemplatesForDbType: vi.fn(),
updateAiConfigItem: vi.fn(),
createAiConfig: vi.fn(),
deleteAiConfig: vi.fn(),
setDefaultAiConfig: vi.fn(),
setDefaultAiMode: vi.fn(),
setRestoreLastConversation: vi.fn(),
};
return { ...actual, useSettingsStore: () => store };
});
import EditorSettingsDialog from "../EditorSettingsDialog.vue";
if (!("ResizeObserver" in globalThis)) {
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
async function flushAsyncUpdates() {
await nextTick();
await Promise.resolve();
await nextTick();
}
// Page variant keeps the root a plain div (no reka-ui Dialog teleport), and
// changing `initialTab` drives the component's real tab watcher, so both
// reset entrypoints are reached through genuine rendered controls.
async function mountSettingsPage(initialTab: string) {
const host = document.createElement("div");
document.body.append(host);
let setTab: (tab: string) => void = () => {};
const app = createApp(
defineComponent({
setup() {
const tab = ref(initialTab);
setTab = (value: string) => {
tab.value = value;
};
return () => h(EditorSettingsDialog, { variant: "page", initialTab: tab.value });
},
}),
);
app.use(createPinia());
app.mount(host);
mountedApps.push({ app, host });
await flushAsyncUpdates();
return { host, setTab };
}
function formatSqlRowControls(host: HTMLElement) {
const input = host.querySelector<HTMLInputElement>('input[data-shortcut-input="formatSql"]');
if (!input) throw new Error("formatSql shortcut row is not rendered");
const scope = input.closest(".settings-shortcut-actions") ?? input.parentElement!;
return {
input,
editButton: scope.querySelector<HTMLButtonElement>('button[aria-label="settings.shortcutPressShortcut"]'),
cancelButton: Array.from(scope.querySelectorAll("button")).find((button) => button.textContent?.trim() === "settings.cancel") ?? null,
};
}
function findButtonByText(host: HTMLElement, text: string) {
return Array.from(host.querySelectorAll("button")).find((button) => button.textContent?.trim() === text) ?? null;
}
function formatSqlDefaultPill() {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === "formatSql");
if (!definition) throw new Error("formatSql definition missing from SHORTCUT_DEFINITIONS");
return formatShortcutDisplay(definition.defaultShortcut);
}
describe("EditorSettingsDialog shortcut reset exits capture state (behavior)", () => {
it("per-tab restore-defaults exits the capture state and restores the default pill", async () => {
const { host } = await mountSettingsPage("shortcuts");
const initial = formatSqlRowControls(host);
expect(initial.input.value).toBe(formatShortcutDisplay("Ctrl+Alt+F"));
expect(initial.editButton).toBeTruthy();
expect(initial.cancelButton).toBeNull();
initial.editButton!.click();
await flushAsyncUpdates();
const editing = formatSqlRowControls(host);
expect(editing.input.value).toBe("");
expect(editing.input.placeholder).toBe("settings.shortcutPressShortcut");
expect(editing.cancelButton).toBeTruthy();
const resetButton = findButtonByText(host, "settings.resetDefaults");
expect(resetButton).toBeTruthy();
resetButton!.click();
await flushAsyncUpdates();
const restored = formatSqlRowControls(host);
expect(restored.input.value).toBe(formatSqlDefaultPill());
expect(restored.cancelButton).toBeNull();
expect(restored.editButton).toBeTruthy();
});
it("full reset on the about tab also exits a capture state that leaked across tabs", async () => {
const { host, setTab } = await mountSettingsPage("shortcuts");
formatSqlRowControls(host).editButton!.click();
await flushAsyncUpdates();
expect(formatSqlRowControls(host).cancelButton).toBeTruthy();
setTab("about");
await flushAsyncUpdates();
const fullResetButton = findButtonByText(host, "settings.resetAllDefaults");
expect(fullResetButton).toBeTruthy();
fullResetButton!.click();
await flushAsyncUpdates();
setTab("shortcuts");
await flushAsyncUpdates();
const restored = formatSqlRowControls(host);
expect(restored.input.value).toBe(formatSqlDefaultPill());
expect(restored.cancelButton).toBeNull();
expect(restored.editButton).toBeTruthy();
});
});
@@ -0,0 +1,30 @@
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/9066: clicking a shortcut
// row's edit button enters the per-row capture state (editingShortcutId), and
// clicking the footer "restore defaults" button afterwards reset the shortcut
// values but left the row stuck in the editing state ("press shortcut"
// capture input still active). Restoring defaults must also exit the edit
// mode so the row returns to its initial pill display. The clear happens at
// the top of both reset entrypoints because the edit state can leak across
// tab switches (the shortcuts and formatter tables share it).
describe("EditorSettingsDialog shortcut restore-defaults exits edit state", () => {
it("resetDefaultsForTab clears the in-progress shortcut capture before resetting values", () => {
const head = dialogSource.slice(dialogSource.indexOf("function resetDefaultsForTab(tab: SettingsCategory) {"), dialogSource.indexOf('if (tab === "editor") {'));
expect(head).toContain("editingShortcutId.value = null;");
});
it("resetAllDefaults clears the in-progress shortcut capture too", () => {
const start = dialogSource.indexOf("function resetAllDefaults() {");
const head = dialogSource.slice(start, dialogSource.indexOf("editFontFamily.value = DEFAULT_EDITOR_SETTINGS.fontFamily;", start));
expect(head).toContain("editingShortcutId.value = null;");
});
it("keeps the footer restore-defaults button wired to resetDefaultsForTab", () => {
expect(dialogSource).toContain('@click="resetDefaultsForTab(activeSettingsTab as SettingsCategory)"');
expect(dialogSource).toContain('{{ t("settings.resetDefaults") }}');
});
});