Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/hub-ui/src/client/state/context-locale.test.ts
Original file line number Diff line number Diff line change
@@ -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<DevframeViewIframe>({ 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()
})
4 changes: 2 additions & 2 deletions packages/hub-ui/src/client/state/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/hub-ui/src/client/state/dock-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions packages/hub-ui/src/client/state/locale.test.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 })
})
21 changes: 19 additions & 2 deletions packages/hub-ui/src/client/state/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<T extends DevframeTitled>(titled: T): T {
return { ...titled, title: resolveTitle(titled, locale.value) }
export function localizeTitle<T extends DevframeTitled>(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. */
Expand Down
Loading