feat(settings): preview locale and serialize UI scale application

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-09-09 12:04:54 +08:00
1 parent 004b27a3c2
commit a0cce8cc8f
9 files changed
+284 -20

No files matched your search

+12 -5
View File
@@ -120,6 +120,7 @@ import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID
import { DATA_GRID_TYPE_COLOR_KEYS, dataGridTypeColorCssVar, resolveActiveDataGridTypeColors } from "@/lib/dataGrid/dataGridTypeColorScheme";
import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory";
import { useUiFontFamilyPreview } from "@/composables/useUiFontFamilyPreview";
import { createUiScaleApplyQueue } from "@/lib/app/uiScaleApplyQueue";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { savedSqlDefaultTargetForWrite } from "@/lib/savedSql/savedSqlExecutionTarget";
import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard";
@@ -1078,15 +1079,21 @@ const saveSqlFolders = computed(() => {
}));
});
async function applyUiScale(scale: number) {
if (!isDesktop) return;
try {
const uiScaleApplyQueue = createUiScaleApplyQueue(
async (scale) => {
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
await getCurrentWebview().setZoom(scale);
},
(scale) => {
window.dispatchEvent(new CustomEvent("dbx:ui-scale-applied", { detail: { scale } }));
} catch (error) {
},
(scale, error) => {
console.warn("[DBX] Failed to apply UI scale", { scale, error });
}
},
);
function applyUiScale(scale: number) {
if (isDesktop) uiScaleApplyQueue.request(scale);
}
function setGlobalUiScale(scale: number) {
@@ -217,7 +217,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useTunnelProfileStore } from "@/stores/tunnelProfileStore";
import { currentLocale, setLocale, type Locale } from "@/i18n";
import { currentLocale, previewLocale, restoreLocalePreview, setLocale, type Locale } from "@/i18n";
import { SETTINGS_SEARCH_DEFINITIONS, TOOLBAR_VISIBILITY_ITEMS, createShortcutSettingsSearchDefinitions, resolveSettingsSearchEntries, searchSettings, toolbarVisibilityItemLabel, type SettingsCategory, type SettingsSearchEntry, type ToolbarVisibilityItem } from "@/lib/settings/settingsSearch";
import { LOCALE_OPTIONS } from "@/lib/app/localeOptions";
import { DEFAULT_WEB_DAV_AUTO_UPLOAD_INTERVAL_MINUTES, DEFAULT_WEB_DAV_REMOTE_PATH, normalizedWebDavAutoUploadInterval, writeWebDavAutoUploadFields } from "@/lib/webdav/webdavAutoUploadConfig";
@@ -274,6 +274,18 @@ function onUiFontFamilyOpenChange(open: boolean) {
}
}
function previewLocaleOption(locale: Locale) {
void previewLocale(locale);
}
function restoreLocaleOptionPreview() {
void restoreLocalePreview();
}
function onLocaleOpenChange(open: boolean) {
if (!open) restoreLocaleOptionPreview();
}
const appThemePaletteOptions = computed(
(): Array<{ value: AppThemePalette; label: string; previewColor: string }> =>
APP_THEME_PALETTES.map((palette) => ({
@@ -1442,6 +1454,7 @@ watch(
} else {
clearThemePalettePreview();
clearUiFontFamilyPreview();
restoreLocaleOptionPreview();
}
},
{ immediate: true },
@@ -1453,6 +1466,7 @@ watch(
if (isSettingsPage.value && !active) {
clearThemePalettePreview();
clearUiFontFamilyPreview();
restoreLocaleOptionPreview();
}
},
{ immediate: true },
@@ -2073,6 +2087,12 @@ function onLocaleChange(v: any) {
if (typeof v === "string") void setLocale(v as Locale);
}
function onUiScaleChange(value: unknown) {
const next = Number(value);
if (!Number.isFinite(next)) return;
editUiScale.value = next;
}
function onUpdateDownloadSourceChange(v: any) {
if (v === "official" || v === "cnb") editUpdateDownloadSource.value = v;
}
@@ -3669,6 +3689,7 @@ onMounted(() => {
onUnmounted(() => {
clearThemePalettePreview();
clearUiFontFamilyPreview();
restoreLocaleOptionPreview();
cleanupTableColumnTemplatePointerDrag();
cleanupTruncationObservers();
});
@@ -5650,7 +5671,7 @@ onUnmounted(() => {
<div class="flex h-9 items-end">
<Label class="whitespace-normal leading-tight">{{ t("settings.languageTitle") }}</Label>
</div>
<Select :model-value="currentLocale()" @update:model-value="onLocaleChange">
<Select :model-value="currentLocale()" @update:model-value="onLocaleChange" @update:open="onLocaleOpenChange">
<SelectTrigger class="h-8 w-full gap-0.5 px-0.5">
<SelectValue>
<span v-if="selectedLocaleOption" class="flex min-w-0 items-center gap-0.5">
@@ -5661,8 +5682,8 @@ onUnmounted(() => {
</span>
</SelectValue>
</SelectTrigger>
<SelectContent class="w-[150px]">
<SelectItem v-for="locale in LOCALE_OPTIONS" :key="locale.value" :value="locale.value">
<SelectContent class="w-[150px]" @pointerleave="restoreLocaleOptionPreview">
<SelectItem v-for="locale in LOCALE_OPTIONS" :key="locale.value" :value="locale.value" @pointerenter="previewLocaleOption(locale.value)" @focus="previewLocaleOption(locale.value)">
<div class="flex items-center gap-1">
<span class="inline-flex h-5 w-6 shrink-0 items-center justify-center text-sm font-medium leading-none">
{{ locale.flag }}
@@ -5719,15 +5740,7 @@ onUnmounted(() => {
</HelpTooltip>
</div>
</div>
<Select
:model-value="String(editUiScale)"
@update:model-value="
(value: any) => {
const next = Number(value);
if (Number.isFinite(next)) editUiScale = next;
}
"
>
<Select :model-value="String(editUiScale)" @update:model-value="onUiScaleChange">
<SelectTrigger class="h-8 w-full">
<SelectValue>{{ Math.round(editUiScale * 100) }}%</SelectValue>
</SelectTrigger>
@@ -5773,6 +5786,7 @@ onUnmounted(() => {
:trigger-class="appearanceFontSearchTriggerClass"
:trigger-icon-class="appearanceFontSearchTriggerIconClass"
content-class="w-[var(--reka-popover-trigger-width)] min-w-[260px]"
:content-style="{ fontFamily: editUiFontFamily || DEFAULT_UI_FONT_FAMILY }"
@update:model-value="onUiFontFamilyChange"
@update:open="onUiFontFamilyOpenChange"
@option-hover="previewUiFontOption"
@@ -17,5 +17,20 @@ describe("EditorSettingsDialog preview cleanup", () => {
expect(dialogSource).toContain("if (isSettingsPage.value && !active)");
expect(dialogSource).toContain("clearThemePalettePreview();");
expect(dialogSource).toContain("clearUiFontFamilyPreview();");
expect(dialogSource).toContain("restoreLocaleOptionPreview();");
});
it("keeps language hover preview while scale stays an unapplied draft", () => {
expect(dialogSource).toContain('@update:open="onLocaleOpenChange"');
expect(dialogSource).toContain('@pointerleave="restoreLocaleOptionPreview"');
expect(dialogSource).toContain('@pointerenter="previewLocaleOption(locale.value)"');
expect(dialogSource).toContain('@update:model-value="onUiScaleChange"');
expect(dialogSource).not.toContain("useUiScalePreview");
expect(dialogSource).not.toContain("previewUiScaleOption");
expect(dialogSource).not.toContain("createUiScalePreviewRestore");
expect(appSource).toContain("createUiScaleApplyQueue");
expect(appSource).not.toContain("useUiScalePreview");
expect(appSource).toContain("() => settingsStore.editorSettings.uiScale");
expect(appSource).toContain("applyUiScale(scale)");
});
});
@@ -27,6 +27,7 @@ const props = withDefaults(
triggerClass?: HTMLAttributes["class"];
triggerIconClass?: HTMLAttributes["class"];
contentClass?: HTMLAttributes["class"];
contentStyle?: HTMLAttributes["style"];
listClass?: HTMLAttributes["class"];
itemClass?: HTMLAttributes["class"];
displayName?: (option: string) => string;
@@ -248,7 +249,7 @@ function handleKeydown(event: KeyboardEvent) {
<ChevronDown v-else :class="cn('shrink-0 opacity-60', triggerIconClass)" />
</Button>
</PopoverTrigger>
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)">
<PopoverContent :align="SEARCHABLE_SELECT_HELP_PANEL_ALIGN" :class="cn('w-auto max-w-[calc(100vw-1rem)] border-0 bg-transparent p-0 shadow-none ring-0', contentClass)" :style="contentStyle">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start">
<div ref="listCard" :class="cn('shrink-0 rounded-md border bg-popover p-1.5 shadow-md', listClass)">
<div class="relative rounded-md border bg-background">
+85
View File
@@ -0,0 +1,85 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { currentLocale, previewLocale, restoreLocalePreview, setLocale } from "@/i18n";
const delayedItalianLocale = vi.hoisted(() => {
let resolveLocaleModule: ((module: { default: Record<string, unknown> }) => void) | undefined;
let pendingLocaleModule: Promise<{ default: Record<string, unknown> }>;
function reset() {
pendingLocaleModule = new Promise((resolve) => {
resolveLocaleModule = resolve;
});
}
reset();
return {
reset,
load: () => pendingLocaleModule,
resolve: () => resolveLocaleModule?.({ default: {} }),
};
});
vi.mock("@/i18n/locales/it", () => delayedItalianLocale.load());
describe("locale preview", () => {
beforeEach(async () => {
delayedItalianLocale.reset();
await setLocale("en");
window.localStorage.clear();
});
afterEach(async () => {
await setLocale("en");
window.localStorage.clear();
vi.restoreAllMocks();
});
it("changes the visible locale without persisting a hover preview", async () => {
const setItem = vi.spyOn(window.localStorage, "setItem");
await previewLocale("es");
expect(currentLocale()).toBe("es");
expect(setItem).not.toHaveBeenCalled();
await restoreLocalePreview();
expect(currentLocale()).toBe("en");
expect(setItem).not.toHaveBeenCalled();
});
it("keeps the most recent asynchronous preview", async () => {
const firstPreview = previewLocale("zh-CN");
const latestPreview = previewLocale("ja");
await Promise.all([firstPreview, latestPreview]);
expect(currentLocale()).toBe("ja");
await restoreLocalePreview();
expect(currentLocale()).toBe("en");
});
it("restores a selected locale when closing its list before loading finishes", async () => {
const selected = setLocale("ko");
const restore = restoreLocalePreview();
await Promise.all([selected, restore]);
expect(currentLocale()).toBe("ko");
expect(window.localStorage.getItem("dbx-locale")).toBe("ko");
});
it("persists an explicit choice before its delayed messages become visible", async () => {
const selected = setLocale("it");
expect(window.localStorage.getItem("dbx-locale")).toBe("it");
expect(currentLocale()).toBe("en");
delayedItalianLocale.resolve();
await selected;
expect(currentLocale()).toBe("it");
});
});
+29 -1
View File
@@ -72,6 +72,8 @@ function detectUserLocale(): Locale {
const savedLocale = normalizeLocale(safeLocalStorageGet("dbx-locale"));
const initialLocale = savedLocale ?? detectUserLocale();
let persistedLocale = initialLocale;
let localeRequestId = 0;
const i18n = createI18n({
legacy: false,
@@ -107,11 +109,37 @@ export async function loadSavedLocale() {
void syncLocaleToBackend(initialLocale);
}
export async function setLocale(locale: Locale) {
async function applyTransientLocale(locale: Locale) {
const requestId = ++localeRequestId;
await loadLocaleMessages(locale);
if (requestId !== localeRequestId) return;
i18nGlobal.locale.value = locale;
}
// Temporary previews deliberately skip persistence and backend synchronization.
// The request id makes a slow locale import unable to overwrite a newer hover,
// a restore, or an explicitly selected locale.
export async function previewLocale(locale: Locale) {
await applyTransientLocale(locale);
}
export async function restoreLocalePreview() {
await applyTransientLocale(persistedLocale);
}
export async function setLocale(locale: Locale) {
// Record a user selection before awaiting a lazy locale import. A following
// close/restore must use the selected locale rather than an older preview.
persistedLocale = locale;
++localeRequestId;
// An explicit selection is durable immediately; only the visible locale
// waits for its lazy message bundle. Preview paths never reach this branch.
safeLocalStorageSet("dbx-locale", locale);
void syncLocaleToBackend(locale);
await loadLocaleMessages(locale);
// A later explicit selection wins; hover/restore requests must not undo it.
if (persistedLocale !== locale) return;
i18nGlobal.locale.value = locale;
}
export function currentLocale(): Locale {
@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import { createUiScaleApplyQueue } from "@/lib/app/uiScaleApplyQueue";
function deferred() {
let resolve: (() => void) | undefined;
return {
promise: new Promise<void>((done) => {
resolve = done;
}),
resolve: () => resolve?.(),
};
}
describe("ui scale apply queue", () => {
it("serializes zoom IPC and only publishes the newest completed target", async () => {
const first = deferred();
const latest = deferred();
const apply = vi.fn((scale: number) => (scale === 0.75 ? first.promise : latest.promise));
const applied = vi.fn();
const queue = createUiScaleApplyQueue(apply, applied, vi.fn());
queue.request(0.75);
queue.request(1.25);
expect(apply).toHaveBeenCalledTimes(1);
expect(apply).toHaveBeenLastCalledWith(0.75);
first.resolve();
await Promise.resolve();
expect(apply).toHaveBeenCalledTimes(2);
expect(apply).toHaveBeenLastCalledWith(1.25);
expect(applied).not.toHaveBeenCalled();
latest.resolve();
await queue.whenIdle();
expect(applied).toHaveBeenCalledTimes(1);
expect(applied).toHaveBeenCalledWith(1.25);
});
});
@@ -0,0 +1,65 @@
export interface UiScaleApplyQueue {
request: (scale: number) => void;
whenIdle: () => Promise<void>;
}
export function createUiScaleApplyQueue(apply: (scale: number) => Promise<void>, onApplied: (scale: number) => void, onError: (scale: number, error: unknown) => void): UiScaleApplyQueue {
let queuedScale: number | undefined;
let applying = false;
let lastAppliedScale: number | undefined;
let idleResolvers: Array<() => void> = [];
function resolveIdle() {
if (applying || queuedScale !== undefined) return;
const resolvers = idleResolvers;
idleResolvers = [];
resolvers.forEach((resolve) => resolve());
}
async function flush() {
try {
while (queuedScale !== undefined) {
const scale = queuedScale;
queuedScale = undefined;
if (scale === lastAppliedScale) continue;
try {
await apply(scale);
} catch (error) {
onError(scale, error);
continue;
}
// Newer requests replace this completed IPC result. Repeating the same
// scale is already satisfied by this call, so coalesce it as well.
if (queuedScale === undefined || queuedScale === scale) {
queuedScale = undefined;
lastAppliedScale = scale;
onApplied(scale);
}
}
} finally {
applying = false;
if (queuedScale !== undefined) {
applying = true;
void flush();
} else {
resolveIdle();
}
}
}
function request(scale: number) {
queuedScale = scale;
if (applying) return;
applying = true;
void flush();
}
function whenIdle() {
if (!applying && queuedScale === undefined) return Promise.resolve();
return new Promise<void>((resolve) => idleResolvers.push(resolve));
}
return { request, whenIdle };
}
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const searchableSelectSource = readFileSync(new URL("../../../components/ui/searchable-select/SearchableSelect.vue", import.meta.url), "utf8");
const editorSettingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
const dataTransferDialogSource = readFileSync(new URL("../../../components/transfer/DataTransferDialog.vue", import.meta.url), "utf8");
const editorToolbarSource = readFileSync(new URL("../../../components/layout/EditorToolbar.vue", import.meta.url), "utf8");
const schemaDiffConfigStepSource = readFileSync(new URL("../../../components/diff/SchemaDiffConfigStep.vue", import.meta.url), "utf8");
@@ -20,6 +21,15 @@ describe("SearchableSelect layout", () => {
expect(searchableSelectSource).toContain("props.trimCustom ? searchText.value.trim() : searchText.value");
});
it("forwards an optional style to its portaled content for local preview isolation", () => {
expect(searchableSelectSource).toContain('contentStyle?: HTMLAttributes["style"]');
expect(searchableSelectSource).toContain(':style="contentStyle"');
expect(editorSettingsDialogSource).toContain(':content-style="{ fontFamily: editUiFontFamily || DEFAULT_UI_FONT_FAMILY }"');
expect(editorSettingsDialogSource).toContain('@option-hover="previewUiFontOption"');
expect(editorSettingsDialogSource).toContain('@option-highlight="previewUiFontOption"');
expect(editorSettingsDialogSource).toContain('@option-leave="restoreUiFontFamilyPreview"');
});
it("renders data transfer connection pickers as sidebar-like trees", () => {
const treeSelects = dataTransferDialogSource.match(/<ConnectionTreeSelect/g) ?? [];