diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index e41b71a0d9..88e1c1df30 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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; diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.integration.spec.ts new file mode 100644 index 0000000000..48c4f779bd --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.integration.spec.ts @@ -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(); + 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('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('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(); + }); +}); diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.spec.ts new file mode 100644 index 0000000000..b2f0e13bed --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutResetEditState.spec.ts @@ -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") }}'); + }); +});