From c6d31c05779a6e55ab91726bda5d5406b9f5ce3b Mon Sep 17 00:00:00 2001 From: zipg Date: Wed, 23 Sep 2026 10:42:58 +0800 Subject: [PATCH] fix(toolbar): make the always-on-top button opt-in --- .../editor/EditorSettingsDialog.vue | 4 +- .../src/components/layout/AppToolbar.vue | 7 +- .../components/layout/DetachedTabHeader.vue | 32 ++ .../__tests__/AlwaysOnTopButton.spec.ts | 208 ++++++++++++ .../AlwaysOnTopVisibilitySync.spec.ts | 321 ++++++++++++++++++ apps/desktop/src/lib/app/windowAlwaysOnTop.ts | 11 + .../settings/__tests__/settingsSearch.spec.ts | 15 + .../src/lib/settings/settingsSearch.ts | 14 +- .../stores/__tests__/settingsStore.spec.ts | 10 + apps/desktop/src/stores/settingsStore.ts | 29 +- 10 files changed, 645 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/components/layout/__tests__/AlwaysOnTopButton.spec.ts create mode 100644 apps/desktop/src/components/layout/__tests__/AlwaysOnTopVisibilitySync.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index a9376a67c3..44e71cd4b2 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -270,6 +270,7 @@ import { resolveSettingsSearchEntries, searchSettings, toolbarVisibilityItemLabel, + visibleToolbarVisibilityItems, type SettingsCategory, type SettingsSearchEntry, type ToolbarVisibilityItem, @@ -824,7 +825,8 @@ const editExportRowLimit = ref(settingsStore.editorSettings.exportRowLimit); const editQueryExportKeysetOptimizationEnabled = ref(settingsStore.editorSettings.queryExportKeysetOptimizationEnabled); const editUpdateDownloadSource = ref(settingsStore.editorSettings.updateDownloadSource); const editToolbarItems = ref({ ...settingsStore.editorSettings.toolbarItems }); -const toolbarVisibilityItems = TOOLBAR_VISIBILITY_ITEMS; +// Desktop-only toolbar buttons (always-on-top) can never render in the Web build, so their switches stay out of the Web settings dialog. +const toolbarVisibilityItems = computed(() => visibleToolbarVisibilityItems(TOOLBAR_VISIBILITY_ITEMS, isWeb)); function getToolbarVisibilityItemLabel(item: ToolbarVisibilityItem): string { return toolbarVisibilityItemLabel(item, t); } diff --git a/apps/desktop/src/components/layout/AppToolbar.vue b/apps/desktop/src/components/layout/AppToolbar.vue index eab1a28aae..7c141c6130 100644 --- a/apps/desktop/src/components/layout/AppToolbar.vue +++ b/apps/desktop/src/components/layout/AppToolbar.vue @@ -80,6 +80,11 @@ const settingsStore = useSettingsStore(); const toolbarItems = computed(() => settingsStore.editorSettings.toolbarItems); const showToolbarUpdateEntry = computed(() => toolbarItems.value.checkUpdates || props.hasUpdateAvailable); const { isMac, isDesktop, showControls, isMaximized, isFullscreen, isAlwaysOnTop, minimize, toggleMaximize, toggleAlwaysOnTop, close } = useWindowControls(); +// The always-on-top control is opt-in (外观 → 工具栏): the right side of the +// toolbar is the most crowded strip in the app. It stays visible while the +// window is actually pinned even with the setting off, so turning the setting +// off can never leave the user with a pinned window and no way to unpin it. +const showAlwaysOnTopButton = computed(() => isDesktop && (toolbarItems.value.alwaysOnTop || isAlwaysOnTop.value)); const updateTooltip = computed(() => { if (props.hasUpdateAvailable && props.updateReady) return t("updates.restartRequiredTooltip"); if (props.hasUpdateAvailable && props.updateReadyToInstall) return t("updates.downloadedReady", { version: props.updateVersion ?? "" }); @@ -645,7 +650,7 @@ const toolbarStyle = computed(() => { - + ` }, +})); +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: { name: "TooltipStub", template: `` }, + TooltipTrigger: { name: "TooltipTriggerStub", template: `` }, + TooltipContent: { name: "TooltipContentStub", template: `` }, +})); +vi.mock("@/components/ui/LightDropdown.vue", () => ({ + default: { name: "LightDropdownStub", template: `
` }, +})); +vi.mock("@/components/layout/WindowControls.vue", () => ({ + default: { name: "WindowControlsStub", template: `
` }, +})); +vi.mock("@/components/export/ExportProgressPopover.vue", () => ({ + default: { name: "ExportProgressPopoverStub", template: `
` }, +})); +vi.mock("@/components/layout/ToolbarUpdateIcon.vue", () => ({ + default: { name: "ToolbarUpdateIconStub", template: `` }, +})); + +import AppToolbar from "../AppToolbar.vue"; +import DetachedTabHeader from "../DetachedTabHeader.vue"; +import { useSettingsStore } from "@/stores/settingsStore"; + +const PIN_ON_LABEL = "pin-window"; +const PIN_OFF_LABEL = "unpin-window"; +const ALWAYS_ON_TOP_EVENT = "dbx:window-always-on-top-changed"; + +let pinia: ReturnType; + +function mount(component: Component, props: Record): { host: HTMLDivElement; unmount: () => void } { + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp(component, props); + app.use(pinia); + app.use( + createI18n({ + legacy: false, + locale: "en", + missingWarn: false, + fallbackWarn: false, + messages: { en: { toolbar: { alwaysOnTop: PIN_ON_LABEL, alwaysOnTopOff: PIN_OFF_LABEL } } }, + }), + ); + app.mount(host); + return { + host, + unmount: () => { + app.unmount(); + host.remove(); + }, + }; +} + +/** The window state sync runs after the component mounts, so wait for it before touching the DOM. */ +async function settled(): Promise { + await vi.waitFor(() => { + expect(listeners.some((listener) => listener.event === ALWAYS_ON_TOP_EVENT)).toBe(true); + }); +} + +function pinButton(host: HTMLElement, label: string = PIN_ON_LABEL): Element | null { + return host.querySelector(`[aria-label="${label}"]`); +} + +function clickPin(host: HTMLElement, label: string = PIN_ON_LABEL): void { + const button = pinButton(host, label); + expect(button).not.toBeNull(); + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); +} + +const toolbarProps = { + isDark: false, + themeMode: "light", + showAiPanel: false, + activeAiRunCount: 0, + awaitingAiRunCount: 0, + showHistory: false, + showSqlLibrary: false, + sqlLibrarySaveFeedbackId: 0, + showSqlFilePanel: false, + showDriverStore: false, + showPluginCenter: false, + showSettingsPage: false, + checkingUpdates: false, + hasUpdateAvailable: false, + isDownloadingUpdate: false, + downloadProgress: null, + updateReadyToInstall: false, + updateReady: false, + agentDriverUpdateCount: 0, + hasMcpUpdateAvailable: false, + hasConnections: false, + canNewQuery: false, + hasSqlFileConnections: false, +}; + +describe("always-on-top button visibility", () => { + beforeEach(() => { + document.body.innerHTML = ""; + listeners.length = 0; + windowState.label = "main"; + windowState.alwaysOnTop = false; + pinia = createPinia(); + setActivePinia(pinia); + }); + + it("hides the main toolbar pin control until the appearance setting opts in", async () => { + const settingsStore = useSettingsStore(); + const { host, unmount } = mount(AppToolbar, toolbarProps); + await settled(); + + expect(pinButton(host)).toBeNull(); + + settingsStore.editorSettings.toolbarItems.alwaysOnTop = true; + await nextTick(); + + expect(pinButton(host)).not.toBeNull(); + expect(pinButton(host, PIN_OFF_LABEL)).toBeNull(); + + unmount(); + }); + + it("hides the detached window pin control until the appearance setting opts in", async () => { + const settingsStore = useSettingsStore(); + const { host, unmount } = mount(DetachedTabHeader, { title: "SQL" }); + await settled(); + + expect(pinButton(host)).toBeNull(); + + settingsStore.editorSettings.toolbarItems.alwaysOnTop = true; + await nextTick(); + + expect(pinButton(host)).not.toBeNull(); + + unmount(); + }); + + it("keeps the pin control reachable while the window is pinned so it can be unpinned again", async () => { + const settingsStore = useSettingsStore(); + const { host, unmount } = mount(AppToolbar, toolbarProps); + await settled(); + + settingsStore.editorSettings.toolbarItems.alwaysOnTop = true; + await nextTick(); + expect(pinButton(host)).not.toBeNull(); + + clickPin(host); + await vi.waitFor(() => expect(windowState.alwaysOnTop).toBe(true)); + await vi.waitFor(() => expect(pinButton(host, PIN_OFF_LABEL)).not.toBeNull()); + + // Turning the setting off while the window is pinned must not strand the + // user with a pinned window and no control left to undo it. + settingsStore.editorSettings.toolbarItems.alwaysOnTop = false; + await nextTick(); + expect(pinButton(host, PIN_OFF_LABEL)).not.toBeNull(); + + clickPin(host, PIN_OFF_LABEL); + await vi.waitFor(() => expect(windowState.alwaysOnTop).toBe(false)); + await vi.waitFor(() => expect(pinButton(host, PIN_OFF_LABEL)).toBeNull()); + + unmount(); + }); +}); diff --git a/apps/desktop/src/components/layout/__tests__/AlwaysOnTopVisibilitySync.spec.ts b/apps/desktop/src/components/layout/__tests__/AlwaysOnTopVisibilitySync.spec.ts new file mode 100644 index 0000000000..c4e1104158 --- /dev/null +++ b/apps/desktop/src/components/layout/__tests__/AlwaysOnTopVisibilitySync.spec.ts @@ -0,0 +1,321 @@ +// @vitest-environment happy-dom +import { createApp, nextTick } from "vue"; +import { createPinia, disposePinia } from "pinia"; +import { createI18n } from "vue-i18n"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const runtime = vi.hoisted(() => ({ desktop: true, current: { label: "main", alwaysOnTop: false } })); +const eventBus = vi.hoisted(() => ({ + listeners: new Set<{ event: string; windowLabel: string; handler: (event: { payload: unknown }) => void; unlisten: ReturnType }>(), + deferVisibility: false, + completeVisibility: null as (() => void) | null, + registrationError: null as Error | null, +})); + +vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => runtime.desktop })); +vi.mock("@/lib/backend/api", () => ({ loadEditorSettings: vi.fn(), saveEditorSettings: vi.fn() })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(), emit: vi.fn() })); +vi.mock("@tauri-apps/api/window", () => ({ + getCurrentWindow: () => { + const state = runtime.current; + return { + label: state.label, + isMaximized: async () => false, + isFullscreen: async () => false, + isAlwaysOnTop: async () => state.alwaysOnTop, + setAlwaysOnTop: async (value: boolean) => { + state.alwaysOnTop = value; + }, + onResized: async () => () => {}, + onFocusChanged: async () => () => {}, + }; + }, +})); + +import { emit, listen } from "@tauri-apps/api/event"; +import * as api from "@/lib/backend/api"; +import { ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT } from "@/lib/app/windowAlwaysOnTop"; +import { normalizeEditorSettings, useSettingsStore, type EditorSettings } from "@/stores/settingsStore"; +import DetachedTabHeader from "../DetachedTabHeader.vue"; + +const PIN_ON_LABEL = "pin-window"; +const PIN_OFF_LABEL = "unpin-window"; +const cleanup: Array<() => void> = []; +let persistedSettings: EditorSettings; + +function createStore() { + const pinia = createPinia(); + cleanup.push(() => disposePinia(pinia)); + return { pinia, store: useSettingsStore(pinia) }; +} + +async function mountDetached(label: string, alwaysOnTop = false) { + const { pinia, store } = createStore(); + await store.initEditorSettings(); + const state = { label, alwaysOnTop }; + runtime.current = state; + const host = document.createElement("div"); + document.body.appendChild(host); + const app = createApp(DetachedTabHeader, { title: label }); + app.use(pinia); + app.use( + createI18n({ + legacy: false, + locale: "en", + missingWarn: false, + fallbackWarn: false, + messages: { en: { toolbar: { alwaysOnTop: PIN_ON_LABEL, alwaysOnTopOff: PIN_OFF_LABEL } } }, + }), + ); + app.mount(host); + let mounted = true; + const unmount = () => { + if (!mounted) return; + mounted = false; + app.unmount(); + host.remove(); + }; + cleanup.push(unmount); + if (runtime.desktop) { + await vi.waitFor(() => expect([...eventBus.listeners].some((listener) => listener.windowLabel === label && listener.event === WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT)).toBe(true)); + } + return { host, store, state, unmount }; +} + +function pinButton(host: HTMLElement, label = PIN_ON_LABEL) { + return host.querySelector(`[aria-label="${label}"]`); +} + +function toolbarPatch(store: ReturnType, visible: boolean) { + return { toolbarItems: { ...store.editorSettings.toolbarItems, alwaysOnTop: visible } }; +} + +describe("always-on-top toolbar visibility across windows", () => { + beforeEach(() => { + vi.resetAllMocks(); + runtime.desktop = true; + runtime.current = { label: "main", alwaysOnTop: false }; + eventBus.listeners.clear(); + eventBus.deferVisibility = false; + eventBus.completeVisibility = null; + eventBus.registrationError = null; + persistedSettings = normalizeEditorSettings({}); + vi.mocked(api.loadEditorSettings).mockImplementation(async () => structuredClone(persistedSettings)); + vi.mocked(api.saveEditorSettings).mockImplementation(async (settings) => { + persistedSettings = structuredClone(settings) as EditorSettings; + }); + vi.mocked(listen).mockImplementation(async (event, handler) => { + if (event === ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT && eventBus.registrationError) throw eventBus.registrationError; + const entry = { event, windowLabel: runtime.current.label, handler: ({ payload }: { payload: unknown }) => handler({ event, id: 0, payload }), unlisten: vi.fn() }; + entry.unlisten.mockImplementation(() => eventBus.listeners.delete(entry)); + eventBus.listeners.add(entry); + if (event === ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT && eventBus.deferVisibility) { + await new Promise((resolve) => { + eventBus.completeVisibility = resolve; + }); + } + return entry.unlisten; + }); + vi.mocked(emit).mockImplementation(async (event, payload) => { + for (const listener of eventBus.listeners) { + if (listener.event === event) listener.handler({ payload }); + } + }); + }); + + afterEach(() => { + for (const dispose of cleanup.splice(0).reverse()) dispose(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("enables and disables already-open independent stores without replacing other settings or echoing", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + const first = await mountDetached("detached-tab-first"); + first.store.editorSettings.fontSize = 27; + first.store.editorSettings.toolbarItems.github = false; + expect(first.store).not.toBe(main); + expect(pinButton(first.host)).toBeNull(); + + main.updateEditorSettings(toolbarPatch(main, true)); + expect(first.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + await vi.waitFor(() => expect(pinButton(first.host)).not.toBeNull()); + const second = await mountDetached("detached-tab-second"); + expect(second.store).not.toBe(first.store); + expect(pinButton(second.host)).not.toBeNull(); + expect(api.saveEditorSettings).toHaveBeenCalledTimes(1); + + main.updateEditorSettings(toolbarPatch(main, false)); + await vi.waitFor(() => { + expect(pinButton(first.host)).toBeNull(); + expect(pinButton(second.host)).toBeNull(); + }); + expect(first.store.editorSettings.fontSize).toBe(27); + expect(first.store.editorSettings.toolbarItems.github).toBe(false); + expect(first.state.alwaysOnTop).toBe(false); + expect(second.state.alwaysOnTop).toBe(false); + expect(vi.mocked(emit).mock.calls).toEqual([ + [ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, true], + [ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, false], + ]); + expect(api.saveEditorSettings).toHaveBeenCalledTimes(2); + expect(api.loadEditorSettings).toHaveBeenCalledTimes(3); + }); + + it("does not rebroadcast a received value when saving an unrelated setting", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + const detached = await mountDetached("detached-tab-save"); + await main.updateEditorSettingsAndPersist(toolbarPatch(main, true)); + await detached.store.updateEditorSettingsAndPersist({ fontSize: 23 }); + expect(emit).toHaveBeenCalledTimes(1); + expect(api.saveEditorSettings).toHaveBeenCalledTimes(2); + expect(main.editorSettings.fontSize).not.toBe(23); + }); + + it("keeps a pinned detached window's escape hatch until it is unpinned", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + await main.updateEditorSettingsAndPersist(toolbarPatch(main, true)); + const detached = await mountDetached("detached-tab-pinned", true); + await main.updateEditorSettingsAndPersist(toolbarPatch(main, false)); + await nextTick(); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + const unpin = pinButton(detached.host, PIN_OFF_LABEL); + expect(unpin).not.toBeNull(); + unpin?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await vi.waitFor(() => { + expect(detached.state.alwaysOnTop).toBe(false); + expect(pinButton(detached.host, PIN_OFF_LABEL)).toBeNull(); + expect(pinButton(detached.host)).toBeNull(); + }); + }); + + it("publishes saved snapshots in order rather than the current unsaved toggle", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + const detached = await mountDetached("detached-tab-queued"); + let completeSave!: () => void; + vi.mocked(api.saveEditorSettings).mockImplementationOnce( + () => + new Promise((resolve) => { + completeSave = resolve; + }), + ); + main.updateEditorSettings(toolbarPatch(main, true)); + main.updateEditorSettings(toolbarPatch(main, false)); + expect(emit).not.toHaveBeenCalled(); + expect(pinButton(detached.host)).toBeNull(); + completeSave(); + await vi.waitFor(() => expect(emit).toHaveBeenCalledTimes(2)); + expect(vi.mocked(emit).mock.calls.map((call) => call[1])).toEqual([true, false]); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + expect(main.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + }); + + it("does not publish a failed optimistic save and propagates a successful retry", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + const detached = await mountDetached("detached-tab-retry"); + vi.mocked(api.saveEditorSettings).mockRejectedValueOnce(new Error("disk full")); + main.updateEditorSettings(toolbarPatch(main, true)); + await nextTick(); + expect(main.editorSettings.toolbarItems.alwaysOnTop).toBe(true); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + expect(emit).not.toHaveBeenCalled(); + await main.persistEditorSettings(); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(true); + expect(emit).toHaveBeenCalledExactlyOnceWith(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, true); + }); + + it.each([false, true])("retains atomic rollback and no notification on persistence failure from %s", async (initial) => { + persistedSettings.toolbarItems.alwaysOnTop = initial; + const { store: main } = createStore(); + await main.initEditorSettings(); + const detached = await mountDetached("detached-tab-failed"); + vi.mocked(api.saveEditorSettings).mockRejectedValueOnce(new Error("disk full")); + await expect(main.updateEditorSettingsAndPersist(toolbarPatch(main, !initial))).rejects.toThrow("disk full"); + expect(main.editorSettings.toolbarItems.alwaysOnTop).toBe(initial); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(initial); + expect(persistedSettings.toolbarItems.alwaysOnTop).toBe(initial); + expect(emit).not.toHaveBeenCalled(); + await main.persistEditorSettings(); + expect(emit).not.toHaveBeenCalled(); + }); + + it("does not roll back a successful save when event delivery fails", async () => { + const { store: main } = createStore(); + await main.initEditorSettings(); + const detached = await mountDetached("detached-tab-delivery"); + const error = new Error("event unavailable"); + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(emit).mockRejectedValueOnce(error); + await expect(main.updateEditorSettingsAndPersist(toolbarPatch(main, true))).resolves.toBeUndefined(); + expect(persistedSettings.toolbarItems.alwaysOnTop).toBe(true); + expect(main.editorSettings.toolbarItems.alwaysOnTop).toBe(true); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + expect(log).toHaveBeenCalledWith("[DBX][window:always-on-top-toolbar-visibility-event]", error); + await main.updateEditorSettingsAndPersist(toolbarPatch(main, false)); + expect(emit).toHaveBeenLastCalledWith(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, false); + }); + + it("ignores nonboolean event payloads without persisting", async () => { + const detached = await mountDetached("detached-tab-validation"); + for (const payload of [null, "true", 1, { visible: true }, { toolbarItems: { alwaysOnTop: true } }]) { + await emit(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, payload); + } + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + expect(api.saveEditorSettings).not.toHaveBeenCalled(); + }); + + it("removes the listener on unmount and ignores stale callbacks", async () => { + const detached = await mountDetached("detached-tab-cleanup"); + const listener = [...eventBus.listeners].find((entry) => entry.event === ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT)!; + detached.unmount(); + expect(listener.unlisten).toHaveBeenCalledTimes(1); + expect(eventBus.listeners.size).toBe(0); + listener.handler({ payload: true }); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + expect(api.saveEditorSettings).not.toHaveBeenCalled(); + }); + + it("cleans up registration that completes after unmount", async () => { + eventBus.deferVisibility = true; + const detached = await mountDetached("detached-tab-late-cleanup"); + const listener = [...eventBus.listeners].find((entry) => entry.event === ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT)!; + detached.unmount(); + listener.handler({ payload: true }); + expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(false); + eventBus.completeVisibility!(); + await vi.waitFor(() => expect(listener.unlisten).toHaveBeenCalledTimes(1)); + expect(eventBus.listeners.size).toBe(0); + }); + + it("handles listener registration failure without breaking the header", async () => { + eventBus.registrationError = new Error("listener unavailable"); + const log = vi.spyOn(console, "error").mockImplementation(() => {}); + const detached = await mountDetached("detached-tab-listen-failure"); + expect(pinButton(detached.host)).toBeNull(); + expect(log).toHaveBeenCalledWith("[DBX][window:always-on-top-toolbar-visibility-listen]", eventBus.registrationError); + }); + + it("saves queued pre-initialization changes and publishes only after initialization", async () => { + const detached = await mountDetached("detached-tab-startup"); + const { store: main } = createStore(); + main.updateEditorSettings(toolbarPatch(main, true)); + expect(emit).not.toHaveBeenCalled(); + await main.initEditorSettings(); + await vi.waitFor(() => expect(detached.store.editorSettings.toolbarItems.alwaysOnTop).toBe(true)); + expect(emit).toHaveBeenCalledExactlyOnceWith(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, true); + }); + + it("does not register or emit native events in the browser", async () => { + runtime.desktop = false; + const detached = await mountDetached("detached-tab-web"); + await detached.store.updateEditorSettingsAndPersist(toolbarPatch(detached.store, true)); + expect(persistedSettings.toolbarItems.alwaysOnTop).toBe(true); + expect(listen).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/lib/app/windowAlwaysOnTop.ts b/apps/desktop/src/lib/app/windowAlwaysOnTop.ts index a3fb44067f..2aea94206c 100644 --- a/apps/desktop/src/lib/app/windowAlwaysOnTop.ts +++ b/apps/desktop/src/lib/app/windowAlwaysOnTop.ts @@ -3,6 +3,17 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; const DETACHED_WINDOW_LABEL_PREFIX = "detached-tab-"; export const WINDOW_ALWAYS_ON_TOP_CHANGED_EVENT = "dbx:window-always-on-top-changed"; +export const ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT = "dbx:always-on-top-toolbar-visibility-changed"; + +export async function emitAlwaysOnTopToolbarVisibilityChanged(visible: boolean): Promise { + if (!isTauriRuntime()) return; + try { + const { emit } = await import("@tauri-apps/api/event"); + await emit(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, visible); + } catch (error) { + console.error("[DBX][window:always-on-top-toolbar-visibility-event]", error); + } +} export type WindowAlwaysOnTopChangedPayload = { windowLabel: string; diff --git a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts index c32d86276a..51c557e693 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsSearch.spec.ts @@ -8,6 +8,7 @@ import { resolveSettingsCategory, resolveSettingsSearchEntries, searchSettings, + visibleToolbarVisibilityItems, type SettingsCategory, type SettingsSearchDefinition, } from "@/lib/settings/settingsSearch"; @@ -233,6 +234,20 @@ describe("settings search", () => { expect(definitions.map((definition) => definition.id)).toEqual(TOOLBAR_VISIBILITY_ITEMS.map((item) => `appearance-toolbar-${item.key}`)); expect(definitions).toContainEqual({ id: "appearance-toolbar-dataTransfer", category: "appearance", titleKey: "transfer.dataTransfer", targetId: "appearance" }); expect(definitions).toContainEqual({ id: "appearance-toolbar-ai", category: "appearance", title: "AI", targetId: "appearance" }); + expect(definitions).toContainEqual({ id: "appearance-toolbar-alwaysOnTop", category: "appearance", titleKey: "toolbar.alwaysOnTop", targetId: "appearance", visible: expect.any(Function) }); + }); + + it("keeps the desktop-only toolbar switch and its search entry out of the Web build", () => { + const desktopKeys = visibleToolbarVisibilityItems(TOOLBAR_VISIBILITY_ITEMS, false).map((item) => item.key); + const webKeys = visibleToolbarVisibilityItems(TOOLBAR_VISIBILITY_ITEMS, true).map((item) => item.key); + expect(desktopKeys).toEqual(TOOLBAR_VISIBILITY_ITEMS.map((item) => item.key)); + expect(webKeys).not.toContain("alwaysOnTop"); + expect(webKeys).toContain("theme"); + + const definitions = createToolbarVisibilitySettingsSearchDefinitions(); + const entryIds = (isWeb: boolean) => resolveSettingsSearchEntries(definitions, { isWeb, visibleCategories: new Set(["appearance"]) }, (key) => key, categoryLabels).map((entry) => entry.id); + expect(entryIds(false)).toContain("appearance-toolbar-alwaysOnTop"); + expect(entryIds(true)).not.toContain("appearance-toolbar-alwaysOnTop"); }); it("indexes the existing descriptions for fixed appearance controls", () => { diff --git a/apps/desktop/src/lib/settings/settingsSearch.ts b/apps/desktop/src/lib/settings/settingsSearch.ts index 1328123a68..e8599d2ba2 100644 --- a/apps/desktop/src/lib/settings/settingsSearch.ts +++ b/apps/desktop/src/lib/settings/settingsSearch.ts @@ -50,9 +50,12 @@ export interface SettingsSearchRoute { export type Translate = (key: string) => string; -type ToolbarVisibilityItemKey = "dataTransfer" | "driverManager" | "pluginCenter" | "sqlFile" | "schemaDiff" | "dataCompare" | "checkUpdates" | "sqlLibrary" | "sqlFileTree" | "history" | "ai" | "theme" | "github"; +type ToolbarVisibilityItemKey = "dataTransfer" | "driverManager" | "pluginCenter" | "sqlFile" | "schemaDiff" | "dataCompare" | "checkUpdates" | "sqlLibrary" | "sqlFileTree" | "history" | "ai" | "theme" | "github" | "alwaysOnTop"; -export type ToolbarVisibilityItem = { key: ToolbarVisibilityItemKey; titleKey: string; title?: never } | { key: ToolbarVisibilityItemKey; title: string; titleKey?: never }; +export type ToolbarVisibilityItem = ({ key: ToolbarVisibilityItemKey; titleKey: string; title?: never } | { key: ToolbarVisibilityItemKey; title: string; titleKey?: never }) & { + /** The matching toolbar button can only exist in the desktop app, so the Web build hides the switch. */ + desktopOnly?: true; +}; /** * The toolbar visibility controls and their search entries use this same list. @@ -73,18 +76,25 @@ export const TOOLBAR_VISIBILITY_ITEMS: readonly ToolbarVisibilityItem[] = [ { key: "ai", title: "AI" }, { key: "theme", titleKey: "toolbar.theme" }, { key: "github", title: "GitHub" }, + { key: "alwaysOnTop", titleKey: "toolbar.alwaysOnTop", desktopOnly: true }, ]; export function toolbarVisibilityItemLabel(item: ToolbarVisibilityItem, translate: Translate): string { return item.titleKey ? translate(item.titleKey) : (item.title ?? ""); } +/** Desktop-only toolbar switches are hidden in the Web build, where the matching button can never render. */ +export function visibleToolbarVisibilityItems(items: readonly ToolbarVisibilityItem[], isWeb: boolean): ToolbarVisibilityItem[] { + return items.filter((item) => !item.desktopOnly || !isWeb); +} + export function createToolbarVisibilitySettingsSearchDefinitions(items: readonly ToolbarVisibilityItem[] = TOOLBAR_VISIBILITY_ITEMS): SettingsSearchDefinition[] { return items.map((item) => ({ id: `appearance-toolbar-${item.key}`, category: "appearance", ...(item.titleKey ? { titleKey: item.titleKey } : { title: item.title }), targetId: "appearance", + ...(item.desktopOnly ? { visible: desktopOnly } : {}), })); } diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 76c0382554..fc9e2af51a 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -526,9 +526,19 @@ describe("normalizeEditorSettings", () => { expect(settings.toolbarItems.sqlFileTree).toBe(false); expect(settings.toolbarItems.history).toBe(false); expect(settings.toolbarItems.sqlLibrary).toBe(true); + expect(settings.toolbarItems.alwaysOnTop).toBe(false); expect(settings.toolbarItems.exclusiveRightSidebarPanels).toBe(true); }); + it("keeps the always-on-top toolbar button hidden unless it is opted into", () => { + expect(DEFAULT_EDITOR_SETTINGS.toolbarItems.alwaysOnTop).toBe(false); + expect(normalizeEditorSettings({}).toolbarItems.alwaysOnTop).toBe(false); + expect(normalizeEditorSettings({ toolbarItems: { alwaysOnTop: true } }).toolbarItems.alwaysOnTop).toBe(true); + // Anything that is not a boolean opt-in must fall back to hidden, so restored + // drafts from before the setting existed cannot turn the button on. + expect(normalizeEditorSettings({ toolbarItems: { alwaysOnTop: "yes" } } as any).toolbarItems.alwaysOnTop).toBe(false); + }); + it("preserves disabled right sidebar panel exclusivity", () => { expect( normalizeEditorSettings({ diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index fc5d2f0ea8..0debd0a03c 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -2,6 +2,7 @@ import { defineStore } from "pinia"; import { computed, ref } from "vue"; import { aiConfigToItem, generateId, getConfigKey } from "@/lib/ai/aiConfigList"; import { DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; +import { emitAlwaysOnTopToolbarVisibilityChanged } from "@/lib/app/windowAlwaysOnTop"; import { defaultBackgroundImageSettings, normalizeBackgroundImageSettings, type BackgroundImageSettings } from "@/lib/app/appBackgroundImage"; import * as api from "@/lib/backend/api"; import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; @@ -967,6 +968,10 @@ export interface ToolbarItems { ai: boolean; theme: boolean; github: boolean; + /** Always-on-top window control. Off by default: the toolbar's right side is + * the most crowded strip in the app and keeping a window above every other + * application is not a day-to-day action, so the button is opt-in. */ + alwaysOnTop: boolean; exclusiveRightSidebarPanels: boolean; } @@ -984,6 +989,7 @@ export const DEFAULT_TOOLBAR_ITEMS: ToolbarItems = { ai: true, theme: true, github: true, + alwaysOnTop: false, exclusiveRightSidebarPanels: true, }; @@ -1506,6 +1512,10 @@ function normalizeToolbarItems(items: Partial | undefined): Toolba ai: items.ai ?? defaults.ai, theme: items.theme ?? defaults.theme, github: items.github ?? defaults.github, + // Unlike the entries above, a newly added toolbar button stays hidden until + // the user asks for it, so upgrading never adds another control to the + // crowded right side of the toolbar. + alwaysOnTop: items.alwaysOnTop === true, // Saved settings from before right-sidebar exclusivity must adopt the new default. exclusiveRightSidebarPanels: items.exclusiveRightSidebarPanels !== false, }; @@ -1901,6 +1911,12 @@ export const useSettingsStore = defineStore("settings", () => { let aiChatSelectionSaveRunning = false; const editorSettings = ref(normalizeEditorSettings({})); + let persistedAlwaysOnTopToolbarVisibility = editorSettings.value.toolbarItems.alwaysOnTop; + + function syncAlwaysOnTopToolbarVisibility(visible: boolean) { + editorSettings.value.toolbarItems.alwaysOnTop = visible; + persistedAlwaysOnTopToolbarVisibility = visible; + } function enqueueEditorSettingsOperation(operation: () => Promise): Promise { const queuedOperation = editorSettingsOperationQueue ? editorSettingsOperationQueue.then(operation) : operation(); @@ -1915,8 +1931,14 @@ export const useSettingsStore = defineStore("settings", () => { return queuedOperation; } - function persistCurrentEditorSettings(): Promise { - return api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value)); + async function persistCurrentEditorSettings(): Promise { + const snapshot = editorSettingsSnapshot(editorSettings.value); + await api.saveEditorSettings(snapshot); + const visible = snapshot.toolbarItems.alwaysOnTop; + if (visible !== persistedAlwaysOnTopToolbarVisibility) { + persistedAlwaysOnTopToolbarVisibility = visible; + await emitAlwaysOnTopToolbarVisibilityChanged(visible); + } } function enqueueEditorSettingsSave(): Promise { @@ -1973,6 +1995,7 @@ export const useSettingsStore = defineStore("settings", () => { normalized.sidebarBrowseObjectsOnDatabaseActivationMigrationVersion = SIDEBAR_BROWSE_OBJECTS_MIGRATION_VERSION; } editorSettings.value = normalized; + persistedAlwaysOnTopToolbarVisibility = normalized.toolbarItems.alwaysOnTop; const needsExecuteModeDefaultMigration = typeof savedSettings.executeModeDefaultVersion !== "number" || savedSettings.executeModeDefaultVersion < EXECUTE_MODE_CURRENT_DEFAULT_VERSION; const needsTabNavigationShortcutMigration = needsTabNavigationHistoryShortcutMigration(savedSettings.shortcuts); const savedNullText = (savedSettings.dataGridExtractorOptions as Partial | undefined)?.dsv?.nullText; @@ -1989,6 +2012,7 @@ export const useSettingsStore = defineStore("settings", () => { const legacy = loadLegacyEditorSettings(); if (legacy) { editorSettings.value = legacy; + persistedAlwaysOnTopToolbarVisibility = legacy.toolbarItems.alwaysOnTop; try { await enqueueEditorSettingsSave(); // Existing desktop users keep settings in localStorage; remove them only @@ -2784,6 +2808,7 @@ export const useSettingsStore = defineStore("settings", () => { desktopSettings, mcpGlobalPolicy, initEditorSettings, + syncAlwaysOnTopToolbarVisibility, updateEditorSettings, updateEditorSettingsAndPersist, persistEditorSettings,