From a693bd947ca15551ee0efa2f8521fd7c7dd24ed5 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Wed, 14 Oct 2026 12:18:45 +0800 Subject: [PATCH] fix(hub-ui): preserve dock order across language changes --- .../src/client/state/context-locale.test.ts | 64 +++++++++++++++++++ packages/hub-ui/src/client/state/context.ts | 4 +- .../hub-ui/src/client/state/dock-settings.ts | 3 +- .../hub-ui/src/client/state/locale.test.ts | 27 +++++++- packages/hub-ui/src/client/state/locale.ts | 21 +++++- 5 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/hub-ui/src/client/state/context-locale.test.ts diff --git a/packages/hub-ui/src/client/state/context-locale.test.ts b/packages/hub-ui/src/client/state/context-locale.test.ts new file mode 100644 index 00000000..27563447 --- /dev/null +++ b/packages/hub-ui/src/client/state/context-locale.test.ts @@ -0,0 +1,64 @@ +import type { DevframeViewIframe } from '@devframes/hub' +import type { DevframeRpcClient } from '@devframes/hub/client' +import { createEventEmitter } from 'devframe/utils/events' +import { createSharedState } from 'devframe/utils/shared-state' +import { afterEach, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' +import { loadLocale, setLocalePreference } from '../i18n' +import { createDocksContext } from './context' + +function createRpc(): DevframeRpcClient { + // The transport is in memory; registration, localization and sorting use the real context. + // eslint-disable-next-line slop/no-chained-type-assertions -- this fixture supplies only the RPC surface used by the context. + return { + sharedState: { get: async (_key: string, options: { initialValue: object }) => createSharedState({ initialValue: options.initialValue }) }, + connectionMeta: {}, + events: createEventEmitter(), + client: { register: vi.fn() }, + isTrusted: false, + } as unknown as DevframeRpcClient +} + +afterEach(() => setLocalePreference('auto')) + +it.each(['embedded', 'standalone'] as const)('preserves title updates and ordering through the %s context', async (mode) => { + setLocalePreference('en') + const context = await createDocksContext(mode, createRpc()) + const handle = context.docks.register({ id: 'demo', type: 'iframe', title: 'Old title', icon: 'ph:cube', url: '/' }) + context.docks.register({ id: 'peer', type: 'iframe', title: 'Middle', icon: 'ph:cube', url: '/' }) + const entry = () => context.docks.entries.find(item => item.id === 'demo')! + const order = () => context.docks.groupedEntries.flatMap(([, items]) => items.map(item => item.id)).filter(id => id !== '~settings') + expect(order()).toEqual(['demo', 'peer']) + + context.docks.update({ ...entry(), title: 'A new title' }) + await nextTick() + expect(entry().title).toBe('A new title') + expect(context.docks.getStateById('demo')?.entryMeta.title).toBe('A new title') + expect(order()).toEqual(['peer', 'demo']) + + handle.update({ titleLocales: { ja: '日本語のタイトル' } }) + await loadLocale('ja') + setLocalePreference('ja') + await nextTick() + expect(entry().title).toBe('日本語のタイトル') + expect(order()).toEqual(['peer', 'demo']) + context.docks.update({ ...entry(), icon: 'ph:star' }) + setLocalePreference('en') + await nextTick() + expect(entry().title).toBe('A new title') + + handle.update({ title: 'Z title' }) + await nextTick() + expect(entry().title).toBe('Z title') + expect(order()).toEqual(['demo', 'peer']) + context.docks.settings.mutate((state) => { + state.docksCustomOrder = { peer: -1, demo: 1 } + }) + await nextTick() + for (const language of ['ja', 'en'] as const) { + setLocalePreference(language) + await nextTick() + expect(order()).toEqual(['peer', 'demo']) + } + handle.dispose() +}) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 3f9cc002..1592cd42 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -78,10 +78,10 @@ export async function createDocksContext( // hub-ui owns the built-in Settings tab so it's always reachable without a // host registering `~settings`; a host that registered its own wins, so add // ours only when the merged list has none. - const localized = base.map(localizeTitle) + const localized = base.map(entry => localizeTitle(entry)) if (localized.some(entry => entry.id === BUILTIN_ENTRY_SETTINGS.id)) return localized - return [...localized, { ...BUILTIN_ENTRY_SETTINGS, title: t('dock.settings') }] + return [...localized, localizeTitle(BUILTIN_ENTRY_SETTINGS, t('dock.settings'))] }) // Per-tab session UI state (open/selectedId/route). A caller (the embedded and diff --git a/packages/hub-ui/src/client/state/dock-settings.ts b/packages/hub-ui/src/client/state/dock-settings.ts index 1ada6e9e..b2135ad5 100644 --- a/packages/hub-ui/src/client/state/dock-settings.ts +++ b/packages/hub-ui/src/client/state/dock-settings.ts @@ -5,6 +5,7 @@ import type { MessageKey } from '../i18n' import { evaluateWhen } from 'devframe/utils/when' import { DEFAULT_CATEGORIES_ORDER, INSPECTOR_DOCK_ID } from '../constants' import { t } from '../i18n' +import { getDefaultTitle } from './locale' import { hubUiSetting } from './settings-defaults' // Registers hub-ui's reference-viewer settings onto DevframeDocksUserSettings. import '../types' @@ -291,7 +292,7 @@ function compareEntryOrder(a: DevframeDockEntry, b: DevframeDockEntry, docksCust return customA - customB const ia = a.defaultOrder ?? 0 const ib = b.defaultOrder ?? 0 - return ib === ia ? b.title.localeCompare(a.title) : ia - ib + return ib === ia ? getDefaultTitle(b).localeCompare(getDefaultTitle(a)) : ia - ib } // A pinned top-level entry or group button re-buckets into `~pinned`; a grouped diff --git a/packages/hub-ui/src/client/state/locale.test.ts b/packages/hub-ui/src/client/state/locale.test.ts index 892eb1e6..55164838 100644 --- a/packages/hub-ui/src/client/state/locale.test.ts +++ b/packages/hub-ui/src/client/state/locale.test.ts @@ -1,10 +1,14 @@ +import type { DevframeDockEntry } from '@devframes/hub' import type { DevframeRpcClient } from '@devframes/hub/client' import type { ConnectionMeta } from 'devframe/types' import { getDevframeConnection } from '@devframes/hub/client' +import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants' import { afterEach, expect, it } from 'vitest' import { nextTick } from 'vue' -import { setHostLocale, setLocalePreference } from '../i18n' -import { localizeCommand, setupLocale } from './locale' +import { BUILTIN_ENTRY_SETTINGS } from '../constants' +import { setHostLocale, setLocalePreference, t } from '../i18n' +import { docksGroupByCategories } from './dock-settings' +import { localizeCommand, localizeTitle, setupLocale } from './locale' function createRpc(connectionMeta: ConnectionMeta): DevframeRpcClient { // Only the connection surface `setupLocale` touches. @@ -49,3 +53,22 @@ it('localizes a command tree in the current UI language', () => { expect(command.title).toBe('ビルド') expect(command.children?.[0]?.title).toBe('クリーン') }) + +it.each(['en', 'zh-CN', 'ja'] as const)('keeps dock order and custom weights when the locale is %s', (language) => { + setLocalePreference(language) + const entries: DevframeDockEntry[] = [ + { id: 'assets', type: 'iframe', url: '/', icon: 'ph:cube', title: 'Assets', titleLocales: { 'zh-CN': '资源', 'ja': 'アセット' }, category: '~builtin' }, + { id: 'terminals', type: 'iframe', url: '/', icon: 'ph:cube', title: 'Terminals', category: '~builtin' }, + { id: 'first', type: 'iframe', url: '/', icon: 'ph:cube', title: 'First', category: '~builtin', defaultOrder: -1 }, + ] + const localized = [...entries.map(entry => localizeTitle(entry)), localizeTitle(BUILTIN_ENTRY_SETTINGS, t('dock.settings'))] + const settings = DEFAULT_STATE_USER_SETTINGS() + const order = () => docksGroupByCategories(localized, settings).flatMap(([, items]) => items.map(item => item.id)) + + expect(order()).toEqual(['first', 'terminals', '~settings', 'assets']) + expect(localized.at(-1)?.title).toBe(t('dock.settings')) + + settings.docksCustomOrder = { 'assets': -2, '~settings': -1, 'first': 1, 'terminals': 2 } + expect(order()).toEqual(['assets', '~settings', 'first', 'terminals']) + expect(settings.docksCustomOrder).toEqual({ 'assets': -2, '~settings': -1, 'first': 1, 'terminals': 2 }) +}) diff --git a/packages/hub-ui/src/client/state/locale.ts b/packages/hub-ui/src/client/state/locale.ts index d64b2096..d8060d22 100644 --- a/packages/hub-ui/src/client/state/locale.ts +++ b/packages/hub-ui/src/client/state/locale.ts @@ -4,6 +4,21 @@ import { resolveTitle, storeConnection } from '@devframes/hub/client' import { watch } from 'vue' import { locale, setHostLocale } from '../i18n' +const defaultTitle = Symbol('defaultTitle') + +interface LocalizedTitle { + original: string + display: string +} + +/** The registered title, preserved across localized copies for stable dock ordering. */ +export function getDefaultTitle(titled: DevframeTitled & { [defaultTitle]?: LocalizedTitle }): string { + const previous = titled[defaultTitle] + // A caller can spread a displayed entry into an update. Keep its original + // title only while the caller has left the displayed title unchanged. + return previous && titled.title === previous.display ? previous.original : titled.title +} + /** * Seed the language from `ConnectionMeta.configs.ui.locale` (the host's * `createUi({ locale })`), then keep the effective language published back @@ -20,8 +35,10 @@ export function setupLocale(rpc: DevframeRpcClient): void { } /** A copy of a dock entry, command or launcher with `title` in the current UI language. */ -export function localizeTitle(titled: T): T { - return { ...titled, title: resolveTitle(titled, locale.value) } +export function localizeTitle(titled: T, title?: string): T { + const original = getDefaultTitle(titled) + const display = title ?? resolveTitle({ ...titled, title: original }, locale.value) + return { ...titled, [defaultTitle]: { original, display }, title: display } } /** `localizeTitle` for a command and its children, so palette and shortcut rows follow the language. */