From d260d094c7ae8f8ab469fa1efd864e005cbe68cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=9F=E6=B4=BB=E4=B8=8D=E6=98=93=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=8F=B9=E6=B0=94?= <121737713+ChthoIly@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:34:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(panel):=20=E6=94=AF=E6=8C=81=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=88=87=E6=8D=A2=E6=8F=92=E4=BB=B6=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dsh-browser/src/background/index.ts | 8 +- extensions/dsh-browser/src/i18n.ts | 119 ++++++++++++++++- extensions/dsh-browser/src/panel/App.tsx | 47 ++++++- extensions/dsh-browser/src/panel/main.tsx | 19 ++- extensions/dsh-browser/src/panel/strings.ts | 15 +++ extensions/dsh-browser/tests/i18n.spec.ts | 85 +++++++++++- .../dsh-browser/tests/panel-language.spec.ts | 123 ++++++++++++++++++ 7 files changed, 400 insertions(+), 16 deletions(-) create mode 100644 extensions/dsh-browser/tests/panel-language.spec.ts diff --git a/extensions/dsh-browser/src/background/index.ts b/extensions/dsh-browser/src/background/index.ts index 9619bd97e..23fb65cd6 100644 --- a/extensions/dsh-browser/src/background/index.ts +++ b/extensions/dsh-browser/src/background/index.ts @@ -72,7 +72,7 @@ import { type ApprovalPrompt, type ApprovalRequest, } from '../security/approval.ts' -import { getUiLocale } from '../i18n.ts' +import { getUiLocale, loadUiLocalePreference, watchUiLocalePreference } from '../i18n.ts' import { InteractionResponseRouter } from './responses.ts' import { actionCoveredByTrustedOrigins, @@ -510,6 +510,12 @@ function normalizeSettings(candidate: Settings): Settings { } } +// The side panel owns the UI language preference, but approval notifications +// and tool error strings are formatted here, so mirror the same key into this +// worker's cache and keep following panel writes. +void loadUiLocalePreference() +watchUiLocalePreference() + /** Settings load is shared by every lazy connection trigger. */ let settingsLoaded = false const settingsReady = loadSettings().then((loaded) => { diff --git a/extensions/dsh-browser/src/i18n.ts b/extensions/dsh-browser/src/i18n.ts index 721730335..55a9eddaf 100644 --- a/extensions/dsh-browser/src/i18n.ts +++ b/extensions/dsh-browser/src/i18n.ts @@ -1,6 +1,28 @@ +/** + * Extension UI language model. + * + * The browser language is only the default: whatever Chrome is set to decides + * the panel the first time it opens. The user can override that with an + * explicit language, which must survive panel reloads and also reach the + * background worker that formats approval notifications and tool errors. + * + * The preference is therefore a small `chrome.storage.local` value owned by + * the panel (like the text scale), mirrored into a module-level cache so the + * synchronous call sites keep working. The background watches the same key and + * refreshes its cache when a panel writes it. + * + * @module + */ + /** Languages supported by the extension UI. */ export type UiLocale = 'en' | 'zh' +/** What the user picked in Settings; `auto` follows the browser language. */ +export type UiLocalePreference = 'auto' | UiLocale + +/** `chrome.storage.local` key holding the persisted preference. */ +export const UI_LOCALE_STORAGE_KEY = 'dshPanelUiLocale' + /** * Chinese browser locales use Chinese; every other locale deliberately falls * back to English so an untranslated third language never leaks into the UI. @@ -10,12 +32,17 @@ export function localeFromLanguage(language: string | null | undefined): UiLocal return normalized === 'zh' || normalized.startsWith('zh-') ? 'zh' : 'en' } +/** Snap a stored or user-supplied value onto a supported preference. */ +export function normalizeUiLocalePreference(value: unknown): UiLocalePreference { + return value === 'en' || value === 'zh' ? value : 'auto' +} + /** Read the browser's first preferred language, with the extension UI locale as a fallback. */ -export function getUiLocale(): UiLocale { +export function browserLanguage(): string | undefined { let language: string | undefined if (typeof navigator !== 'undefined') { const preferred = navigator.languages?.find((candidate) => candidate.trim() !== '') ?? navigator.language - language = preferred.trim() === '' ? undefined : preferred + language = preferred?.trim() === '' ? undefined : preferred } if (language === undefined && typeof chrome !== 'undefined' && chrome.i18n?.getUILanguage !== undefined) { try { @@ -24,5 +51,91 @@ export function getUiLocale(): UiLocale { // A partially mocked or stale extension context may expose an unusable API. } } - return localeFromLanguage(language) + return language +} + +/** + * Resolve the preference to the locale text is rendered in. An explicit choice + * wins outright; `auto` keeps the browser-derived behavior. + */ +export function resolveUiLocale( + preference: unknown, + language: string | null | undefined = browserLanguage(), +): UiLocale { + const normalized = normalizeUiLocalePreference(preference) + return normalized === 'auto' ? localeFromLanguage(language) : normalized +} + +/** Latest preference loaded by this context; `auto` until storage says otherwise. */ +let cachedPreference: UiLocalePreference = 'auto' + +/** Update the in-memory cache without touching storage. */ +export function setUiLocalePreference(value: unknown): UiLocalePreference { + cachedPreference = normalizeUiLocalePreference(value) + return cachedPreference +} + +/** The preference currently in effect, as opposed to the resolved locale. */ +export function getUiLocalePreference(): UiLocalePreference { + return cachedPreference +} + +/** Locale for text generated right now; the manual choice beats the browser language. */ +export function getUiLocale(): UiLocale { + return resolveUiLocale(cachedPreference) +} + +/** Load the persisted preference into the cache; falls back to the cache on failure. */ +export async function loadUiLocalePreference(): Promise { + try { + if (typeof chrome === 'undefined' || chrome.storage?.local === undefined) return cachedPreference + const stored = await chrome.storage.local.get(UI_LOCALE_STORAGE_KEY) + return setUiLocalePreference(stored[UI_LOCALE_STORAGE_KEY]) + } catch { + return cachedPreference + } +} + +/** Persist a choice and apply it to this context immediately. */ +export function saveUiLocalePreference(value: unknown): UiLocalePreference { + const preference = setUiLocalePreference(value) + try { + if (typeof chrome !== 'undefined' && chrome.storage?.local !== undefined) { + void chrome.storage.local.set({ [UI_LOCALE_STORAGE_KEY]: preference }).catch(() => {}) + } + } catch { + // A storage failure must not break the in-memory switch. + } + return preference +} + +/** + * Follow preference changes made by another context (a second side panel, or a + * panel writing while the background worker formats a message). Returns an + * unsubscribe function; a context without `storage.onChanged` gets a no-op. + */ +export function watchUiLocalePreference(onChange?: (preference: UiLocalePreference) => void): () => void { + if (typeof chrome === 'undefined' || chrome.storage?.onChanged === undefined) return () => {} + const listener = ( + changes: Record, + areaName: string, + ): void => { + if (areaName !== 'local') return + const change = changes[UI_LOCALE_STORAGE_KEY] + if (change === undefined) return + const preference = setUiLocalePreference(change.newValue) + onChange?.(preference) + } + try { + chrome.storage.onChanged.addListener(listener) + } catch { + return () => {} + } + return () => { + try { + chrome.storage.onChanged.removeListener(listener) + } catch { + // The context may already be torn down; there is nothing left to detach. + } + } } diff --git a/extensions/dsh-browser/src/panel/App.tsx b/extensions/dsh-browser/src/panel/App.tsx index c772c28f9..32fadfc69 100644 --- a/extensions/dsh-browser/src/panel/App.tsx +++ b/extensions/dsh-browser/src/panel/App.tsx @@ -7,7 +7,7 @@ * @module */ -import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { BRIDGE_SESSION_PURGE_METHOD, DEFAULT_SNAPSHOT_MAX_CHARS } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { BridgeCaps } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' import type { ServerFrame } from '@yuxianglin/dsh-bridge-browser/src/protocol.ts' @@ -17,7 +17,14 @@ import { connectPanel, PanelRpcError, type BridgeInstance, type DevToolsStatus, import { renderMarkdown } from './markdown.ts' import whaleUrl from '../../assets/icons/deepseek-256.png' import type { ApprovalDecision, ApprovalRequest } from '../security/approval.ts' -import { getUiLocale } from '../i18n.ts' +import { + getUiLocale, + getUiLocalePreference, + saveUiLocalePreference, + watchUiLocalePreference, + type UiLocale, + type UiLocalePreference, +} from '../i18n.ts' import { PANEL_COPY, type PanelCopy } from './strings.ts' import { applyUiScale, @@ -739,7 +746,8 @@ const ToolActivity = memo(function ToolActivity({ row, copy }: { row: Row; copy: }) export function App(): React.JSX.Element { - const locale = useMemo(() => getUiLocale(), []) + const [locale, setLocale] = useState(() => getUiLocale()) + const [localePreference, setLocalePreference] = useState(() => getUiLocalePreference()) const copy = PANEL_COPY[locale] const [api] = useState(() => connectPanel()) const [state, setState] = useState('stopped') @@ -921,6 +929,25 @@ export function App(): React.JSX.Element { saveUiScale(next) } + // UI language: the choice lives in storage (see `i18n.ts`), so this view + // applies it immediately and keeps the document language/title in sync. The + // watcher catches a change made from another side-panel window. + useEffect(() => { + document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en' + document.title = copy.documentTitle + }, [locale, copy]) + + useEffect(() => watchUiLocalePreference((preference) => { + setLocalePreference(preference) + setLocale(getUiLocale()) + }), []) + + function changeUiLocale(next: UiLocalePreference): void { + saveUiLocalePreference(next) + setLocalePreference(getUiLocalePreference()) + setLocale(getUiLocale()) + } + // Settings: seed from storage, then let the panel own the form. useEffect(() => { void chrome.storage.local.get('dshSettings').then((stored) => { @@ -2545,6 +2572,20 @@ export function App(): React.JSX.Element { +
+ +