fix(toolbar): make the always-on-top button opt-in

This commit is contained in:
zipg authored and GitHub committed 2026-09-23 10:42:58 +08:00
1 parent 8b822e974b
commit c6d31c0577
10 files changed
+645 -6

No files matched your search

@@ -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<UpdateDownloadSource>(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);
}
@@ -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(() => {
</Tooltip>
</template>
<Tooltip v-if="isDesktop">
<Tooltip v-if="showAlwaysOnTopButton">
<TooltipTrigger as-child>
<Button
variant="ghost"
@@ -1,7 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { ArrowLeft, Minus, Square, Copy, X, Grip, Pin, PinOff } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { useWindowControls } from "@/composables/useWindowControls";
import { useSettingsStore } from "@/stores/settingsStore";
import { ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT } from "@/lib/app/windowAlwaysOnTop";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
defineProps<{
title: string;
@@ -18,6 +22,33 @@ const emit = defineEmits<{
const { t } = useI18n();
const { isMac, isMaximized, isAlwaysOnTop, minimize, toggleMaximize, toggleAlwaysOnTop } = useWindowControls();
const settingsStore = useSettingsStore();
// Mirrors the main toolbar: the pin control is opt-in, and it keeps showing
// whenever this window is pinned so the user can always unpin it again.
const showAlwaysOnTopButton = computed(() => settingsStore.editorSettings.toolbarItems.alwaysOnTop || isAlwaysOnTop.value);
let unlistenToolbarVisibility: (() => void) | null = null;
let unmounted = false;
onMounted(async () => {
if (!isTauriRuntime()) return;
try {
const { listen } = await import("@tauri-apps/api/event");
if (unmounted) return;
const unlisten = await listen<boolean>(ALWAYS_ON_TOP_TOOLBAR_VISIBILITY_CHANGED_EVENT, ({ payload }) => {
if (!unmounted && typeof payload === "boolean") settingsStore.syncAlwaysOnTopToolbarVisibility(payload);
});
if (unmounted) unlisten();
else unlistenToolbarVisibility = unlisten;
} catch (error) {
console.error("[DBX][window:always-on-top-toolbar-visibility-listen]", error);
}
});
onUnmounted(() => {
unmounted = true;
unlistenToolbarVisibility?.();
});
let dragging = false;
@@ -74,6 +105,7 @@ async function handleDragEnd(event: PointerEvent) {
<span>{{ t("tabs.returnToMainWindow") }}</span>
</button>
<button
v-if="showAlwaysOnTopButton"
type="button"
class="mr-1 inline-flex h-7 w-7 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
:class="{ 'bg-accent text-foreground': isAlwaysOnTop }"
@@ -0,0 +1,208 @@
// @vitest-environment happy-dom
import { createApp, nextTick, type Component } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { createI18n } from "vue-i18n";
import { beforeEach, describe, expect, it, vi } from "vitest";
const windowState = vi.hoisted(() => ({ label: "main", alwaysOnTop: false }));
const listeners = vi.hoisted(() => [] as Array<{ event: string; unlisten: () => void }>);
vi.mock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => true }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => undefined) }));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (event: string) => {
const entry = { event, unlisten: () => {} };
entry.unlisten = () => {
const index = listeners.indexOf(entry);
if (index >= 0) listeners.splice(index, 1);
};
listeners.push(entry);
return entry.unlisten;
}),
emit: vi.fn(async () => undefined),
}));
vi.mock("@tauri-apps/api/webviewWindow", () => ({
getAllWebviewWindows: vi.fn(async () => []),
WebviewWindow: { getByLabel: vi.fn(async () => null) },
}));
vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: () => ({
label: windowState.label,
isMaximized: async () => false,
isFullscreen: async () => false,
isAlwaysOnTop: async () => windowState.alwaysOnTop,
setAlwaysOnTop: async (value: boolean) => {
windowState.alwaysOnTop = value;
},
onResized: async () => () => {},
onFocusChanged: async () => () => {},
minimize: async () => {},
toggleMaximize: async () => {},
}),
}));
vi.mock("@/components/ui/button", () => ({
Button: { name: "ButtonStub", template: `<button><slot /></button>` },
}));
vi.mock("@/components/ui/tooltip", () => ({
Tooltip: { name: "TooltipStub", template: `<span><slot /></span>` },
TooltipTrigger: { name: "TooltipTriggerStub", template: `<span><slot /></span>` },
TooltipContent: { name: "TooltipContentStub", template: `<span><slot /></span>` },
}));
vi.mock("@/components/ui/LightDropdown.vue", () => ({
default: { name: "LightDropdownStub", template: `<div />` },
}));
vi.mock("@/components/layout/WindowControls.vue", () => ({
default: { name: "WindowControlsStub", template: `<div />` },
}));
vi.mock("@/components/export/ExportProgressPopover.vue", () => ({
default: { name: "ExportProgressPopoverStub", template: `<div />` },
}));
vi.mock("@/components/layout/ToolbarUpdateIcon.vue", () => ({
default: { name: "ToolbarUpdateIconStub", template: `<span />` },
}));
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<typeof createPinia>;
function mount(component: Component, props: Record<string, unknown>): { 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<void> {
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();
});
});
@@ -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<typeof vi.fn> }>(),
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<typeof useSettingsStore>, 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<void>((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<void>((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();
});
});
@@ -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<void> {
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;
@@ -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<SettingsCategory>(["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", () => {
@@ -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 } : {}),
}));
}
+10
View File
@@ -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({
+27 -2
View File
@@ -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<ToolbarItems> | 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<EditorSettings>(normalizeEditorSettings({}));
let persistedAlwaysOnTopToolbarVisibility = editorSettings.value.toolbarItems.alwaysOnTop;
function syncAlwaysOnTopToolbarVisibility(visible: boolean) {
editorSettings.value.toolbarItems.alwaysOnTop = visible;
persistedAlwaysOnTopToolbarVisibility = visible;
}
function enqueueEditorSettingsOperation<T>(operation: () => Promise<T>): Promise<T> {
const queuedOperation = editorSettingsOperationQueue ? editorSettingsOperationQueue.then(operation) : operation();
@@ -1915,8 +1931,14 @@ export const useSettingsStore = defineStore("settings", () => {
return queuedOperation;
}
function persistCurrentEditorSettings(): Promise<void> {
return api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
async function persistCurrentEditorSettings(): Promise<void> {
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<void> {
@@ -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<DataGridExtractorOptions> | 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,