diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index a45e2738e4..a9376a67c3 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -2079,7 +2079,18 @@ const hasBlockingShortcutConflicts = computed(() => {
return hasShortcutConflicts.value || hasSqlShortcutConflicts.value;
});
const hasBlockingFormatterConfig = computed(() => activeSettingsTab.value === "formatter" && !sqlFormatterConfigValid.value);
-const hasBlockingQueryResultRowLimit = computed(() => editQueryResultMaxRowsEnabled.value && editQueryResultMaxRows.value < editPageSize.value);
+// 上限小于每页行数时该组合一定不会生效,提示与 aria-invalid 始终跟随这一事实。
+const queryResultRowLimitViolated = computed(() => editQueryResultMaxRowsEnabled.value && editQueryResultMaxRows.value < editPageSize.value);
+// 但只有用户在本对话框里动过相关草稿时才拦截「应用」:结果网格右下角的「设为默认」
+// 可以在不改动本对话框的情况下先落盘 pageSize,若仍然拦截,用户只想换字体/主题也会
+// 被永久禁用的「应用」按钮挡住(issue #9994)。与上方快捷键冲突的判定口径保持一致。
+const queryResultRowLimitDraftTouched = computed(
+ () =>
+ editQueryResultMaxRowsEnabled.value !== editEditorSettingsBase.value.queryResultMaxRowsEnabled ||
+ normalizeQueryResultMaxRowsDraft(editQueryResultMaxRows.value) !== normalizeQueryResultMaxRowsDraft(editEditorSettingsBase.value.queryResultMaxRows) ||
+ normalizeTableOpenPageSizeDraft(editPageSize.value) !== normalizeTableOpenPageSizeDraft(editEditorSettingsBase.value.pageSize),
+);
+const hasBlockingQueryResultRowLimit = computed(() => queryResultRowLimitViolated.value && queryResultRowLimitDraftTouched.value);
const hasApplyBlocker = computed(() => historyRetention.invalid.value || historyRetentionSaving.value || hasBlockingShortcutConflicts.value || hasBlockingFormatterConfig.value || hasBlockingQueryResultRowLimit.value);
function hasChanges(): boolean {
@@ -7708,7 +7719,7 @@ onUnmounted(() => {
:min="MIN_RESULT_PAGE_SIZE"
:max="MAX_RESULT_PAGE_SIZE"
:model-value="editPageSize"
- :aria-invalid="hasBlockingQueryResultRowLimit"
+ :aria-invalid="queryResultRowLimitViolated"
@update:model-value="updatePageSizeDraft"
/>
@@ -7768,8 +7779,8 @@ onUnmounted(() => {
-
- {{ hasBlockingQueryResultRowLimit ? t("settings.queryResultMaxRowsTooSmall", { pageSize: editPageSize }) : editQueryResultMaxRowsEnabled ? t("settings.queryResultMaxRowsDescription") : t("settings.queryResultMaxRowsUnlimitedDescription") }}
+
+ {{ queryResultRowLimitViolated ? t("settings.queryResultMaxRowsTooSmall", { pageSize: editPageSize }) : editQueryResultMaxRowsEnabled ? t("settings.queryResultMaxRowsDescription") : t("settings.queryResultMaxRowsUnlimitedDescription") }}
@@ -7782,7 +7793,7 @@ onUnmounted(() => {
:max="MAX_QUERY_RESULT_MAX_ROWS"
:model-value="editQueryResultMaxRows"
:disabled="!editQueryResultMaxRowsEnabled"
- :aria-invalid="hasBlockingQueryResultRowLimit"
+ :aria-invalid="queryResultRowLimitViolated"
@input="updateQueryResultMaxRowsInput"
/>
diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.queryResultRowLimitBlocker.integration.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.queryResultRowLimitBlocker.integration.spec.ts
new file mode 100644
index 0000000000..696e0f56ee
--- /dev/null
+++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.queryResultRowLimitBlocker.integration.spec.ts
@@ -0,0 +1,237 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, type App } from "vue";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia } from "pinia";
+import { DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
+
+const hoisted = vi.hoisted(() => ({
+ retentionLimit: 5000,
+ editorSettings: {} as Record,
+ updateEditorSettings: vi.fn(),
+ persistEditorSettings: vi.fn(async () => undefined),
+}));
+
+vi.mock("vue-i18n", async () => {
+ const { ref } = await import("vue");
+ const locale = ref("en");
+ const t = (key: string) => key;
+ return {
+ 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: defineComponent({
+ props: { open: { type: Boolean, default: false } },
+ setup:
+ (props, { slots }) =>
+ () =>
+ props.open ? h("div", slots.default?.()) : null,
+ }),
+ 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, disabled: (props as { disabled?: boolean }).disabled ? true : undefined }, slots.default?.());
+ },
+ props: { disabled: { type: Boolean, default: false } },
+ }),
+ };
+});
+
+// ChangelogPanel fires a network fetch on mount (About tab); irrelevant here.
+vi.mock("@/components/settings/ChangelogPanel.vue", async () => {
+ const { defineComponent } = await import("vue");
+ return { default: defineComponent({ setup: () => () => null }) };
+});
+
+vi.mock("@/stores/settingsStore", async (importOriginal) => {
+ const actual = await importOriginal();
+ const store = {
+ settingsPageActive: false,
+ get editorSettings() {
+ return hoisted.editorSettings;
+ },
+ 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),
+ // Mirror the real store: applying a patch also becomes the new persisted
+ // settings, which is what persistSettings() re-baselines the draft against.
+ updateEditorSettings: (patch: Record) => {
+ hoisted.updateEditorSettings(patch);
+ hoisted.editorSettings = { ...hoisted.editorSettings, ...(JSON.parse(JSON.stringify(patch)) as Record) };
+ },
+ persistEditorSettings: hoisted.persistEditorSettings,
+ 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 };
+});
+
+vi.mock("@/lib/backend/api", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ loadHistoryRetentionLimit: async () => hoisted.retentionLimit,
+ saveHistoryRetentionLimit: async (limit: number) => {
+ hoisted.retentionLimit = limit;
+ },
+ };
+});
+
+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() {
+ for (let i = 0; i < 5; i++) {
+ await nextTick();
+ await Promise.resolve();
+ }
+}
+
+async function mountDataTab() {
+ const host = document.createElement("div");
+ document.body.append(host);
+ const app = createApp(
+ defineComponent({
+ setup: () => () =>
+ h(EditorSettingsDialog, {
+ variant: "page",
+ initialTab: "data",
+ "onUpdate:open": () => undefined,
+ }),
+ }),
+ );
+ app.use(createPinia());
+ app.mount(host);
+ mountedApps.push({ app, host });
+ await flushAsyncUpdates();
+ return host;
+}
+
+function button(host: HTMLElement, text: string) {
+ return [...host.querySelectorAll("button")].find((item) => item.textContent?.trim() === text)!;
+}
+
+function setInputValue(input: HTMLInputElement, value: string) {
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+}
+
+beforeEach(() => {
+ hoisted.retentionLimit = 5000;
+ hoisted.updateEditorSettings.mockClear();
+ hoisted.persistEditorSettings.mockClear();
+ // The exact persisted state reported for #9994: the result-grid menu's
+ // "set as default" wrote pageSize = 1000 while the row cap stayed at 500,
+ // so the stored pair already violates the "cap >= page size" rule that the
+ // settings dialog used to treat as an unconditional apply blocker.
+ hoisted.editorSettings = {
+ ...DEFAULT_EDITOR_SETTINGS,
+ pageSize: 1000,
+ queryResultMaxRowsEnabled: true,
+ queryResultMaxRows: 500,
+ };
+});
+
+// Regression for https://github.com/t8y2/dbx/issues/9994: with an
+// already-persisted cap < page size pair, hasBlockingQueryResultRowLimit made
+// every Apply button dead for the rest of the session. Users could not save
+// any unrelated setting (the report was a font change) and got no hint about
+// why, because the offending row lives on another tab.
+describe("EditorSettingsDialog row-limit apply blocker", { timeout: 30_000 }, () => {
+ it("does not block unrelated edits when the stored cap/page-size pair is already invalid", async () => {
+ const host = await mountDataTab();
+ const apply = button(host, "settings.apply");
+ expect(apply.disabled, "nothing changed yet, so Apply starts disabled").toBe(true);
+ // The row keeps telling the truth about the stored pair instead of hiding it.
+ expect(host.textContent).toContain("settings.queryResultMaxRowsTooSmall");
+
+ // Any edit outside the cap/page-size pair (the report used the UI font).
+ setInputValue(host.querySelector("#table-open-page-size")!, "200");
+ await flushAsyncUpdates();
+ expect(apply.disabled, "an unrelated edit must be saveable").toBe(false);
+ });
+
+ it("still blocks an invalid pair the user creates in this dialog", async () => {
+ const host = await mountDataTab();
+ const apply = button(host, "settings.apply");
+ const maxRows = host.querySelector("#query-result-max-rows")!;
+
+ maxRows.dispatchEvent(new Event("input", { bubbles: true }));
+ await flushAsyncUpdates();
+ setInputValue(maxRows, "400");
+ await flushAsyncUpdates();
+ expect(maxRows.getAttribute("aria-invalid")).toBe("true");
+ expect(apply.disabled, "cap below page size must keep Apply disabled").toBe(true);
+
+ setInputValue(maxRows, "1500");
+ await flushAsyncUpdates();
+ expect(apply.disabled).toBe(false);
+ });
+
+ it("lets an unrelated edit apply end to end", async () => {
+ const host = await mountDataTab();
+ setInputValue(host.querySelector("#table-open-page-size")!, "200");
+ await flushAsyncUpdates();
+ const apply = button(host, "settings.apply");
+ expect(apply.disabled).toBe(false);
+ apply.click();
+ await flushAsyncUpdates();
+ const patch = hoisted.updateEditorSettings.mock.calls[0]?.[0] as Record | undefined;
+ expect(patch, "Apply must reach the settings store").toBeTruthy();
+ expect(patch!.tableOpenPageSize).toBe(200);
+ expect(hoisted.persistEditorSettings).toHaveBeenCalled();
+ expect(apply.disabled, "the draft is the saved baseline again").toBe(true);
+ });
+});
diff --git a/docs/screenshots/issue-9994-after.png b/docs/screenshots/issue-9994-after.png
new file mode 100644
index 0000000000..9fec9479a4
Binary files /dev/null and b/docs/screenshots/issue-9994-after.png differ
diff --git a/docs/screenshots/issue-9994-before.png b/docs/screenshots/issue-9994-before.png
new file mode 100644
index 0000000000..9814b30270
Binary files /dev/null and b/docs/screenshots/issue-9994-before.png differ
diff --git a/docs/screenshots/issue-9994-in-dialog-blocked.png b/docs/screenshots/issue-9994-in-dialog-blocked.png
new file mode 100644
index 0000000000..623bdd4a54
Binary files /dev/null and b/docs/screenshots/issue-9994-in-dialog-blocked.png differ