diff --git a/AGENTS.md b/AGENTS.md index 24b7a9f..aa310e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -595,6 +595,7 @@ interface OpenTabSeed { | **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **家族右面板互斥(v0.13.0+)** | 读取 `aionui-panel` 设置命名空间的 `rightPanel`:解析为 `'aionui-panel'` 时整个侧边栏不挂载(`settings.get` 路由返回 `externalDisable: true`,客户端挂载门 + 各类接管一并停用;`settings/document-updated` 推送实时生效,无 `remote` 服务的部署回退为启动时判定)。未安装 aionui 或提供方为其他值时不受影响 | | **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 | +| **第三语言覆盖(ja 等)** | 可选 peer `@huanlin/dsh-plugin-better-locale`(`peerDependenciesMeta.optional: true`)提供 ja/ko/... 等第三语言覆盖。安装后 `apply()` 调 `ctx.get('betterLocale')` 取得 override store 并经 `attachBetterLocale()` 注入 `t()`:`t()` 先查 `store.getOverride(active, 'betterSidebar', key)`,命中则返回覆盖文本(如 ja),否则走原 zh/en 链。Sidebar root 另订阅 store 的 `subscribe()`(uSES on `store.active`),覆盖切换时全树重渲染(DSH locale 的 `active` 字段不变,所以既有 `localeRevision` uSES 不会触发)。本插件同时把 ja 词典 `register('betterSidebar', { ja })` 进 better-locale 的 store,让外部 `ctx.locale.lookup('betterSidebar', key)` 调用者也能拿到 ja 文本。未安装 better-locale 时 `ctx.get` 返回 undefined,整段为 no-op,zh/en 行为不变。新增 zh key 时**必须**同步加 ja 翻译(`src/client/locales-ja.ts`),否则该 key 在 ja 覆盖下回退到 en | | **懒加载 chunk** | 内置重依赖(xterm/CodeMirror)在独立 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 b1e0be5..f63fd8b 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ - **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化 - **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗 - **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md)) -- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换 +- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换;安装 `@huanlin/dsh-plugin-better-locale` 后支持日语(ja)等第三语言覆盖(见下方「🌏 第三语言覆盖」) > 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **28+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。 diff --git a/package.json b/package.json index a923848..5a0bb2a 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "@deepseek-ai/dsh-settings": "^0.1.0-rc.8", "@deepseek-ai/dsh-subagent": "^0.1.0-rc.8", "@deepseek-ai/dsh-tools": "^0.1.0-rc.8", + "@huanlin/dsh-plugin-better-locale": "^0.1.0", "cordis": "^4.0.0-rc.8", "react": "^18.2.0", "react-dom": "^18.2.0" @@ -106,6 +107,9 @@ "peerDependenciesMeta": { "cordis": { "optional": true + }, + "@huanlin/dsh-plugin-better-locale": { + "optional": true } }, "dependencies": { @@ -154,6 +158,7 @@ "@deepseek-ai/dsh-settings": "0.1.1-rc.1", "@deepseek-ai/dsh-subagent": "0.1.1-rc.1", "@deepseek-ai/dsh-tools": "0.1.1-rc.1", + "@huanlin/dsh-plugin-better-locale": "link:D:/Projects/deepseek-harness/dsh-plugin-better-locale", "@playwright/test": "^1.62.1", "@types/node": "^24.0.0", "@types/react": "~18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e074cf2..16a4b8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: 0.1.1-rc.1 version: 0.1.1-rc.1(5493e0ab460eb504a0593977b284c2a8) + '@huanlin/dsh-plugin-better-locale': + specifier: link:D:/Projects/deepseek-harness/dsh-plugin-better-locale + version: link:../dsh-plugin-better-locale '@playwright/test': specifier: ^1.62.1 version: 1.62.1 diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 2b5be43..77efde3 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -188,6 +188,37 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ) void localeRevision + // better-locale override freshness: when @huanlin/dsh-plugin-better-locale + // is installed and the user picks an override language (e.g. ja), the + // store's `active` changes but the DSH locale's `active` does NOT — + // better-locale keeps the dsh active value (zh/en) unchanged and only + // patches `LocaleRuntime.prototype.lookup`. The localeRevision uSES + // above reads `getSnapshot().active`, so it sees no change and skips + // re-render. This second uSES reads the better-locale store's `active` + // directly, so an override switch fires a full re-render and t() picks + // up the new override text. Optional: ctx.get returns undefined when + // better-locale is absent, in which case this is a no-op uSES. + type BetterLocaleStore = { + readonly active: string | undefined + subscribe(listener: () => void): () => void + } + const betterLocaleStore = (ctx as unknown as { + get(name: 'betterLocale'): BetterLocaleStore | undefined + }).get('betterLocale') + const betterLocaleActive = useSyncExternalStore( + useMemo(() => { + const store = betterLocaleStore + if (store === undefined) return (_cb: () => void) => () => {} + return (callback: () => void) => store.subscribe(callback) + }, [betterLocaleStore]), + useMemo(() => { + const store = betterLocaleStore + if (store === undefined) return () => undefined + return () => store.active + }, [betterLocaleStore]), + ) + void betterLocaleActive + // Tab-registry revision: TabContent memo cells must pick up a descriptor // a plugin registers/disposes after mount (the + menu / icons already read // the registry at render). Rare events (plugin (un)mount), so one full diff --git a/src/client/index.tsx b/src/client/index.tsx index c87fbd1..08bcc06 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -24,7 +24,7 @@ import { registerSettingsNavIcon } from './settings-nav-icon.ts' import { loadExternalDisable, loadPrefs } from './prefs.ts' import { SideCardSection } from './SideCardSection.tsx' import { api } from './api.ts' -import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts' +import { LOCALE_NS, attachLocale, attachBetterLocale, t, zh, en, ja } from './locales.ts' import css from './sidebar.module.css' import './layout.css' @@ -57,6 +57,37 @@ export function apply(ctx: Context): void { const offEn = ctx.locale.register(LOCALE_NS, 'en', en) return () => { offZh(); offEn() } }, 'dsh-better-sidebar: dictionaries') + + // Opt-in third-language support through @huanlin/dsh-plugin-better-locale. + // When that plugin is installed, it publishes `ctx.betterLocale` (the + // override store) and patches LocaleRuntime.prototype.lookup to consult + // it. We mirror the same override awareness into the sidebar's own `t()`: + // attachBetterLocale() makes t() consult the store's getOverride first, + // so the sidebar's chrome (which bypasses ctx.locale and calls t() + // directly) also switches to the override language. We also register + // the ja dict with the better-locale store so external callers of + // ctx.locale.lookup('betterSidebar', key) get the override text too. + // Optional peer: ctx.get returns undefined when better-locale is absent, + // in which case the whole block is a no-op and the zh/en chain runs. + const betterLocale = ctx.get('betterLocale') as + | { + readonly active: string | undefined + getOverride(dshActive: string, ns: string, key: string): string | undefined + register(ns: string, dicts: Record>): () => void + subscribe(listener: () => void): () => void + } + | undefined + attachBetterLocale(betterLocale) + if (betterLocale !== undefined) { + ctx.effect(() => { + // Register the ja dict for the betterSidebar namespace. Better-locale's + // patched lookup consults this when the user has selected the ja + // override; the dict stays owned by better-sidebar (the namespace + // owner), so a single source of truth covers both ctx.locale callers + // and the internal t(). + return betterLocale.register(LOCALE_NS, { ja }) + }, 'dsh-better-sidebar: better-locale ja dict') + } // 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-ja.ts b/src/client/locales-ja.ts new file mode 100644 index 0000000..ec29559 --- /dev/null +++ b/src/client/locales-ja.ts @@ -0,0 +1,345 @@ +/** + * The ja (Japanese) dictionary for the betterSidebar namespace. + * + * Mirrors the key set of `zh` in `locales.ts`. The sidebar's `t()` + * consults this dict when `attachBetterLocale(store)` has been called + * with an active better-locale store whose `active` is `'ja'`; absent + * that, the existing zh/en chain runs unchanged. + * + * Translation conventions: + * - Common dev-tool loanwords stay in katakana (ターミナル, ブラウザー, サンドボックス). + * - Git vocabulary follows the GitHub Japan style guide (ステージ, コミット, ブランチ). + * - Settings labels end in する/名/方法 to mirror the zh 「…行为/方式」 cadence. + * - Placeholders keep `{name}` verbatim (interpolation runs after lookup). + * - English brand names (VS Code, Cursor, Zed, SSH) stay as-is. + */ + +/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */ +export const ja: Record = { + files: 'ファイル', + explorer: 'エクスプローラー', + git: 'ソース管理', + terminal: 'ターミナル', + editor: 'エディター', + editorExplorer: 'ファイルの開き方', + editorExplorerDesc: 'ファイルを開く方法を制御', + editorExplorerMerged: '統合', + editorExplorerMergedDesc: '同じウィンドウ内でファイルを切り替え。新しいウィンドウはファイルツューを展開した状態で開く', + editorExplorerSplit: '個別', + editorExplorerSplitDesc: 'パスなしウィンドウはエクスプローラー単体(ツリーのみ)。各ファイルは別ウィンドウで開く(ツリーは格納済み、デフォルトで折りたたみ)', + editorTreeToggle: 'ファイルツリーパネル', + editorPathPlaceholder: 'ファイルパス(セッションディレクトリからの相対 or 絶対)、Enter で開く', + editorSearchPlaceholder: 'ファイル名で検索…', + editorSearchNoResults: '一致するファイルはありません', + editorSearchTruncated: '結果が多すぎます — 一部のみ表示', + editorEmptyHint: '右のファイルツリーまたは上のパス入力欄からファイルを選んでプレビュー', + openFileNewTab: '新しいタブで開く', + openFileSide: '横に開く', + openWithMenu: 'アプリで開く', + openWithSshSuffix: ' (SSH)', + pinOpenWith: 'メニューに固定', + unpinOpenWith: '固定を解除', + openWithExplorer: 'エクスプローラー', + openWithVscode: 'VS Code', + openWithCursor: 'Cursor', + openWithZed: 'Zed', + openWithSettingsSshTitle: 'SSH リモートホスト', + openWithSettingsSshDesc: '空欄 = ローカルワークスペース。user@host または SSH エイリアスを入力すると、VSCode 系の起動方法は vscode-remote/ssh-remote プロトコルに切り替わり、エクスプローラー / Zed / 非 VSCode 系カスタムエディターはメニューから非表示になります', + openWithSettingsSshPlaceholder: 'user@host または SSH エイリアス', + openWithSettingsCustomTitle: 'カスタムエディター', + openWithSettingsCustomDesc: '名前 + URL テンプレート({path} プレースホルダー)+ VSCode 系かどうか。SSH モードでは VSCode 系のみリモートパスを開けます', + openWithSettingsAdd: '追加', + openWithSettingsName: '名前', + openWithSettingsTemplate: '例: cursor://file/{path}', + openWithSettingsFamily: 'VSCode 系', + openWithSettingsFamilyDesc: 'このエディターは VSCode の URL プロトコルを使います(SSH リモートオープンをサポート)', + openWithSettingsRemove: '削除', + openWithSettingsInvalidHint: '名前またはテンプレート({path} を含み scheme:// で始まる必要あり)が未入力の編集者はメニューに表示されません', + newTab: '新しいタブ', + openExplorer: 'エクスプローラー', + brokenSymlink: '無効なシンボリックリンク', + openGit: 'Git パネル', + newTerminal: '新しいターミナル', + terminalLimit: 'ターミナル数が上限に達しました (3)', + close: '閉じる', + closeOtherTabs: '他のタブを閉じる', + closeLeftTabs: '左のタブを閉じる', + closeRightTabs: '右のタブを閉じる', + collapse: 'サイドバーを折りたたむ', + expand: 'サイドバーを展開', + collapseBottomPanel: '下パネルを折りたたむ', + expandBottomPanel: '下パネルを展開', + terminalError: 'ターミナル接続に失敗', + terminalConnectFailed: 'ターミナル接続が繰り返し失敗しました', + terminalRetry: '再試行', + terminalDepsFailed: 'ターミナル依存関係 node-pty の読み込みに失敗', + terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):', + terminalDepsProfile: '(検出された profile:{profile})', + preview: 'プレビュー', + edit: '編集', + mermaidError: 'Mermaid レンダリング失敗', + mermaidZoomIn: '拡大', + mermaidZoomOut: '縮小', + mermaidZoomReset: 'リセット', + mermaidZoomHint: 'スクロールで拡大 ・ ドラッグで移動 ・ Esc で閉じる', + refresh: '更新', + save: '保存', + saved: '保存済み', + unsaved: '未保存', + saveFailed: '保存に失敗', + truncation: 'ファイルが大きすぎます — 最初の 512KB のみ表示', + binary: 'バイナリファイル、プレビュー不可', + loading: '読み込み中…', + error: '読み込みに失敗', + retry: '再試行', + splitLeft: '左に分割', + splitRight: '右に分割', + splitUp: '上に分割', + splitDown: '下に分割', + notRepo: 'このディレクトリは git リポジトリではありません', + noChanges: '変更はありません', + stage: 'ステージ', + unstage: 'ステージ解除', + stageAll: 'すべてステージ', + unstageAll: 'すべてステージ解除', + commitPlaceholder: 'コミットメッセージ (Ctrl+Enter)', + commit: 'コミット', + commitError: 'コミット失敗', + branch: 'ブランチ', + checkoutError: 'ブランチ切り替えに失敗', + history: '履歴', + changes: '変更', + staged: 'ステージ済み', + unstaged: '未ステージ', + cancel: 'キャンセル', + diffEmpty: 'テキスト差分はありません', + diffLoadError: '差分の読み込みに失敗', + diffBinary: 'バイナリ', + diffAdded: '追加', + diffDeleted: '削除', + diffRenamed: '名前変更', + diffExpand: '残り {count} 行を展開', + diffCollapse: '折りたたむ', + discard: '変更を破棄', + discardTitle: '変更を破棄', + discardDesc: '「{path}」のワークツリー変更を破棄します(復元不可)。', + viewCommitDiff: 'コミット差分を表示', + copyShortHash: '短いハッシュをコピー', + copyFullHash: '完全なハッシュをコピー', + copySubject: 'コミットメッセージをコピー', + revertCommit: 'このコミットを取り消す', + revertTitle: 'このコミットを取り消す', + revertDesc: '現在のブランチに「{subject}」を取り消す新しいコミットを作成します。', + cherryPickCommit: 'このコミットをチェリーピック', + cherryPickTitle: 'このコミットをチェリーピック', + cherryPickDesc: '「{subject}」の変更を現在のブランチに適用します。', + timeJustNow: 'たった今', + timeMinutesAgo: '{n} 分前', + timeHoursAgo: '{n} 時間前', + timeYesterday: '昨日', + loadMore: 'もっと読み込む', + historyLoadError: '履歴の追加読み込みに失敗', + produced: '今回の産物', + producedOpen: 'サイドバーで開く', + disconnected: 'ターミナル接続が切れました、再接続中…', + exited: 'ターミナルプロセスが終了しました', + noSession: 'サイドバーを使うには会話を選択してください', + pluginNotLoaded: 'プラグイン未読み込み、タブは一時的に利用不可:', + hiddenFiles: '隠しファイル', + parent: '上位ディレクトリ', + copied: 'コピーしました', + copy: 'コピー', + newFile: '新規ファイル', + openEditor: 'エディターで開く', + gitDetail: '変更詳細を表示', + referenceFile: '@ファイル', + addToConversation: '会話に追加', + copyRelative: '相対パスをコピー', + copyAbsolute: '絶対パスをコピー', + download: 'ダウンロード', + uploadFiles: 'ファイルをアップロード', + uploadFolder: 'フォルダーをアップロード', + uploadHere: 'ここにアップロード', + uploadDropHint: 'ファイル/フォルダーをここにドロップしてアップロード', + uploadDropChat: 'チャットにドロップで画像を会話に追加', + uploadTo: '{dir} にアップロード', + uploadingTo: '{dir} にアップロード中…', + uploadProgress: 'アップロード中 {done}/{total}: {name}', + uploadDone: '{count} 個のファイルをアップロードしました', + uploadFailed: 'アップロード失敗:{error}', + uploadFailedUnknown: '不明なエラー', + uploadTooLarge: 'ファイルが大きすぎます(アップロード上限超過)', + uploadCancelled: 'アップロードはキャンセルされました', + settingsNav: 'サイドカード', + settingsIntro: 'サイドカードの表示内容とデフォルト挙動を管理', + settingsPopupDesc: '「{feature}」の関連オプションを設定', + settingsDone: '完了', + settingsOpenTitle: '新規会話でデフォルト展開', + settingsOpenDesc: '新規会話時にサイドカードを自動展開。既存の会話はそれぞれのレイアウトを維持', + settingsWidthTitle: 'デフォルト幅の比率', + settingsWidthDesc: '新規会話時のサイドカードがウィンドウ幅に占める割合 (20–60)', + settingsWidthSuffix: '%', + settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く', + settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く', + settingsTitleBarTitle: '位置互換モード', + settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)', + settingsTitleBarStripTitle: '下移動距離', + settingsTitleBarStripDesc: 'タイトルバー帯の高さ:サイドバーボタンとコンテンツが下に移動するピクセル数 (0–120、デフォルト 40。カスタムスキーム下で有効)', + settingsSchemeAutoTitle: '自動検出', + settingsSchemeAutoDesc: '控えめなスキーム:Window Controls Overlay 標準 API が利用可能な場合のみ実際のタイトルバー高さ分譲る。通常の Web 環境では何も変更しない', + settingsSchemeWebTitle: 'DSH 公式 Web', + settingsSchemeWebDesc: '公式 Web 版で動いていることを明示:一切適用しない(標準 WCO 幾何も適用外)', + settingsSchemeCustomTitle: 'カスタム', + settingsSchemeCustomDesc: '完全に制御:カスタム CSS を注入(内置スタイルも上書き可)し、タイトルバーの下移動距離を指定', + settingsSchemeDetectedSuffix: '検出済み', + settingsCustomCssTitle: 'カスタム CSS', + settingsCustomCssDesc: 'ページ末尾に追加されるスタイル(同優先度では後勝ち。JS 起点のインライン変数を上書きするには !important を使用)', + settingsCustomCssPlaceholder: '/* 例:自描きタイトルバーのシェルに 36px を予約 */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}', + settingsSaveFailed: '保存に失敗', + settingsConflict: '設定が別のウィンドウで変更されました、再試行してください', + binaryNoPreview: 'このファイル形式はプレビューできません', + downloadToView: 'ダウンロードして表示', + settingsSubagentTitle: 'サブエージェント検出時に自動展開', + settingsSubagentDesc: '現在の会話で新しいサブエージェントが発生した際、サイドバーを自動展開してタスク管理ページを開く。オフ時は手動で開く必要あり', + settingsJobsTitle: '新規バックグラウンドタスク時に自動展開', + settingsJobsDesc: '現在の会話で新しいバックグラウンドタスクが発生するたびにサイドバーを展開してタスクページを開く(新規タスクごとに発火)。オフ時は手動で開く必要あり', + settingsToolsTitle: 'モデルにターミナルツールを注入', + settingsToolsDesc: 'オンにすると、モデルが terminal_create 等 8 個のツールでサイドバーターミナルを作成・操作可能(デフォルトオフ)', + settingsBottomTerminalTitle: '下パネル初回展開時に自動でターミナルを開く', + settingsBottomTerminalDesc: '会話ごとに下パネルを初めて展開した際、下パネルに新しいターミナルタブを自動で開く(ターミナル数上限は依然適用。デフォルトオン)', + settingsFontFamilyTitle: 'ターミナルフォント', + settingsFontFamilyDesc: 'ターミナルのカスタムフォントファミリー(CSS font-family、例: "JetBrains Mono", monospace。空欄でテーマの等幅フォントに追従)', + settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace', + settingsFontSizeTitle: 'ターミナルフォントサイズ', + settingsFontSizeDesc: 'ターミナルのフォントサイズ (px、9–32、デフォルト 13)', + settingsFontSizeSuffix: 'px', + settingsShellTitle: 'シェルパス', + settingsShellDesc: 'UI とモデルのターミナルが起動するシェル(絶対パス or 実行ファイル名)。空欄時は従来順で解決: yaml の config.shell → $SHELL / ログインシェル / Windows の powershell.exe。以降に開くターミナルに適用', + settingsShellPlaceholder: '例: /bin/zsh(空欄で自動解決)', + settingsShellArgsTitle: 'シェル引数', + settingsShellArgsDesc: '明示的なシェル起動引数(スペース区切り)。非空の場合デフォルト引数を完全に置き換え(yaml の shellArgs 契約と同じ)', + settingsShellArgsPlaceholder: '例: -l(空欄でデフォルト引数)', + settingsTabsTitle: 'サイドバーの内容', + settingsViewersTitle: 'ファイルプレビュー', + settingsGeneralTitle: '一般', + settingsPopup: '機能設定', + settingsViewerCatchAll: 'フォールバック:任意のファイル', + viewerImage: '画像', + viewerPdf: 'PDF', + viewerMarkdown: 'Markdown', + viewerCode: 'コード', + viewerBinary: 'バイナリダウンロード', + viewerHtml: 'HTML', + browser: 'ブラウザー', + browserPlaceholder: 'URL を入力、例: example.com', + browserGo: '開く', + browserBack: '戻る', + browserForward: '進む', + browserStart: 'URL を入力してブラウズ開始(サンドボックスモード)', + browserBlockedScheme: 'ブロック:http/https リンクのみ許可', + browserBlockedLoopback: 'ブロック:ローカルや内部アドレスはブラウズできません', + browserInvalid: '無効な URL', + browserNoSandboxWarning: 'サンドボックスオフ:現在のページは GUI と同一オリジンで、完全な会話権限を持ちます(設定で復元可)', + htmlNoSandboxWarning: 'サンドボックスオフ:この HTML は GUI と同一オリジンで、会話ファイルや内部 API を読み取れます(設定で復元可)', + sandboxStatusOn: 'サンドボックスモード:オン ・ ページは GUI のデータやローカルファイルにアクセス不可。ログイン状態とサードパーティ Cookie は利用できない場合あり', + sandboxUnlock: '一時的に解除(非安全)', + sandboxRestore: 'サンドボックスを復元', + settingsHtmlDefaultUnsafeTitle: 'HTML プレビューをデフォルトで非サンドボックスで開く(非安全)', + settingsHtmlDefaultUnsafeDesc: 'オンにすると、新しく開く HTML プレビューは非サンドボックス状態で起動(GUI と同一オリジン、会話ファイルや内部 API を読み取り可)。ステータス行から一時的にサンドボックスを復元可能', + settingsHtmlSandboxTitle: 'HTML プレビューのサンドボックスを無効化(非安全)', + settingsHtmlSandboxDesc: 'サンドボックスをオフにすると、プレビューされる HTML は GUI と同一オリジンで動作し、会話ファイル・ローカルストレージ・内部 API にアクセス可能。完全に信頼できるファイルのみで有効化', + settingsBrowserSandboxTitle: 'ブラウザーサンドボックスを無効化(非安全)', + settingsBrowserSandboxDesc: 'サンドボックスをオフにすると、訪問するあらゆるサイトが GUI と同一オリジンで動作し、会話データを読み取ったりログイン状態を偽装したりできます。完全に信頼できるサイトのみで有効化', + settingsBrowserLinksTitle: 'チャット内の外部リンクをサイドバーで開く', + settingsBrowserLinksDesc: 'オンにすると、チャットや GUI の外部リンククリック時に新しいウィンドウではなくサイドバーで開く。HTTP と HTTPS は下のスイッチで個別制御。Ctrl/Cmd+クリックで一時的にバイパス', + settingsBrowserHttpTitle: 'HTTP ページをサイドバーで開く', + settingsBrowserHttpDesc: 'オンにすると、チャットや GUI の HTTP 外部リンククリック時にサイドバーで開く(urlTarget を宣言したプラグインページが優先)。Ctrl/Cmd+クリックで一時的にバイパス', + settingsBrowserHttpsTitle: 'HTTPS ページをサイドバーで開く', + settingsBrowserHttpsDesc: 'オンにすると、チャットや GUI の HTTPS 外部リンククリック時にサイドバーで開く。デフォルトオフ:大半の HTTPS サイトは埋め込みを拒否するため、システムブラウザーの方がスムーズ', + browserOpenExternal: 'ブラウザーで開く', + browserEmbedBlocked: '{host} は埋め込みを拒否しました', + browserEmbedBlockedDesc: 'このサイトは X-Frame-Options / frame-ancestors で他ページ内での表示を禁止しているため、サイドバー内で読み込めません。ブラウザーで直接開いてください', + browserEmbedAnyway: 'それでも読み込む', + subagent: 'タスク管理', + openSubagent: 'タスク管理', + subagentMainAgent: 'メインエージェント', + subagentEmpty: 'サブエージェントなし', + subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます', + subagentRunning: '実行中', + subagentInactive: 'アイドル', + subagentModeOneShot: 'ワンショット', + subagentModeContinuable: '継続可', + subagentCount: '{count} 個のサブエージェント', + subagentCountRunning: '{count} 個のサブエージェント ・ {running} 個実行中', + subagentDiagCorrupt: 'ディレクトリ破損', + subagentDiagUnsupported: '未サポートのエントリ', + subagentDiagUnavailable: '利用不可', + subagentThinking: '思考中…', + sideChat: 'サイドチャット(beta)', + sideChatNew: '新規スレッド', + sideChatUntitled: '新規スレッド', + sideChatEmpty: 'サイド会話なし', + sideChatEmptyDesc: '各サイド会話はタブバーの独立 Tab で、現在の会話のコンテキストを継承し、メイン会話には入りません', + sideChatCreating: 'サイド会話を作成中…', + sideChatRetry: '再試行', + sideChatThreads: 'スレッド切替 / 新規', + sideChatSave: '新規会話として保存', + sideChatSaveTitle: 'このスレッドをトップレベルの会話に昇格、メイン会話リストに表示', + sideChatSaved: '新規会話として保存しました', + sideChatNoTurn: '最初のターンを完了してから保存可能', + sideChatPendingDrop: '最後の未回答フォローアップは保存される会話に含まれません', + sideChatFirstPlaceholder: '最初の質問を入力、コンテキスト継承済み…', + sideChatComposerPlaceholder: 'フォローアップを質問…', + sideChatThinking: '深掘り中…', + sideChatThink: '思考プロセス', + sideChatInjection: 'コンテキスト注入済み', + sideChatSend: '送信', + sideChatCancel: '停止', + sideChatCancelTitle: '現在のターンを中止(キューは保持)', + sideChatClose: 'スレッドを閉じる', + sideChatCloseTitle: 'スレッドの agent を解放(履歴は保持)', + sideChatError: 'サイド会話エラー:{message}', + jobs: 'バックグラウンドタスク', + jobsCount: '{count} 個のバックグラウンドタスク', + jobsCountRunning: '{count} 個のバックグラウンドタスク ・ {running} 個実行中', + jobStatusRunning: '実行中', + jobStatusStopping: '停止中', + jobStatusCompleted: '完了', + jobStatusKilled: '終了', + jobStatusFailed: '失敗', + jobDurationSeconds: '{seconds} 秒', + jobDurationMinutes: '{minutes} 分 {seconds} 秒', + jobDurationHours: '{hours} 時間 {minutes} 分', + jobViewOutput: '出力を表示', + jobHideOutput: '出力を隠す', + jobNoOutput: 'まだ出力なし', + jobNotReadYet: 'モデルがこのタスクの出力を読み取るのを待機中(モデルが job_output を実行すると、出力がここに表示されます)', + jobOutputTruncated: '出力が長すぎるため表示を切り詰めました', + jobOutputError: '出力の読み取りに失敗', + jobKill: '終了', + jobKillConfirm: 'もう一度クリックして終了を確認', + jobKillError: '終了に失敗', + addPluginsTabCard: 'Tab プラグインを追加', + addPluginsTabCardDesc: '新しいサイドバーページを登録', + addPluginsViewerCard: 'プレビュープラグインを追加', + addPluginsViewerCardDesc: '新しいファイル種別プレビューを登録', + addPluginsTabDesc: 'サイドバーページ(Tab)はプラグインで拡張可能。プラグインは ctx.betterSidebar サービス経由で登録。「インストール」をクリックしてインストールコマンドをコピー、DSH 環境のターミナルに貼り付けて実行。', + addPluginsViewerDesc: 'ファイルプレビューアーはプラグインで拡張可能。プラグインは ctx.betterSidebar サービス経由で登録。「インストール」をクリックしてインストールコマンドをコピー、DSH 環境のターミナルに貼り付けて実行。', + addPluginsBrowseMore: 'GitHub でプラグинをさらに閲覧(topic: dsh-better-sidebar)', + addPluginsSearch: 'プラグイン名 / 説明で検索…', + addPluginsNoMatch: '一致するプラグインなし', + addPluginsRecommended: '推奨プラグイン', + addPluginsEmpty: 'まだプラグインが収録されていません、GitHub topic であなたのプラグインを公開してください', + openPlugin: '開く', + copyInstall: 'インストールコマンドをコピー', + pluginOfficeDesc: 'better-sidebar エディター向け Office スイートプレビュー(.docx / .xlsx / .pptx)。重い Office レンダリングライブラリをコアバンドルから分離、必要に応じてインストール', + pluginFlowglassDesc: 'ライブセッションフローグラフ:ユーザー、アシスタント、ツール呼び出しの 3 レーン泳道、並列グループ、サブエージェント支線、ドリルダウン、ライブ状態をサポート。better-sidebar インストール時にネイティブ「Flowglass」Tab を登録、未インストール時は独立ドロワーを保持', + pluginGitForgeDesc: 'better-sidebar「Git Forge」Tab:GitHub/Gitea 等 Forge アカウントライブラリ + プロジェクト単位の認可 + push ポリシー硬核拒絶。token はローカル secrets のみ、モデルコンテキストに入らない。読み取り専用 GitForge ツールと agent HTTPS credential helper を提供', + pluginGitRemotesDesc: 'better-sidebar Git リモート Tab:ブランチ/アップストリーム/ahead-behind 表示、fetch(prune 可)、ff-only pull、確認後のみ push。内蔵 Git のステージ/コミットは置き換えず、force-push やモデル自動 push ツールは提供しない', + pluginSentinelDesc: '条件駆動の agent 起動システム:ファイル/プロセス/ポート/HTTP/コマンド/webhook センサーが条件達成時に休眠会話を自動起動。「Sentinel」Tab を登録しサーバー全体の監視テーブルを表示', + pluginSidebarQaDesc: 'better-sidebar ベースの選択して質問 Tab:会話選択 → 右パネルで質問 → 同ワークスペースの独立追問会話(❓追問・主題)。高速 no-thinking モデルが主対話コンテキストを圧縮し引用と一緒に注入、主対話を中断しない。追問はネスト、継続、アーカイブ可能', + pluginSshTunnelDesc: 'better-sidebar「SSH トンネル」Tab:複数ホストインベントリ + プロジェクト単位の認可 + 鍵のローカル保管。モデルツール SSHManager(exec/SFTP/セッション戦略)。中央対話ターミナルと双欄 SFTP', + pluginTurnReviewDesc: '「直近のターン」の diff に Approve / Request changes のヒューマンゲート。直前ターンのみ審査、会話を fork しない。ファイルをメイン会話/サブエージェント/未帰属グループ分け、ファイル単位でチェックして差し戻し + 任意コメント、ファイルをクリックするとターン開始スナップショット vs 現在の diff を先に表示。/rewind ではない', + pluginVideoPreviewDesc: 'better-sidebar エディター内で動画ファイルをインラインプレビュー(.mp4/.webm/.mov/.mkv/.avi 等)。HTTP Range (206) をサポートする専用 /video ホストルートを搭載、プログレスバードラッグ可、20MB mediaLimit 制限なし', + pluginDocsPanelDesc: 'DSH サイドバー内の「グローバルドキュメント」:任意のワークスペースから読めるグローバル Markdown ノート。リストで選んで閲覧、ホバーでアウトラインジャンプ、Chrome / VS Code で外部オープン、コードコピー。ディレクトリは設定可(デフォルト ~/.dsh/docs)', +} diff --git a/src/client/locales.ts b/src/client/locales.ts index e868552..dd22225 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -1,5 +1,5 @@ /** - * Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system: + * Minimal zh/en/ja 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 @@ -7,6 +7,18 @@ * 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}. + * + * ja (Japanese) is opt-in through `@huanlin/dsh-plugin-better-locale`: when + * that plugin is installed, the client apply also calls + * {@link attachBetterLocale} with the override store. `t()` then consults + * the store's active override id first; if it is `'ja'` (or any id whose + * dict has the requested key) the ja text wins, otherwise the existing + * zh/en chain runs unchanged. better-locale itself patches + * `LocaleRuntime.prototype.lookup` so DSH's own translate chain also + * returns ja where the `betterSidebar` namespace has a ja entry — that + * path covers external callers of `ctx.locale.bind('betterSidebar')`, + * while the override-aware `t()` here covers better-sidebar's own + * components (which bypass `ctx.locale` and call `t()` directly). */ /** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */ @@ -675,9 +687,28 @@ export const en: Record = { */ export const LOCALE_NS = 'betterSidebar' +// The ja dictionary lives in a sibling file (326 keys) so this module +// stays readable. Type-checked against the zh key set: a missing or extra +// ja key is a compile error. +import { ja as jaDict } from './locales-ja.ts' + +/** The ja dictionary (key-set-equal to zh, enforced by the type annotation). */ +export const ja: Record = jaDict as Record + /** The DSH locale service attached by the client apply (absent → browser detection). */ let localeService: { getSnapshot(): { active: string } } | undefined +/** + * The better-locale override store attached by the client apply + * (absent → no override; the zh/en chain runs). The store's `active` + * field holds the user's chosen override id (e.g. `'ja'`); `undefined` + * means "no override, use DSH native zh/en". + */ +let betterLocaleStore: { + readonly active: string | undefined + getOverride(dshActive: string, ns: string, key: string): string | undefined +} | 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 @@ -689,6 +720,23 @@ export function attachLocale(service: { getSnapshot(): { active: string } } | un localeService = service } +/** + * Attach (or detach, with undefined) the better-locale override store. + * When attached with an active override, `t()` consults the store's + * `getOverride(active, LOCALE_NS, key)` first; if it returns a string, + * that text wins over the zh/en chain. Detaching (or the store's active + * being `undefined`) restores the zh/en chain unchanged. + * + * The Sidebar root subscribes to the store separately (see Sidebar.tsx) + * so an override change re-renders the whole tree — the locale service's + * own revision bump (which better-locale triggers via `publish(active, true)`) + * does NOT fire the existing `localeRevision` uSES because that snapshot + * reads `getSnapshot().active` (unchanged) rather than `revision`. + */ +export function attachBetterLocale(store: typeof betterLocaleStore): void { + betterLocaleStore = store +} + /** * The active locale id ('zh' | 'en'): the DSH locale service's snapshot when * attached, else the browser language. @@ -704,8 +752,24 @@ export type CopyKey = keyof typeof zh /** Translate a copy key; `{name}` placeholders interpolate from `params`. */ export function t(key: CopyKey, params?: Record): string { - const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en - let text = dict[key] + // 1. better-locale override (e.g. ja) wins when an override is active + // and the store has a translation for this (ns, key). The DSH active + // locale is forwarded for forward compatibility (a future mode could + // gate overrides on DSH being 'en'); the current store ignores it. + const dshActive = localeService?.getSnapshot().active ?? '' + const override = betterLocaleStore?.getOverride(dshActive, LOCALE_NS, key) + let text: string | undefined = override + // 2. Fall back to the zh/en chain when no override matched. + if (text === undefined) { + const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en + text = dict[key] + } + if (text === undefined) { + // Key missing from every dict (should not happen — zh is the source of + // truth and en/ja are checked against it). Return the key itself so the + // UI shows something identifiable rather than `undefined`. + text = key + } if (params !== undefined) { for (const [name, value] of Object.entries(params)) { text = text.replaceAll(`{${name}}`, String(value)) @@ -716,6 +780,9 @@ export function t(key: CopyKey, params?: Record): strin /** Whether the active locale is Chinese (used for selectors). */ export function isZh(): boolean { + // An active override is not Chinese — short-circuit so selectors pick + // the non-zh branch (e.g. date format, pluralization). + if (betterLocaleStore?.active !== undefined) return false return activeLocale().toLowerCase().startsWith('zh') } diff --git a/tests/locales.spec.ts b/tests/locales.spec.ts index 06f763a..8ec9d7d 100644 --- a/tests/locales.spec.ts +++ b/tests/locales.spec.ts @@ -6,7 +6,7 @@ * interpolation. */ import { afterEach, describe, expect, it } from 'vitest' -import { LOCALE_NS, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts' +import { LOCALE_NS, attachBetterLocale, attachLocale, en, isZh, ja, relativeTime, t, zh } from '../src/client/locales.ts' /** Minimal structural fake of the DSH LocaleService face the sidebar uses. */ class FakeLocale { @@ -35,6 +35,7 @@ function stubNavigatorLanguage(lang: string | undefined): void { afterEach(() => { attachLocale(undefined) + attachBetterLocale(undefined) stubNavigatorLanguage(undefined) }) @@ -106,4 +107,121 @@ describe('locales (DSH i18n following)', () => { it('keeps the zh and en dictionaries key-set-equal', () => { expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) }) + + it('keeps the ja dictionary key-set-equal to zh', () => { + expect(Object.keys(ja).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +/** Minimal fake of the better-locale override store (the subset t() reads). */ +class FakeBetterLocale { + active: string | undefined = undefined + private readonly dict: Record>> + constructor(dict: Record>>) { + this.dict = dict + } + getOverride(_dshActive: string, ns: string, key: string): string | undefined { + if (this.active === undefined) return undefined + return this.dict[ns]?.[this.active]?.[key] + } + subscribe(_listener: () => void): () => void { + return () => {} + } +} + +describe('locales (better-locale override)', () => { + it('returns the override text when an override is active and the key exists', () => { + const locale = new FakeLocale() + locale.switchTo('en') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ + [LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } }, + }) + betterLocale.active = 'ja' + attachBetterLocale(betterLocale) + + expect(t('explorer')).toBe('エクスプローラー') + expect(isZh()).toBe(false) + }) + + it('falls back to the zh/en chain when override is active but the key is missing', () => { + const locale = new FakeLocale() + locale.switchTo('en') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ + [LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } }, + }) + betterLocale.active = 'ja' + attachBetterLocale(betterLocale) + + // 'git' has no ja entry in this fake; should fall back to the en text. + expect(t('git')).toBe('Source Control') + }) + + it('falls back to the zh/en chain when no override is active', () => { + const locale = new FakeLocale() + locale.switchTo('zh') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ + [LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } }, + }) + // active is undefined — no override. + attachBetterLocale(betterLocale) + + expect(t('explorer')).toBe('资源管理器') + expect(isZh()).toBe(true) + }) + + it('returns to the zh/en chain after detaching the override store', () => { + const locale = new FakeLocale() + locale.switchTo('en') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ + [LOCALE_NS]: { ja: { explorer: 'エクスプローラー' } }, + }) + betterLocale.active = 'ja' + attachBetterLocale(betterLocale) + expect(t('explorer')).toBe('エクスプローラー') + + attachBetterLocale(undefined) + expect(t('explorer')).toBe('Explorer') + expect(isZh()).toBe(false) + }) + + it('interpolates {name} placeholders in the override text', () => { + const locale = new FakeLocale() + locale.switchTo('en') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ + [LOCALE_NS]: { ja: { timeMinutesAgo: '{n} 分前' } }, + }) + betterLocale.active = 'ja' + attachBetterLocale(betterLocale) + + expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分前') + }) + + it('ships a ja dictionary whose every key resolves to a non-empty string', () => { + // Smoke-check the shipped ja dict: every key in zh must have a non-empty + // ja entry. This catches copy-paste mistakes where a key was added to zh + // but its ja translation was left blank or missing. + const locale = new FakeLocale() + locale.switchTo('en') + attachLocale(locale) + + const betterLocale = new FakeBetterLocale({ [LOCALE_NS]: { ja } }) + betterLocale.active = 'ja' + attachBetterLocale(betterLocale) + + for (const key of Object.keys(zh) as (keyof typeof zh)[]) { + const text = t(key) + expect(text, `ja translation for "${key}"`).toBeTruthy() + expect(text, `ja translation for "${key}"`).not.toBe(key) + } + }) })