Skip to content

Commit a693bd9

Browse files
committed
fix(hub-ui): preserve dock order across language changes
1 parent 0661c6c commit a693bd9

5 files changed

Lines changed: 112 additions & 7 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { DevframeViewIframe } from '@devframes/hub'
2+
import type { DevframeRpcClient } from '@devframes/hub/client'
3+
import { createEventEmitter } from 'devframe/utils/events'
4+
import { createSharedState } from 'devframe/utils/shared-state'
5+
import { afterEach, expect, it, vi } from 'vitest'
6+
import { nextTick } from 'vue'
7+
import { loadLocale, setLocalePreference } from '../i18n'
8+
import { createDocksContext } from './context'
9+
10+
function createRpc(): DevframeRpcClient {
11+
// The transport is in memory; registration, localization and sorting use the real context.
12+
// eslint-disable-next-line slop/no-chained-type-assertions -- this fixture supplies only the RPC surface used by the context.
13+
return {
14+
sharedState: { get: async (_key: string, options: { initialValue: object }) => createSharedState({ initialValue: options.initialValue }) },
15+
connectionMeta: {},
16+
events: createEventEmitter(),
17+
client: { register: vi.fn() },
18+
isTrusted: false,
19+
} as unknown as DevframeRpcClient
20+
}
21+
22+
afterEach(() => setLocalePreference('auto'))
23+
24+
it.each(['embedded', 'standalone'] as const)('preserves title updates and ordering through the %s context', async (mode) => {
25+
setLocalePreference('en')
26+
const context = await createDocksContext(mode, createRpc())
27+
const handle = context.docks.register<DevframeViewIframe>({ id: 'demo', type: 'iframe', title: 'Old title', icon: 'ph:cube', url: '/' })
28+
context.docks.register({ id: 'peer', type: 'iframe', title: 'Middle', icon: 'ph:cube', url: '/' })
29+
const entry = () => context.docks.entries.find(item => item.id === 'demo')!
30+
const order = () => context.docks.groupedEntries.flatMap(([, items]) => items.map(item => item.id)).filter(id => id !== '~settings')
31+
expect(order()).toEqual(['demo', 'peer'])
32+
33+
context.docks.update({ ...entry(), title: 'A new title' })
34+
await nextTick()
35+
expect(entry().title).toBe('A new title')
36+
expect(context.docks.getStateById('demo')?.entryMeta.title).toBe('A new title')
37+
expect(order()).toEqual(['peer', 'demo'])
38+
39+
handle.update({ titleLocales: { ja: '日本語のタイトル' } })
40+
await loadLocale('ja')
41+
setLocalePreference('ja')
42+
await nextTick()
43+
expect(entry().title).toBe('日本語のタイトル')
44+
expect(order()).toEqual(['peer', 'demo'])
45+
context.docks.update({ ...entry(), icon: 'ph:star' })
46+
setLocalePreference('en')
47+
await nextTick()
48+
expect(entry().title).toBe('A new title')
49+
50+
handle.update({ title: 'Z title' })
51+
await nextTick()
52+
expect(entry().title).toBe('Z title')
53+
expect(order()).toEqual(['demo', 'peer'])
54+
context.docks.settings.mutate((state) => {
55+
state.docksCustomOrder = { peer: -1, demo: 1 }
56+
})
57+
await nextTick()
58+
for (const language of ['ja', 'en'] as const) {
59+
setLocalePreference(language)
60+
await nextTick()
61+
expect(order()).toEqual(['peer', 'demo'])
62+
}
63+
handle.dispose()
64+
})

‎packages/hub-ui/src/client/state/context.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ export async function createDocksContext(
7878
// hub-ui owns the built-in Settings tab so it's always reachable without a
7979
// host registering `~settings`; a host that registered its own wins, so add
8080
// ours only when the merged list has none.
81-
const localized = base.map(localizeTitle)
81+
const localized = base.map(entry => localizeTitle(entry))
8282
if (localized.some(entry => entry.id === BUILTIN_ENTRY_SETTINGS.id))
8383
return localized
84-
return [...localized, { ...BUILTIN_ENTRY_SETTINGS, title: t('dock.settings') }]
84+
return [...localized, localizeTitle(BUILTIN_ENTRY_SETTINGS, t('dock.settings'))]
8585
})
8686

8787
// Per-tab session UI state (open/selectedId/route). A caller (the embedded and

‎packages/hub-ui/src/client/state/dock-settings.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { MessageKey } from '../i18n'
55
import { evaluateWhen } from 'devframe/utils/when'
66
import { DEFAULT_CATEGORIES_ORDER, INSPECTOR_DOCK_ID } from '../constants'
77
import { t } from '../i18n'
8+
import { getDefaultTitle } from './locale'
89
import { hubUiSetting } from './settings-defaults'
910
// Registers hub-ui's reference-viewer settings onto DevframeDocksUserSettings.
1011
import '../types'
@@ -291,7 +292,7 @@ function compareEntryOrder(a: DevframeDockEntry, b: DevframeDockEntry, docksCust
291292
return customA - customB
292293
const ia = a.defaultOrder ?? 0
293294
const ib = b.defaultOrder ?? 0
294-
return ib === ia ? b.title.localeCompare(a.title) : ia - ib
295+
return ib === ia ? getDefaultTitle(b).localeCompare(getDefaultTitle(a)) : ia - ib
295296
}
296297

