feat(settings): preview locale and serialize UI scale application
This commit is contained in:
9 files changed
+284
-20
No files matched your search
@@ -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"
|
||||
|
||||
+15
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) ?? [];
|
||||
|
||||
|
||||
Reference in new issue
Block a user