fix(toolbar): make the always-on-top button opt-in
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
+321
@@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user