diff --git a/AGENTS.md b/AGENTS.md index 6ef601e..1a7a25f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -407,6 +407,7 @@ interface BetterSidebarService { | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | +| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好) | | **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 | --- diff --git a/README.md b/README.md index e4d8515..c363584 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86 - **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开) - **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 - **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) +- **🌏 多语言**:界面文案跟随 DSH 的语言设置(zh/en)实时切换——Host 偏好优先于浏览器语言,词典注册进 DSH 的 i18n 命名空间;切换语言无需刷新 ## ⌨️ 快捷键 diff --git a/docs/plans/2026-08-12-follow-dsh-i18n-design.md b/docs/plans/2026-08-12-follow-dsh-i18n-design.md new file mode 100644 index 0000000..ef55f5a --- /dev/null +++ b/docs/plans/2026-08-12-follow-dsh-i18n-design.md @@ -0,0 +1,60 @@ +# 跟随 DSH 的 i18n(ctx.locale)设计 + +> 2026-08-12 · 随 v0.8.2 发布 + +## 背景 + +better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读 +`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot +system's locale seat, so it reads the browser language directly")。问题: + +- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`), + 用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。 +- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。 + +DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`): + +- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace + (uSES 安全,revision 递增),`locale/change` 事件在切换时触发; +- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并 + `LocaleNamespaceMap`);查询链 ns → common → zh → key; +- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。 + +## 设计决策 + +1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH + monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此 + Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片: + `getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入 + `@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。 +2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder: + `apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale; + 无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom + 测试与非标准组合的降级路径)。 +3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。 + 在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)` + (该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树 + 重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks) + 在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身 + 订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision), + 自动跟随。 +4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走 + 非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例); + 键型安全由本地 `const en: Record` + 测试键集相等断言保证。 +5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer + (fiber 卸载时撤销,HMR 重激活不抛 "already registered")。 +6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走 + `isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。 + +## 实施偏差记录 + +- 无。计划与实施一致。 +- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。 + +## 验收 + +- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts` + 覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、 + relativeTime 链路、命名空间名、zh/en 键集相等。 +- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en + → 侧边栏中文。 diff --git a/package.json b/package.json index 59b1d5e..06799d2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-ui-conversation" ], diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 51b44df..426593e 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -104,6 +104,15 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { ctx, store } = props + // Copy freshness: re-render the whole tree when the DSH locale switches. + // The module-level t() reads the active locale at call time, so a root + // re-render alone re-localizes every panel (no memo barriers below). + const localeRevision = useSyncExternalStore( + useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]), + useCallback(() => ctx.locale.getSnapshot().active, [ctx]), + ) + void localeRevision + // Narrow (mobile) viewports collapse the two panels into one: the right // panel becomes a full-width drawer holding BOTH workbenches, the bottom // panel (and its toggle button) disappears, and the layout push is diff --git a/src/client/index.tsx b/src/client/index.tsx index 6d673be..4784028 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -22,12 +22,13 @@ import { registerImeGuard } from './ime-guard.ts' import { loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' -import { t } from './locales.ts' +import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts' import css from './sidebar.module.css' import './layout.css' -/** Services required before mounting (provided by the client runtime). */ -export const inject = ['slots', 'sessions', 'connection', 'workspaces'] +/** Services required before mounting (provided by the client runtime; the + * locale service backs the sidebar's copy — see locales.ts). */ +export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale'] /** * Error boundary over the sidebar tree: a render error must never blank the @@ -69,6 +70,17 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string * @param ctx - the client cordis context (slots, sessions). */ export function apply(ctx: Context): void { + // The sidebar follows the DSH i18n system: attach the locale service so + // the module-level t()/isZh() resolve the Host-backed language preference + // (and switch live — the Sidebar root subscribes to it), and register the + // plugin's dictionaries into the shared locale registry. The disposers + // run on fiber disposal, so re-activation (HMR) re-registers cleanly. + attachLocale(ctx.locale) + ctx.effect(() => { + const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh) + const offEn = ctx.locale.register(LOCALE_NS, 'en', en) + return () => { offZh(); offEn() } + }, 'dsh-better-sidebar: dictionaries') // One store instance per activation: production code creates it only here, // then hands it to the mounted panel and closes over it in the slot // registrations (the official createXXXStore() factory rule — no diff --git a/src/client/locales.ts b/src/client/locales.ts index f69ba1d..494c912 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -1,11 +1,16 @@ /** - * Minimal zh/en copy for the sidebar. The plugin renders outside the slot - * system's locale seat, so it reads the browser language directly (the DSH - * locale preference and the browser language agree in practice; both default - * from navigator.language anyway). + * Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system: + * the client apply attaches the locale service (`ctx.locale`, provided by + * `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and + * `t()`/`isZh()` resolve the active locale from it — the Host-backed + * `locale.preference` wins over the raw browser language and switches live. + * Without an attached service (standalone/test compositions) the browser + * language is used, matching the previous behavior. The dictionaries are + * also registered into the DSH locale registry under {@link LOCALE_NS}. */ -const zh = { +/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ +export const zh = { explorer: '资源管理器', git: '源代码管理', terminal: '终端', @@ -204,7 +209,8 @@ const zh = { taskKillError: '终止失败', } -const en: Record = { +/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */ +export const en: Record = { explorer: 'Explorer', git: 'Source Control', terminal: 'Terminal', @@ -403,13 +409,42 @@ const en: Record = { taskKillError: 'Kill failed', } -/** Translate a copy key in the browser's language (zh → zh, else en). */ +/** + * The dictionary namespace this plugin owns in the DSH locale registry + * (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name). + */ +export const LOCALE_NS = 'betterSidebar' + +/** The DSH locale service attached by the client apply (absent → browser detection). */ +let localeService: { getSnapshot(): { active: string } } | undefined + +/** + * Attach (or detach, with undefined) the DSH locale service. The sidebar + * mounts its own React root outside the slot system's locale seat, so the + * service rides this module-level holder: components keep calling the plain + * `t()` function, and the Sidebar root's locale subscription re-renders the + * whole tree on switches. + */ +export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void { + localeService = service +} + +/** + * The active locale id ('zh' | 'en'): the DSH locale service's snapshot when + * attached, else the browser language. + */ +function activeLocale(): string { + return localeService?.getSnapshot().active + ?? (typeof navigator !== 'undefined' ? navigator.language : '') + ?? 'en' +} + +/** Translate a copy key in the active locale (zh → zh, else en). */ export type CopyKey = keyof typeof zh /** Translate a copy key; `{name}` placeholders interpolate from `params`. */ export function t(key: CopyKey, params?: Record): string { - const lang = typeof navigator !== 'undefined' ? navigator.language : 'en' - const dict = lang.toLowerCase().startsWith('zh') ? zh : en + const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en let text = dict[key] if (params !== undefined) { for (const [name, value] of Object.entries(params)) { @@ -419,9 +454,9 @@ export function t(key: CopyKey, params?: Record): strin return text } -/** Whether the browser language is Chinese (used for selectors). */ +/** Whether the active locale is Chinese (used for selectors). */ export function isZh(): boolean { - return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh') + return activeLocale().toLowerCase().startsWith('zh') } /** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */ diff --git a/src/client/xlsx-view.tsx b/src/client/xlsx-view.tsx index 7977f2d..023583c 100644 --- a/src/client/xlsx-view.tsx +++ b/src/client/xlsx-view.tsx @@ -11,7 +11,7 @@ */ import { useEffect, useRef, useState } from 'react' import { mediaUrl } from './api.ts' -import { t } from './locales.ts' +import { isZh, t } from './locales.ts' import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx' import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts' import css from './sidebar.module.css' @@ -54,14 +54,14 @@ export function XlsxView(props: OfficeViewProps): JSX.Element { const XLSX = await import('xlsx') const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets') const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core') - // Locales pick the browser language; falls back to en-US. - const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh') - const localePack = await (isZh + // Locales follow the DSH i18n system (the active locale); falls + // back to the browser language, then en-US. + const localePack = await (isZh() ? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null) : import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null)) const wb = XLSX.read(buf, { type: 'array' }) - const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US + const locale = isZh() ? LocaleType.ZH_CN : LocaleType.EN_US const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale) if (cancelled) return diff --git a/src/context-types.ts b/src/context-types.ts index de751a4..6cc4c44 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -265,6 +265,23 @@ export interface SidebarSessionsService { refreshSubagents?(parentSessionId: string): Promise } +/** + * The client locale service face (mirror of @deepseek-ai/dsh-client-locale's + * LocaleService — only the slices the sidebar touches). The sidebar follows + * the DSH i18n system: the active locale is the Host-backed preference + * (`locale.preference` in settings.yaml) rather than the raw browser + * language, and the sidebar's zh/en dictionaries register into the service's + * namespace registry under `betterSidebar`. + */ +export interface SidebarLocaleService { + /** Current immutable locale snapshot (uSES-safe; `active` is 'zh' | 'en' today). */ + getSnapshot(): { active: string } + /** Subscribe to snapshot changes (locale switch or dictionary registration). */ + subscribe(fn: () => void): () => void + /** Register one locale's dictionary for a namespace; returns the disposer. */ + register(ns: string, locale: string, dict: Record): () => void +} + /** The composer draft face the sidebar reaches through `ctx.conversation.input`. */ export interface SidebarSessionInput { /** The live input store (draft read for append). */ @@ -372,6 +389,12 @@ declare module 'cordis' { settings: SidebarSettingsService invariants: SidebarInvariantsService tools: SidebarToolsService + /** + * The client locale service (`@deepseek-ai/dsh-client-locale`): the + * sidebar's copy follows its active locale and registers its + * dictionaries under the `betterSidebar` namespace. Client side only. + */ + locale: SidebarLocaleService /** * The host background-task registry (`ctx.get('tasks')`; optional — the * sidebar routes degrade to a 503 when the deployment lacks it). diff --git a/tests/locales.spec.ts b/tests/locales.spec.ts new file mode 100644 index 0000000..06f763a --- /dev/null +++ b/tests/locales.spec.ts @@ -0,0 +1,109 @@ +/** + * Locale-following spec: the sidebar copy follows the DSH locale service + * (`ctx.locale`, provided by @deepseek-ai/dsh-client-locale) when attached + * through `attachLocale`, and falls back to the browser language otherwise. + * Covers attach/detach, live switching, dictionary parity, and placeholder + * interpolation. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { LOCALE_NS, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts' + +/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */ +class FakeLocale { + active: string = 'en' + getSnapshot(): { active: string } { + return { active: this.active } + } + subscribe(_fn: () => void): () => void { + return () => {} + } + register(_ns: string, _locale: string, _dict: Record): () => void { + return () => {} + } + switchTo(id: string): void { + this.active = id + } +} + +/** Point the browser-language fallback at a specific language (undefined = none). */ +function stubNavigatorLanguage(lang: string | undefined): void { + Object.defineProperty(globalThis, 'navigator', { + value: lang === undefined ? undefined : { language: lang }, + configurable: true, + }) +} + +afterEach(() => { + attachLocale(undefined) + stubNavigatorLanguage(undefined) +}) + +describe('locales (DSH i18n following)', () => { + it('falls back to the browser language without an attached service', () => { + stubNavigatorLanguage('en-US') + expect(t('explorer')).toBe('Explorer') + expect(isZh()).toBe(false) + + stubNavigatorLanguage('zh-CN') + expect(t('explorer')).toBe('资源管理器') + expect(isZh()).toBe(true) + }) + + it('defaults to English when no locale service and no browser language are available', () => { + expect(t('explorer')).toBe('Explorer') + expect(isZh()).toBe(false) + }) + + it('follows the attached locale service instead of the browser language', () => { + stubNavigatorLanguage('en-US') + const locale = new FakeLocale() + attachLocale(locale) + + locale.switchTo('zh') + expect(t('git')).toBe('源代码管理') + expect(isZh()).toBe(true) + + // Live switch: the service's active locale wins even though the + // browser still asks for en-US. + locale.switchTo('en') + expect(t('git')).toBe('Source Control') + expect(isZh()).toBe(false) + }) + + it('detaches back to the browser-language fallback', () => { + const locale = new FakeLocale() + locale.switchTo('zh') + attachLocale(locale) + expect(t('terminal')).toBe('终端') + + stubNavigatorLanguage('en-US') + attachLocale(undefined) + expect(t('terminal')).toBe('Terminal') + }) + + it('interpolates {name} placeholders in the active locale', () => { + const locale = new FakeLocale() + attachLocale(locale) + locale.switchTo('zh') + expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分钟前') + locale.switchTo('en') + expect(t('timeMinutesAgo', { n: 5 })).toBe('5 min ago') + }) + + it('drives the relative-time chain through the active locale', () => { + const locale = new FakeLocale() + attachLocale(locale) + locale.switchTo('zh') + expect(relativeTime(new Date().toISOString())).toBe('刚刚') + locale.switchTo('en') + expect(relativeTime(new Date().toISOString())).toBe('just now') + }) + + it('registers a namespace distinct from DSH ui-sidebar\'s own \'sidebar\'', () => { + expect(LOCALE_NS).toBe('betterSidebar') + }) + + it('keeps the zh and en dictionaries key-set-equal', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +})