--- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -5,7 +5,7 @@ /** Settings namespace owned by the locale plugin. */ export const LOCALE_SETTINGS_NAMESPACE = 'locale' -/** Field carrying an explicit locale selection; absence delegates to the browser. */ +/** Field carrying an explicit ordered locale preference; absence delegates to the browser. */ export const LOCALE_PREFERENCE_FIELD = 'preference' /** Accepted BCP 47-style language ids. */ @@ -20,15 +20,50 @@ /** Open locale identifier accepted from language-pack plugins. */ export type LocaleId = string +/** + * Ordered locale preference. The first registered entry is the active locale; + * later entries answer missing keys before the fallback chain and the raw key. + * A stored string is the legacy one-element form of the same value. + */ +export type LocalePreference = readonly LocaleId[] + +/** + * Normalize a stored preference to an ordered, duplicate-free list. A stored + * string is read as a one-element list; null and undefined stay absent. + * @param value - stored preference (legacy string, ordered list, null or undefined). + * @returns the normalized list, or undefined when nothing is stored. + */ +export function normalizeLocalePreference( + value: string | readonly string[] | null | undefined, +): LocalePreference | undefined { + if (value === null || value === undefined) return undefined + const list = typeof value === 'string' ? [value] : value + const seen = new Set() + const normalized: LocaleId[] = [] + for (const id of list) { + const key = id.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + normalized.push(id) + } + return normalized.length === 0 ? undefined : Object.freeze(normalized) +} + /** Durable locale section shared by the Host schema and the browser scope. */ export interface LocaleSettings { - /** Explicit locale selection; absence delegates to the browser. */ - preference?: LocaleId + /** + * Ordered locale preference; absence delegates to the browser. The legacy + * string form remains accepted so existing profiles keep loading. + */ + preference?: LocaleId | LocalePreference } + +/** One accepted locale id. */ +const LocaleIdField = z.string().pattern(LOCALE_ID_PATTERN) /** Durable locale schema; also the wire envelope the browser scope validates against. */ export const LocaleSettingsFields = { - [LOCALE_PREFERENCE_FIELD]: z.string().pattern(LOCALE_ID_PATTERN).required(false), + [LOCALE_PREFERENCE_FIELD]: z.union([LocaleIdField, z.array(LocaleIdField)]).required(false), } /** Schema for the shared locale preference. */ --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -9,14 +9,14 @@ import { LocaleSettingsFields } from './locale-settings.ts' export { - LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, - type BuiltInLocaleId, type LocaleId, type LocaleSettings, + LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, normalizeLocalePreference, + type BuiltInLocaleId, type LocaleId, type LocalePreference, type LocaleSettings, } from './locale-settings.ts' /** Runtime preferences projected to the browser. */ export interface Config { - /** Explicit locale; omission follows the browser. */ - preference: Volatile + /** Ordered locale preference; omission follows the browser. */ + preference: Volatile } /** Live preferences projected to the browser. */ --- a/packages/client/locale/src/client/bootstrap.ts +++ b/packages/client/locale/src/client/bootstrap.ts @@ -4,8 +4,8 @@ export interface LocaleBootstrap { /** Operating-system language tags in preference order. */ readonly languages: readonly string[] - /** Stored locale.preference; null preserves automatic language selection. */ - readonly preference: string | null + /** Stored locale.preference; a string is the legacy one-element form. */ + readonly preference: string | readonly string[] | null } /** Optional page-global bridge named __DSH_LOCALE__; ordinary browsers omit it. */ @@ -24,8 +24,16 @@ export function parseLocaleBootstrap(value: unknown): LocaleBootstrap { if (typeof value !== 'object' || value === null || !('languages' in value) || !Array.isArray(value.languages) || !value.languages.every((language: unknown) => typeof language === 'string') - || !('preference' in value) || (value.preference !== null && typeof value.preference !== 'string')) { + || !('preference' in value)) { throw new TypeError('locale: invalid native initialization data') } - return { languages: value.languages, preference: value.preference } + const preference = (value as { preference: unknown }).preference + if (preference !== null && typeof preference !== 'string' + && !(Array.isArray(preference) && preference.every((id: unknown) => typeof id === 'string'))) { + throw new TypeError('locale: invalid native initialization data') + } + return { + languages: value.languages, + preference: preference as string | readonly string[] | null, + } } --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -18,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { LOCALE_ID_PATTERN, LOCALE_IDS, LOCALE_PREFERENCE_FIELD, LOCALE_SETTINGS_NAMESPACE, + normalizeLocalePreference, type BuiltInLocaleId, type LocaleId, type LocaleSettings, } from '../locale-settings.ts' import { en, ja, es, fr, de, ru, pt, zh, type CommonKey } from '../locales/index.ts' @@ -73,11 +74,13 @@ /** Immutable locale state published on every change. */ export interface LocaleSnapshot { - /** Active locale id. */ + /** Active locale id (the first registered entry of {@link preference}). */ active: LocaleId + /** Ordered preference used for per-key lookup; registered ids only. */ + preference: readonly LocaleId[] /** Selectable locales in display order. */ locales: readonly LocaleDefinition[] - /** Monotonic change counter (registry or active changes). */ + /** Monotonic change counter (registry or preference changes). */ revision: number } @@ -168,14 +171,15 @@ private bound = new Map() private catalog = new Map() private fallbackChains = new Map() + private preferenceChains = new Map() private snapshot: LocaleSnapshot private listeners = new Set<() => void>() private readonly ctx: ClientContext private readonly host: ConfigForm | undefined - /** Browser-derived locale standing wherever no explicit Host selection does. */ - private provisional: LocaleId - /** Last explicit selection, including one awaiting an external registration. */ - private preference: LocaleId | undefined + /** Browser-derived ordered preference standing wherever no explicit Host selection does. */ + private provisional: readonly LocaleId[] + /** Explicit preference from the Host, including entries awaiting registration. */ + private preference: readonly LocaleId[] | undefined /** * @param ctx - owning context (change events are emitted on it; the scope @@ -189,9 +193,14 @@ this.host = host for (const locale of BUILT_IN_LOCALES) this.catalog.set(localeKey(locale.id), locale) const locales = this.localeList() - this.provisional = resolveInitialLocale(locales, bootstrap?.languages) - this.preference = bootstrap?.preference ?? undefined - this.snapshot = Object.freeze({ active: this.resolveActive(), locales, revision: 0 }) + this.provisional = resolveInitialLocales(locales, bootstrap?.languages) + this.preference = normalizeLocalePreference(bootstrap?.preference) + this.snapshot = Object.freeze({ + active: this.resolveActive(), + preference: this.effectivePreference(), + locales, + revision: 0, + }) if (host !== undefined) { ctx.effect(() => host.subscribe(() => { this.adopt(host) }), 'locale: settings scope adoption') this.adopt(host) @@ -214,7 +223,7 @@ */ resolveText(text: LocalizedText): string { if (typeof text === 'string') return text - return this.fallbackChain(this.snapshot.active).reduceRight( + return this.lookupChain().reduceRight( (resolved, locale) => text[localeKey(locale)] ?? resolved, text.en, ) @@ -242,23 +251,99 @@ } /** - * Switch the active locale — the only user preference write entry. - * - * The durable write happens even when the id already matches the active - * locale, because the active value may be a provisional browser-derived or - * fallback resolution that nothing has stored yet. Picking the language - * already on screen is still an explicit choice, and it must survive a - * different browser sharing the same DSH home. Only the render notification - * is conditional: republishing an unchanged locale would churn every - * subscriber for nothing. + * Switch to one locale — the legacy single-selection entry point. Writes the + * stored string form, so callers written before ordered preferences keep the + * exact persistence behaviour; {@link setPreferences} writes the list form. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { + const match = this.requireLocale(id) + this.preference = Object.freeze([match.id]) + this.publishPreference(true) + void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) + } + + /** + * Replace the ordered locale preference. The first registered entry becomes + * the active locale; later entries answer missing keys before each entry's + * declared fallback chain, English, and finally the raw key. + * @param ids - registered locale ids in preference order; empty throws. + */ + setPreferences(ids: readonly string[]): void { + const normalized = this.requireLocales(ids) + this.preference = Object.freeze(normalized) + this.publishPreference(true) + void this.host?.set(LOCALE_PREFERENCE_FIELD, [...normalized]) + } + + /** + * Append one registered language after the current preference. + * @param id - a registered locale id. + */ + addPreference(id: string): void { + const match = this.requireLocale(id) + this.setPreferences([...this.effectivePreference(), match.id]) + } + + /** + * Remove one entry; the active language changes when the first entry leaves. + * The last remaining entry cannot be removed (the UI disables that control). + * @param id - a preferred locale id; an unlisted id is ignored. + */ + removePreference(id: string): void { + const current = this.effectivePreference() + const next = current.filter(locale => localeKey(locale) !== localeKey(id)) + if (next.length === current.length) return + if (next.length === 0) throw new Error('locale preference must not be empty') + this.setPreferences(next) + } + + /** + * Move one entry by a signed offset inside the preference order. + * @param id - a preferred locale id. + * @param delta - negative for earlier, positive for later. + */ + movePreference(id: string, delta: number): void { + const current = [...this.effectivePreference()] + const index = current.findIndex(locale => localeKey(locale) === localeKey(id)) + if (index === -1) throw new Error(`locale "${id}" is not preferred`) + const target = Math.min(current.length - 1, Math.max(0, index + delta)) + if (target === index) return + const entry = current[index] as LocaleId + current.splice(index, 1) + current.splice(target, 0, entry) + this.setPreferences(current) + } + + /** Resolve one registered definition or throw. */ + private requireLocale(id: string): LocaleDefinition { const match = this.catalog.get(localeKey(id)) if (match === undefined) throw new Error(`locale "${id}" is not registered`) - this.preference = match.id - if (this.snapshot.active !== match.id) this.publish(match.id, true) - void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) + return match + } + + /** Normalize a caller's list to registered ids (deduped, order kept). */ + private requireLocales(ids: readonly string[]): LocaleId[] { + const seen = new Set() + const normalized: LocaleId[] = [] + for (const id of ids) { + const match = this.requireLocale(id) + const key = localeKey(match.id) + if (seen.has(key)) continue + seen.add(key) + normalized.push(match.id) + } + if (normalized.length === 0) throw new Error('locale preference must not be empty') + return normalized + } + + /** Adopt the current preference and publish only when something changed. */ + private publishPreference(localeChanged: boolean): void { + this.preferenceChains.clear() + const active = this.resolveActive() + const preference = this.effectivePreference() + if (active === this.snapshot.active && sameLocales(this.snapshot.preference, preference)) return + this.publish(active, localeChanged && active !== this.snapshot.active) } /** @@ -305,25 +390,63 @@ private adopt(host: ConfigForm): void { const section = host.getSnapshot().value if (section === undefined) return - this.preference = section.preference + this.preference = normalizeLocalePreference(section.preference) + this.preferenceChains.clear() const target = this.resolveActive() - if (this.snapshot.active === target) return - this.publish(target, true) + const preference = this.effectivePreference() + if (this.snapshot.active === target && sameLocales(this.snapshot.preference, preference)) return + this.publish(target, this.snapshot.active !== target) } /** Recompute browser fallback and publish the current catalog. */ private publishCatalog(): void { this.fallbackChains.clear() + this.preferenceChains.clear() const locales = this.localeList() - this.provisional = resolveInitialLocale(locales, this.bootstrap?.languages) + this.provisional = resolveInitialLocales(locales, this.bootstrap?.languages) const active = this.resolveActive() this.publish(active, active !== this.snapshot.active, locales) } - /** Resolve an explicit preference only while its definition is available. */ + /** Resolve the active locale: first registered preference entry, else browser. */ private resolveActive(): LocaleId { + return this.effectivePreference()[0] ?? FALLBACK_LOCALE + } + + /** Registered entries of the stored preference, or the browser-derived list. */ + private effectivePreference(): readonly LocaleId[] { if (this.preference === undefined) return this.provisional - return this.catalog.get(localeKey(this.preference))?.id ?? this.provisional + const available: LocaleId[] = [] + for (const id of this.preference) { + const match = this.catalog.get(localeKey(id)) + if (match !== undefined) available.push(match.id) + } + return available.length > 0 ? available : this.provisional + } + + /** + * Ordered lookup chain: every preferred language followed by its declared + * fallback chain, deduplicated, with English as the final dictionary stop. + * A one-element preference therefore yields exactly the legacy chain. + */ + private lookupChain(): readonly LocaleId[] { + const preference = this.effectivePreference() + const key = preference.map(localeKey).join('|') + const cached = this.preferenceChains.get(key) + if (cached !== undefined) return cached + const chain: LocaleId[] = [] + const seen = new Set() + const push = (locale: LocaleId): void => { + const localeId = localeKey(locale) + if (seen.has(localeId)) return + seen.add(localeId) + chain.push(locale) + } + for (const id of preference) for (const locale of this.fallbackChain(id)) push(locale) + if (!seen.has(localeKey(FALLBACK_LOCALE))) chain.push(FALLBACK_LOCALE) + const resolved = Object.freeze(chain) + this.preferenceChains.set(key, resolved) + return resolved } /** Snapshot the catalog in registration order. */ @@ -468,7 +591,7 @@ } private translate(ns: string, key: string, params?: Record): string { - const chain = this.fallbackChain(this.snapshot.active) + const chain = this.lookupChain() const template = this.lookup(ns, key, chain) ?? (ns !== COMMON_NS ? this.lookup(COMMON_NS, key, chain) : undefined) ?? key @@ -500,6 +623,7 @@ ): void { this.snapshot = Object.freeze({ active, + preference: this.effectivePreference(), locales, revision: this.snapshot.revision + 1, }) @@ -517,41 +641,53 @@ } /** - * The browser's own language wins over {@link FALLBACK_LOCALE}; an explicit - * Host preference may replace this provisional value after plugin activation. + * The browser's own ordered languages win over {@link FALLBACK_LOCALE}; an + * explicit Host preference may replace this provisional list after activation. */ -function resolveInitialLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId { - return detectBrowserLocale(locales, languages) ?? FALLBACK_LOCALE +function resolveInitialLocales(locales: readonly LocaleDefinition[], languages?: readonly string[]): readonly LocaleId[] { + const detected = detectBrowserLocales(locales, languages) + return detected.length > 0 ? Object.freeze(detected) : Object.freeze([FALLBACK_LOCALE]) } /** - * The first registered locale the browser asks for. Each browser tag first - * matches a locale id exactly, then its primary subtag, so an exact regional - * registration wins before a language-wide fallback. + * Every registered locale the browser asks for, in the browser's order. Each + * browser tag first matches a locale id exactly, then its primary subtag, so an + * exact regional registration wins before a language-wide fallback. The whole + * ordered list is kept (instead of only the first match) because a partial + * translation answers from later entries when the first misses a key. * `window` is the browser test, not `navigator`: Node exposes a global * `navigator` reporting the machine's own language, which must not decide the * locale for non-browser runs. `navigator.language` trails the ordered * `languages` list and covers hosts exposing only the single tag. * @param locales - definitions currently available to the browser. * @param languages - native system language order, when supplied by a shell. - * @returns the first matching locale id, or undefined. + * @returns matching locale ids in preference order. */ -function detectBrowserLocale(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId | undefined { +function detectBrowserLocales(locales: readonly LocaleDefinition[], languages?: readonly string[]): LocaleId[] { if (languages === undefined) { - if (typeof window === 'undefined') return undefined + if (typeof window === 'undefined') return [] // Embedders and older WebViews may omit the DOM-typed `languages` property. const browserLanguages = (navigator as { readonly languages?: readonly string[] }).languages languages = [...(browserLanguages ?? []), navigator.language] } + const matches: LocaleId[] = [] + const seen = new Set() for (const tag of languages) { const requested = localeKey(tag) const exact = locales.find(locale => localeKey(locale.id) === requested) - if (exact !== undefined) return exact.id const primary = requested.split('-')[0] - const match = locales.find(locale => localeKey(locale.id).split('-')[0] === primary) - if (match !== undefined) return match.id - } - return undefined + const match = exact ?? locales.find(locale => localeKey(locale.id).split('-')[0] === primary) + if (match === undefined || seen.has(localeKey(match.id))) continue + seen.add(localeKey(match.id)) + matches.push(match.id) + } + return matches +} + +/** Compare two ordered locale id lists case-insensitively. */ +function sameLocales(left: readonly LocaleId[], right: readonly LocaleId[]): boolean { + return left.length === right.length + && left.every((id, index) => localeKey(id) === localeKey(right[index] as LocaleId)) } /** Required services: slot registration plus the settings transport. */ @@ -601,6 +737,7 @@ syncDocumentLanguage(snapshot) bound?.sync( snapshot.active, + [...snapshot.preference], snapshot.locales.map(l => ({ id: l.id, label: l.label })), snapshot.revision, ) @@ -617,6 +754,7 @@ sync() return { setLocale: (id) => { locale.setLocale(id) }, + setPreferences: (ids) => { locale.setPreferences(ids) }, } } ctx.slots.inject('settings.general.item', () => ctx.slots.register({ --- a/packages/client/locale/src/client/settings-store.ts +++ b/packages/client/locale/src/client/settings-store.ts @@ -15,8 +15,10 @@ /** Store state mirrored from the locale snapshot. */ export interface LanguageRowState { - /** Active locale id. */ + /** Active locale id (the first registered preference entry). */ active: string + /** Ordered locale preference used for per-key lookup. */ + preference: string[] /** Selectable locales in display order. */ options: LanguageOptionRow[] /** Service revision; -1 until first sync so revision 0 lands as a change. */ @@ -25,7 +27,7 @@ /** Declared action shape giving the exported factory a stable return type. */ type LanguageRowActions = { - sync: (draft: LanguageRowState, active: string, options: LanguageOptionRow[], revision: number) => void + sync: (draft: LanguageRowState, active: string, preference: string[], options: LanguageOptionRow[], revision: number) => void } /** @@ -34,11 +36,12 @@ */ export function createLanguageRowStore(): EngineStoreHandle { return defineStore({ - init: (): LanguageRowState => ({ active: '', options: [], revision: -1 }), + init: (): LanguageRowState => ({ active: '', preference: [], options: [], revision: -1 }), actions: { - sync: (d, active: string, options: LanguageOptionRow[], revision: number) => { + sync: (d, active: string, preference: string[], options: LanguageOptionRow[], revision: number) => { if (revision <= d.revision) return d.active = active + d.preference = preference d.options = options d.revision = revision }, --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -1,8 +1,11 @@ /** * Language preference row registered into the General section item slot - * (figma 501:30011 'Setting-Cell'): title + selector pill opening the locale - * menu. Registered by this package — the locale feature owns its own - * settings surface. + * (figma 501:30011 'Setting-Cell'): title + ordered preference list plus an + * "add language" menu. Registered by this package — the locale feature owns + * its own settings surface. + * + * The first entry is the active interface language; later entries answer + * missing strings before English and the raw key (browser-style preference). */ import { useState } from 'react' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' @@ -11,10 +14,12 @@ import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' -/** Injected business face: the preference write (t rides the standard locale seat). */ +/** Injected business face: the preference writes (t rides the standard locale seat). */ export interface LanguageRowInjected { - /** Switch the active locale (a registered locale id). */ + /** Switch to one locale (legacy single-selection entry point). */ setLocale: (id: string) => void + /** Replace the ordered locale preference. */ + setPreferences: (ids: readonly string[]) => void } /** Full component props: runtime share + store share + locale seat + injected face. */ @@ -27,41 +32,94 @@ * @param props - composed slot props. * @returns the row element tree. */ -export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProps) { - const active = useStore(s => s.active) +export function LanguageRow({ t, setPreferences, useStore }: LanguageRowComponentProps) { + const preference = useStore(s => s.preference) const options = useStore(s => s.options) const [open, setOpen] = useState(false) - const activeLabel = options.find(o => o.id === active)?.label ?? active + const labels = new Map(options.map(option => [option.id.toLowerCase(), option.label])) + const label = (id: string): string => labels.get(id.toLowerCase()) ?? id + const remaining = options.filter(option => + !preference.some(id => id.toLowerCase() === option.id.toLowerCase())) + + const move = (index: number, delta: number): void => { + const target = index + delta + if (target < 0 || target >= preference.length) return + const next = [...preference] + const [entry] = next.splice(index, 1) + if (entry === undefined) return + next.splice(target, 0, entry) + setPreferences(next) + } return (
{t('language.title')}
+
{t('language.hint')}
+
    + {preference.map((id, index) => ( +
  1. + {label(id)} + + + + + +
  2. + ))} +
- { setOpen(false) }} - items={options.map(o => ({ id: o.id, label: o.label }))} - selectedId={active} - onSelect={(id) => { - setLocale(id) - setOpen(false) - }} - align="end" - portal - anchor={( - - )} - /> + {remaining.length > 0 && ( + { setOpen(false) }} + items={remaining.map(option => ({ id: option.id, label: option.label }))} + onSelect={(id) => { + setPreferences([...preference, id]) + setOpen(false) + }} + align="end" + portal + anchor={( + + )} + /> + )}
) } --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -3,7 +3,7 @@ .row { display: flex; - align-items: center; + align-items: flex-start; gap: 8px; padding: 16px 0; border-bottom: 0.5px solid var(--dsw-alias-border-l2); @@ -33,6 +33,68 @@ color: var(--dsw-alias-label-primary); } +.hint { + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-secondary); +} + +.list { + display: flex; + flex-direction: column; + gap: 4px; + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.preferenceRow { + display: flex; + align-items: center; + gap: 8px; + min-height: 32px; +} + +.preferenceLabel { + flex: 1; + min-width: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.controls { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.control { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 1; + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.control:hover:enabled { + background: var(--dsw-alias-interactive-bg-hover); +} + +.control:disabled { + opacity: 0.4; + cursor: default; +} + /* Language selector uses the standard control size. */ .selector { display: inline-flex; --- a/packages/client/locale/src/locales/settings.ts +++ b/packages/client/locale/src/locales/settings.ts @@ -3,6 +3,11 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'language.title': '语言', + 'language.hint': '按顺序用于缺失文本的回退;第一个是界面语言。', + 'language.add': '添加语言', + 'language.moveUp': '上移', + 'language.moveDown': '下移', + 'language.remove': '移除', } satisfies Record /** The settings.locale namespace key union. */ @@ -11,34 +16,69 @@ /** English dictionary, checked complete against the zh key set. */ export const en = { 'language.title': 'Language', + 'language.hint': 'Tried in order for missing text; the first one is the interface language.', + 'language.add': 'Add language', + 'language.moveUp': 'Move up', + 'language.moveDown': 'Move down', + 'language.remove': 'Remove', } satisfies Record /** Japanese copy, checked against the English keys. */ export const ja = { 'language.title': '言語', + 'language.hint': '不足しているテキストは順にフォールバックします。先頭が表示言語です。', + 'language.add': '言語を追加', + 'language.moveUp': '上へ', + 'language.moveDown': '下へ', + 'language.remove': '削除', } satisfies Record /** Spanish dictionary, checked against the English keys. */ export const es = { 'language.title': 'Idioma', + 'language.hint': 'El texto que falta se busca en orden; el primero es el idioma de la interfaz.', + 'language.add': 'Añadir idioma', + 'language.moveUp': 'Subir', + 'language.moveDown': 'Bajar', + 'language.remove': 'Quitar', } satisfies Record /** French dictionary, checked against the English keys. */ export const fr = { 'language.title': 'Langue', + 'language.hint': 'Le texte manquant est essayé dans l’ordre ; la première langue est celle de l’interface.', + 'language.add': 'Ajouter une langue', + 'language.moveUp': 'Monter', + 'language.moveDown': 'Descendre', + 'language.remove': 'Retirer', } satisfies Record /** German dictionary, checked against the English keys. */ export const de = { 'language.title': 'Sprache', + 'language.hint': 'Fehlender Text wird der Reihe nach versucht; die erste Sprache ist die Oberflächensprache.', + 'language.add': 'Sprache hinzufügen', + 'language.moveUp': 'Nach oben', + 'language.moveDown': 'Nach unten', + 'language.remove': 'Entfernen', } satisfies Record /** Russian dictionary, checked against the English keys. */ export const ru = { 'language.title': 'Язык', + 'language.hint': 'Отсутствующий текст ищется по порядку; первый язык — язык интерфейса.', + 'language.add': 'Добавить язык', + 'language.moveUp': 'Вверх', + 'language.moveDown': 'Вниз', + 'language.remove': 'Удалить', } satisfies Record /** Portuguese dictionary, checked against the English keys. */ export const pt = { 'language.title': 'Idioma', + 'language.hint': 'O texto em falta é procurado por ordem; o primeiro idioma é o da interface.', + 'language.add': 'Adicionar idioma', + 'language.moveUp': 'Mover para cima', + 'language.moveDown': 'Mover para baixo', + 'language.remove': 'Remover', } satisfies Record --- /dev/null +++ b/packages/client/locale/tests/ordered-preference.client.spec.ts @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +/** + * Ordered locale preference: a stored list (or the legacy single string) is + * walked per key before English and the raw key, while the first registered + * entry stays the active interface language. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { stubConfigForm, type StubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' + +const stubLanguages = (...tags: string[]): void => { + vi.stubGlobal('navigator', { languages: tags, language: tags[0] ?? '' }) +} + +const make = (host?: StubConfigForm): { + ctx: Context + svc: LocaleRuntime + events: LocaleSnapshot[] +} => { + const ctx = new Context() + const events: LocaleSnapshot[] = [] + ctx.on('locale/change', (snapshot) => { events.push(snapshot) }) + return { ctx, svc: new LocaleRuntime(ctx, host?.scope), events } +} + +describe('ordered locale preference', () => { + beforeEach(() => { + stubLanguages('zh-CN') + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reads a legacy stored string as a one-element preference', () => { + stubLanguages('en-US') + const { svc } = make() + expect(svc.getLocale().preference).toEqual(['en']) + expect(svc.getLocale().active).toBe('en') + }) + + it('walks the ordered preference before English and the raw key', () => { + const { svc } = make() + svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + // Only zh owns this key; the ja chain (ja -> en) has no dictionary for it. + svc.register('ns', 'zh', { onlyZh: '中文のみ' }) + const t = svc.bind('ns') + svc.setPreferences(['ja', 'zh']) + expect(t('onlyZh')).toBe('中文のみ') + svc.setPreferences(['ja']) + expect(t('onlyZh')).toBe('onlyZh') + }) + + it('answers from the earliest preferred language that owns the key', () => { + const { svc } = make() + svc.register('ns', 'zh', { hello: '你好' }) + svc.register('ns', 'en', { hello: 'Hello' }) + svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + svc.register('ns', 'ja', { hello: 'こんにちは' }) + const t = svc.bind('ns') + svc.setPreferences(['zh', 'ja', 'en']) + expect(t('hello')).toBe('你好') + svc.setPreferences(['ja', 'zh', 'en']) + expect(t('hello')).toBe('こんにちは') + expect(svc.getLocale().active).toBe('ja') + }) + + it('applies the ordered list to package text (resolveText)', () => { + const { svc } = make() + svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + const text = { zh: '中文标题', ja: '日本語タイトル', en: 'English title' } + svc.setPreferences(['ja', 'zh']) + expect(svc.resolveText(text)).toBe('日本語タイトル') + svc.setPreferences(['zh', 'ja']) + expect(svc.resolveText(text)).toBe('中文标题') + }) + + it('persists the ordered list and still writes a legacy single string from setLocale', () => { + const host = stubConfigForm() + const { svc } = make(host) + svc.setPreferences(['zh', 'en']) + expect(host.set).toHaveBeenLastCalledWith('preference', ['zh', 'en']) + svc.setLocale('en') + expect(host.set).toHaveBeenLastCalledWith('preference', 'en') + expect(svc.getLocale().preference).toEqual(['en']) + }) + + it('keeps the browser language order instead of narrowing to the first match', () => { + stubLanguages('ja-JP', 'zh-CN') + const { svc } = make() + svc.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }) + expect(svc.getLocale().preference).toEqual(['ja', 'zh']) + expect(svc.getLocale().active).toBe('ja') + }) + + it('reorders without a locale/change event while the active language stays', () => { + const { svc, events } = make() + svc.setPreferences(['en', 'zh']) + expect(events).toHaveLength(1) + svc.setPreferences(['en']) + expect(events).toHaveLength(1) + svc.setPreferences(['zh', 'en']) + expect(events).toHaveLength(2) + expect(events[1]!.active).toBe('zh') + }) + + it('normalizes duplicates and rejects unknown or empty preferences', () => { + const { svc } = make() + svc.setPreferences(['EN', 'en']) + expect(svc.getLocale().preference).toEqual(['en']) + expect(() => { svc.setPreferences(['fr']) }).toThrow('not registered') + expect(() => { svc.setPreferences([]) }).toThrow('must not be empty') + }) + + it('adopts an ordered Host preference and follows later list updates', () => { + const host = stubConfigForm() + const { svc } = make(host) + host.publish({ status: 'ready', value: { preference: ['zh', 'en'] }, revision: 1, writable: true }) + expect(svc.getLocale().active).toBe('zh') + expect(svc.getLocale().preference).toEqual(['zh', 'en']) + host.publish({ status: 'ready', value: { preference: ['en', 'zh'] }, revision: 2, writable: true }) + expect(svc.getLocale().active).toBe('en') + expect(svc.getLocale().preference).toEqual(['en', 'zh']) + }) +}) --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -19,6 +19,15 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] +const COPY: Record = { + 'language.title': 'Language', + 'language.hint': 'Tried in order for missing text.', + 'language.add': 'Add language', + 'language.moveUp': 'Move up', + 'language.moveDown': 'Move down', + 'language.remove': 'Remove', +} + function emptySessions() { const store = createSnapshotStore( { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) @@ -35,11 +44,12 @@ const noAttention: AttentionSnapshot = new Map() const useSessionStatus: LanguageRowComponentProps['useSessionStatus'] = selector => selector(noAttention) -function mount(active = 'en') { +function mount(preference = ['en'], active = 'en') { // Real store instance — the sanctioned zero-machinery path for tests. const store = createLanguageRowStore().create() - store.actions.sync(active, OPTIONS, 0) + store.actions.sync(active, preference, OPTIONS, 0) const setLocale = vi.fn() + const setPreferences = vi.fn() const props: LanguageRowComponentProps = { useSessions: emptySessions(), useSessionStatus, @@ -49,46 +59,58 @@ useContextMenu: contextMenuHook(), useStore: bindSnapshotSelector(store), actions: store.actions, - t: (key: string) => key === 'language.title' ? 'Language' : key, + t: (key: string) => COPY[key] ?? key, setLocale, + setPreferences, } render() - return { store, setLocale } + return { store, setLocale, setPreferences } } describe('LanguageRow', () => { - it('shows the title and the active locale label on the selector pill', () => { - mount('en') + it('shows the title, hint, and one row per preferred language', () => { + mount(['en']) expect(screen.getByText('Language')).toBeDefined() - const trigger = screen.getByRole('button', { name: /English/ }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByText('English')).toBeDefined() + expect(screen.queryByText('中文')).toBeNull() + expect(screen.getByRole('button', { name: 'Add language' })).toBeDefined() }) - it('opens the menu, selects a locale, and closes', () => { - const b = mount('en') - const trigger = screen.getByRole('button', { name: /English/ }) - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') + it('moves an entry down within the preference list', () => { + const b = mount(['en', 'zh']) + const down = screen.getAllByRole('button', { name: 'Move down' }) + fireEvent.click(down[0]!) + expect(b.setPreferences).toHaveBeenLastCalledWith(['zh', 'en']) + }) + + it('moves an entry up within the preference list', () => { + const b = mount(['zh', 'en']) + const up = screen.getAllByRole('button', { name: 'Move up' }) + fireEvent.click(up[1]!) + expect(b.setPreferences).toHaveBeenLastCalledWith(['en', 'zh']) + }) + + it('removes a non-last entry but never the last one', () => { + const b = mount(['en', 'zh']) + fireEvent.click(screen.getAllByRole('button', { name: 'Remove' })[0]!) + expect(b.setPreferences).toHaveBeenLastCalledWith(['zh']) + cleanup() + mount(['en']) + const button = screen.getByRole('button', { name: 'Remove' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + }) + + it('adds a remaining language through the menu', () => { + const b = mount(['en']) + fireEvent.click(screen.getByRole('button', { name: 'Add language' })) fireEvent.click(screen.getByRole('menuitem', { name: '中文' })) - expect(b.setLocale).toHaveBeenCalledWith('zh') - expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(b.setPreferences).toHaveBeenLastCalledWith(['en', 'zh']) expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() }) - it('closes on outside pointerdown without selecting', () => { - const b = mount('en') - fireEvent.click(screen.getByRole('button', { name: /English/ })) - expect(screen.getByRole('menuitem', { name: '中文' })).toBeDefined() - fireEvent.pointerDown(document.body) - expect(screen.queryByRole('menuitem', { name: '中文' })).toBeNull() - expect(b.setLocale).not.toHaveBeenCalled() - }) - - it('follows store changes; an unknown active id falls back to the id itself', () => { - const b = mount('en') - act(() => { b.store.actions.sync('zh', OPTIONS, 1) }) - expect(screen.getByRole('button', { name: /中文/ })).toBeDefined() - act(() => { b.store.actions.sync('fr', OPTIONS, 2) }) - expect(screen.getByRole('button', { name: /fr/ })).toBeDefined() + it('follows store changes; an unknown id falls back to the id itself', () => { + const b = mount(['en']) + act(() => { b.store.actions.sync('fr', ['fr'], OPTIONS, 1) }) + expect(screen.getByText('fr')).toBeDefined() }) }) --- a/packages/client/locale/tests/settings-store.client.spec.ts +++ b/packages/client/locale/tests/settings-store.client.spec.ts @@ -7,24 +7,26 @@ describe('createLanguageRowStore', () => { it('init shape: empty mirror with revision at -1', () => { const store = createLanguageRowStore().create() - expect(store.getSnapshot()).toEqual({ active: '', options: [], revision: -1 }) + expect(store.getSnapshot()).toEqual({ active: '', preference: [], options: [], revision: -1 }) }) - it('sync mirrors the snapshot and advances the revision', () => { + it('sync mirrors the ordered preference and advances the revision', () => { const store = createLanguageRowStore().create() - store.actions.sync('zh', OPTIONS, 0) - expect(store.getSnapshot()).toEqual({ active: 'zh', options: OPTIONS, revision: 0 }) - store.actions.sync('en', OPTIONS, 1) - expect(store.getSnapshot().active).toBe('en') + store.actions.sync('en', ['en', 'zh'], OPTIONS, 0) + expect(store.getSnapshot()).toEqual({ active: 'en', preference: ['en', 'zh'], options: OPTIONS, revision: 0 }) + store.actions.sync('zh', ['zh', 'en'], OPTIONS, 1) + expect(store.getSnapshot().active).toBe('zh') + expect(store.getSnapshot().preference).toEqual(['zh', 'en']) expect(store.getSnapshot().revision).toBe(1) }) it('revision guard drops stale and duplicate writes', () => { const store = createLanguageRowStore().create() - store.actions.sync('en', OPTIONS, 5) - store.actions.sync('zh', OPTIONS, 4) - store.actions.sync('zh', OPTIONS, 5) + store.actions.sync('en', ['en'], OPTIONS, 5) + store.actions.sync('zh', ['zh'], OPTIONS, 4) + store.actions.sync('zh', ['zh'], OPTIONS, 5) expect(store.getSnapshot().active).toBe('en') + expect(store.getSnapshot().preference).toEqual(['en']) expect(store.getSnapshot().revision).toBe(5) }) }) --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -104,7 +104,7 @@ expect(onChange).toHaveBeenLastCalledWith('en') await fiber.dispose() const calls = onChange.mock.calls.length - b.ctx.emit('locale/change', { active: 'zh', locales: [], revision: 99 }) + b.ctx.emit('locale/change', { active: 'zh', preference: ['zh'], locales: [], revision: 99 }) expect(onChange).toHaveBeenCalledTimes(calls) } finally { ready.resolve({ languages: ['en-US'], preference: 'zh' }) --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -259,7 +259,7 @@ }) locale = 'zh' - root.emit('locale/change', { active: 'zh', locales: [], revision: 1 } as LocaleSnapshot) + root.emit('locale/change', { active: 'zh', preference: ['zh'], locales: [], revision: 1 } as LocaleSnapshot) expect(first.menu.getSnapshot().open).toBe(true) expect(second.menu.getSnapshot().open).toBe(true) await tick() --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -174,7 +174,7 @@ useConfigLedger: bindSnapshotSelector(ledger), usePluginTabs: bindSnapshotSelector(tabLedger), useBundleDetails: bindSnapshotSelector(bundleDetails), - useLocale: bindSnapshotSelector(createSnapshotStore({ active: 'en', locales: [], revision: 0 })), + useLocale: bindSnapshotSelector(createSnapshotStore({ active: 'en', preference: ['en'], locales: [], revision: 0 })), useConfigurations: bindSnapshotSelector(createSnapshotStore({ status: 'ready', error: null, view: { writable: true, hasDocument: true, namespaces: Object.keys(forms).map(ns => ({ --- a/packages/client/ui-plugin-market/tests/discover.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/discover.client.spec.tsx @@ -81,7 +81,7 @@ function renderTab(state: Partial = {}, dictionary: Record = en) { const store = createSnapshotStore({ ...READY, ...state }) const language = dictionary === en ? 'en' : 'zh' - const locale = createSnapshotStore({ active: language, locales: [], revision: 0 }) + const locale = createSnapshotStore({ active: language, preference: [language], locales: [], revision: 0 }) const actions = { ensure: vi.fn(), reload: vi.fn(), select: vi.fn(), search: vi.fn(), more: vi.fn(), refresh: vi.fn(), retry: vi.fn(), toggleSources: vi.fn(), --- a/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx +++ b/packages/client/ui-plugin-market/tests/plugin-detail.client.spec.tsx @@ -24,7 +24,7 @@ function setup(initial: DetailsState = {}, language = 'en') { const details = createSnapshotStore(initial) - const locale = createSnapshotStore({ active: language, locales: [], revision: 0 }) + const locale = createSnapshotStore({ active: language, preference: [language], locales: [], revision: 0 }) const face = { loadDetail: vi.fn(), retryDetail: vi.fn(), detailImage: vi.fn((document: PluginDetailDocumentId, destination: string) => `https://dsh.example/api/plugin-market/detail-file?document=${document}&path=${destination}`), @@ -47,7 +47,7 @@ // Relative links stay text. expect(screen.queryByRole('link', { name: 'guide' })).toBeNull() expect(screen.getByText('guide')).toBeTruthy() - act(() => { locale.set({ active: 'zh', locales: [], revision: 1 }) }) + act(() => { locale.set({ active: 'zh', preference: ['zh'], locales: [], revision: 1 }) }) expect(face.loadDetail).toHaveBeenLastCalledWith(REQUEST, 'zh') view.rerender() expect(face.loadDetail).toHaveBeenCalledTimes(2) --- a/packages/experimental/client-ui-cot-translation/tests/translation-body.client.spec.tsx +++ b/packages/experimental/client-ui-cot-translation/tests/translation-body.client.spec.tsx @@ -15,7 +15,7 @@ function fixture(text = 'Original paragraph') { const preferences = createSnapshotStore({ provider: 'google', targetLanguage: 'auto' }) - const locale = createSnapshotStore({ active: 'zh', locales: [], revision: 0 }) + const locale = createSnapshotStore({ active: 'zh', preference: ['zh'], locales: [], revision: 0 }) const translationLimit = createSnapshotStore(4000) const translate = vi.fn(async request => `${request.provider}:${request.targetLanguage}:${request.text}`) const props = { rawText: 'Raw model reasoning', text, running: false, translate, renderFactorySlot, t: makeTranslate(en), @@ -51,7 +51,7 @@ first.resolve('outdated translation') await act(async () => { await first.promise }) expect(view.queryByText('outdated translation')).toBeNull() - act(() => { b.locale.set({ active: 'en', locales: [], revision: 1 }) }) + act(() => { b.locale.set({ active: 'en', preference: ['en'], locales: [], revision: 1 }) }) expect(b.translate).toHaveBeenCalledTimes(2) act(() => { b.preferences.set({ provider: 'bing', targetLanguage: 'auto' }) }) await view.findByText('bing:en:Original paragraph')