297298
// A pinned top-level entry or group button re-buckets into `~pinned`; a grouped

‎packages/hub-ui/src/client/state/locale.test.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1+
import type { DevframeDockEntry } from '@devframes/hub'
12
import type { DevframeRpcClient } from '@devframes/hub/client'
23
import type { ConnectionMeta } from 'devframe/types'
34
import { getDevframeConnection } from '@devframes/hub/client'
5+
import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants'
46
import { afterEach, expect, it } from 'vitest'
57
import { nextTick } from 'vue'
6-
import { setHostLocale, setLocalePreference } from '../i18n'
7-
import { localizeCommand, setupLocale } from './locale'
8+
import { BUILTIN_ENTRY_SETTINGS } from '../constants'
9+
import { setHostLocale, setLocalePreference, t } from '../i18n'
10+
import { docksGroupByCategories } from './dock-settings'
11+
import { localizeCommand, localizeTitle, setupLocale } from './locale'
812

913
function createRpc(connectionMeta: ConnectionMeta): DevframeRpcClient {
1014
// Only the connection surface `setupLocale` touches.
@@ -49,3 +53,22 @@ it('localizes a command tree in the current UI language', () => {
4953
expect(command.title).toBe('ビルド')
5054
expect(command.children?.[0]?.title).toBe('クリーン')
5155
})
56+
57+
it.each(['en', 'zh-CN', 'ja'] as const)('keeps dock order and custom weights when the locale is %s', (language) => {
58+
setLocalePreference(language)
59+
const entries: DevframeDockEntry[] = [
60+
{ id: 'assets', type: 'iframe', url: '/', icon: 'ph:cube', title: 'Assets', titleLocales: { 'zh-CN': '资源', 'ja': 'アセット' }, category: '~builtin' },
61+
{ id: 'terminals', type: 'iframe', url: '/', icon: 'ph:cube', title: 'Terminals', category: '~builtin' },
62+
{ id: 'first', type: 'iframe', url: '/', icon: 'ph:cube', title: 'First', category: '~builtin', defaultOrder: -1 },
63+
]
64+
const localized = [...entries.map(entry => localizeTitle(entry)), localizeTitle(BUILTIN_ENTRY_SETTINGS, t('dock.settings'))]
65+
const settings = DEFAULT_STATE_USER_SETTINGS()
66+
const order = () => docksGroupByCategories(localized, settings).flatMap(([, items]) => items.map(item => item.id))
67+
68+
expect(order()).toEqual(['first', 'terminals', '~settings', 'assets'])
69+
expect(localized.at(-1)?.title).toBe(t('dock.settings'))
70+
71+
settings.docksCustomOrder = { 'assets': -2, '~settings': -1, 'first': 1, 'terminals': 2 }
72+
expect(order()).toEqual(['assets', '~settings', 'first', 'terminals'])
73+
expect(settings.docksCustomOrder).toEqual({ 'assets': -2, '~settings': -1, 'first': 1, 'terminals': 2 })
74+
})

‎packages/hub-ui/src/client/state/locale.ts‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@ import { resolveTitle, storeConnection } from '@devframes/hub/client'
44
import { watch } from 'vue'
55
import { locale, setHostLocale } from '../i18n'
66

7+
const defaultTitle = Symbol('defaultTitle')
8+
9+
interface LocalizedTitle {
10+
original: string
11+
display: string
12+
}
13+
14+
/** The registered title, preserved across localized copies for stable dock ordering. */
15+
export function getDefaultTitle(titled: DevframeTitled & { [defaultTitle]?: LocalizedTitle }): string {
16+
const previous = titled[defaultTitle]
17+
// A caller can spread a displayed entry into an update. Keep its original
18+
// title only while the caller has left the displayed title unchanged.
19+
return previous && titled.title === previous.display ? previous.original : titled.title
20+
}
21+
722
/**
823
* Seed the language from `ConnectionMeta.configs.ui.locale` (the host's
924
* `createUi({ locale })`), then keep the effective language published back
@@ -20,8 +35,10 @@ export function setupLocale(rpc: DevframeRpcClient): void {
2035
}
2136

2237
/** A copy of a dock entry, command or launcher with `title` in the current UI language. */
23-
export function localizeTitle<T extends DevframeTitled>(titled: T): T {
24-
return { ...titled, title: resolveTitle(titled, locale.value) }
38+
export function localizeTitle<T extends DevframeTitled>(titled: T, title?: string): T {
39+
const original = getDefaultTitle(titled)
40+
const display = title ?? resolveTitle({ ...titled, title: original }, locale.value)
41+
return { ...titled, [defaultTitle]: { original, display }, title: display }
2542
}
2643

2744
/** `localizeTitle` for a command and its children, so palette and shortcut rows follow the language. */

0 commit comments

Comments
 (0)