From 4c6a29ef2822fd81870cbebed5ccdd1b60ec3518 Mon Sep 17 00:00:00 2001 From: Bill Date: Sun, 20 Sep 2026 14:22:29 +0800 Subject: [PATCH] feat: multi-language interface (zh-CN/zh-TW/en/ja), multilingual offline changelog, settings robustness i18n - shared/i18n: dependency-free t() with flat per-namespace dictionaries (common/settings/workspace/terminal/ssh/panels/main), zh-CN fallback - language picker in Settings -> System; antd ConfigProvider locale follows it - main process tracks the language too: tray menu, close prompt, ssh/sftp/ zmodem errors and the log TUI marker are translated; tray rebuilds on change changelog - CHANGELOG.md (zh-CN canonical) + .zh-TW/.en/.ja, bundled via ?raw and read per interface language with per-version fallback to zh-CN (no network) - sync-changelog.cjs merges RELEASE_NOTES[.].md per release; release.cjs refuses to publish without a zh-CN entry for the version settings robustness - closeAction 'ask' survives the sanitizer (was silently coerced to 'tray', which made the 'ask every time' option dead) - highlight rules are repaired instead of dropped: string priority, 0/1 enabled, missing fg colour; unknown fields preserved - load-time warnings are written to settings-warnings.log (deduped, capped) terminal/UI - configurable terminal toolbar: open working directory (default on), session log recording (off), open logs folder (off) - global shortcut field records key combos (modifier or F-key required) - input suggestions + command history default to off, with a one-time reset migration for existing installs - settings dialog scrolling fixed (antd v6 renamed the tabs container), theme gallery nested scrollbar removed, joined segmented pickers with readable selected-state text tests: settings-store.mjs (15 checks) added; commands-store.mjs updated for the new off-by-default history setting --- .gitignore | 5 +- CHANGELOG.en.md | 37 +++++ CHANGELOG.ja.md | 36 +++++ CHANGELOG.md | 20 +++ CHANGELOG.zh-TW.md | 35 ++++ package-lock.json | 4 +- package.json | 2 +- scripts/sync-changelog.cjs | 83 ++++++---- src/main/index.ts | 6 +- src/main/ipc.ts | 3 +- src/main/logSanitizer.ts | 8 +- src/main/pty.ts | 5 +- src/main/settingsStore.ts | 128 ++++++++++++--- src/main/sftp.ts | 29 ++-- src/main/ssh.ts | 23 ++- src/main/sysinfo.ts | 7 +- src/main/tray.ts | 41 +++-- src/main/updater.ts | 3 +- src/main/zmodem.ts | 33 ++-- src/renderer/src/App.tsx | 34 +++- src/renderer/src/ErrorBoundary.tsx | 5 +- src/renderer/src/commands/CommandsPanel.tsx | 61 +++---- src/renderer/src/connections/Common.tsx | 7 +- .../src/connections/ConnectionEditDialog.tsx | 60 +++---- .../src/connections/ConnectionSidebar.tsx | 60 +++---- src/renderer/src/connections/Fields.tsx | 25 +-- src/renderer/src/monitor/MonitorPanel.tsx | 33 ++-- src/renderer/src/settings/AboutTab.tsx | 92 +++++++---- src/renderer/src/settings/HighlightTab.tsx | 77 ++++----- src/renderer/src/settings/SettingsDialog.tsx | 25 +-- src/renderer/src/settings/SettingsTabs.tsx | 146 +++++++++-------- .../src/settings/ThemeSettingsTab.tsx | 47 +++--- src/renderer/src/settings/fields.tsx | 37 +++-- src/renderer/src/settings/settings.css | 36 ++++- src/renderer/src/sftp/FilePanel.tsx | 121 ++++++++------ src/renderer/src/sftp/TransferPanel.tsx | 41 ++--- src/renderer/src/terminal/TerminalView.tsx | 53 +++--- src/renderer/src/theme/editor/ThemeEditor.tsx | 27 ++-- src/renderer/src/workspace/ConnectFlow.tsx | 19 +-- src/renderer/src/workspace/HostKeyModal.tsx | 21 +-- src/renderer/src/workspace/SshBottomPanel.tsx | 3 +- src/renderer/src/workspace/SshHostBadge.tsx | 4 +- src/renderer/src/workspace/TerminalPanel.tsx | 5 +- src/renderer/src/workspace/TerminalTab.tsx | 39 ++--- src/renderer/src/workspace/Workspace.tsx | 81 ++++++---- src/renderer/src/workspace/ZmodemOffers.tsx | 18 ++- src/shared/i18n/dicts/en/common.ts | 17 ++ src/shared/i18n/dicts/en/index.ts | 19 +++ src/shared/i18n/dicts/en/main.ts | 56 +++++++ src/shared/i18n/dicts/en/panels.ts | 36 +++++ src/shared/i18n/dicts/en/settings.ts | 153 ++++++++++++++++++ src/shared/i18n/dicts/en/ssh.ts | 150 +++++++++++++++++ src/shared/i18n/dicts/en/terminal.ts | 27 ++++ src/shared/i18n/dicts/en/workspace.ts | 61 +++++++ src/shared/i18n/dicts/ja/common.ts | 17 ++ src/shared/i18n/dicts/ja/index.ts | 19 +++ src/shared/i18n/dicts/ja/main.ts | 56 +++++++ src/shared/i18n/dicts/ja/panels.ts | 36 +++++ src/shared/i18n/dicts/ja/settings.ts | 150 +++++++++++++++++ src/shared/i18n/dicts/ja/ssh.ts | 152 +++++++++++++++++ src/shared/i18n/dicts/ja/terminal.ts | 26 +++ src/shared/i18n/dicts/ja/workspace.ts | 62 +++++++ src/shared/i18n/dicts/zh-CN/common.ts | 17 ++ src/shared/i18n/dicts/zh-CN/index.ts | 18 +++ src/shared/i18n/dicts/zh-CN/main.ts | 56 +++++++ src/shared/i18n/dicts/zh-CN/panels.ts | 36 +++++ src/shared/i18n/dicts/zh-CN/settings.ts | 147 +++++++++++++++++ src/shared/i18n/dicts/zh-CN/ssh.ts | 144 +++++++++++++++++ src/shared/i18n/dicts/zh-CN/terminal.ts | 26 +++ src/shared/i18n/dicts/zh-CN/workspace.ts | 60 +++++++ src/shared/i18n/dicts/zh-TW/common.ts | 17 ++ src/shared/i18n/dicts/zh-TW/index.ts | 18 +++ src/shared/i18n/dicts/zh-TW/main.ts | 56 +++++++ src/shared/i18n/dicts/zh-TW/panels.ts | 36 +++++ src/shared/i18n/dicts/zh-TW/settings.ts | 147 +++++++++++++++++ src/shared/i18n/dicts/zh-TW/ssh.ts | 144 +++++++++++++++++ src/shared/i18n/dicts/zh-TW/terminal.ts | 26 +++ src/shared/i18n/dicts/zh-TW/workspace.ts | 60 +++++++ src/shared/i18n/index.ts | 83 ++++++++++ src/shared/settings.ts | 5 +- tests/commands-store.mjs | 17 +- tests/settings-store.mjs | 115 +++++++++++++ 82 files changed, 3342 insertions(+), 608 deletions(-) create mode 100644 CHANGELOG.en.md create mode 100644 CHANGELOG.ja.md create mode 100644 CHANGELOG.zh-TW.md create mode 100644 src/shared/i18n/dicts/en/common.ts create mode 100644 src/shared/i18n/dicts/en/index.ts create mode 100644 src/shared/i18n/dicts/en/main.ts create mode 100644 src/shared/i18n/dicts/en/panels.ts create mode 100644 src/shared/i18n/dicts/en/settings.ts create mode 100644 src/shared/i18n/dicts/en/ssh.ts create mode 100644 src/shared/i18n/dicts/en/terminal.ts create mode 100644 src/shared/i18n/dicts/en/workspace.ts create mode 100644 src/shared/i18n/dicts/ja/common.ts create mode 100644 src/shared/i18n/dicts/ja/index.ts create mode 100644 src/shared/i18n/dicts/ja/main.ts create mode 100644 src/shared/i18n/dicts/ja/panels.ts create mode 100644 src/shared/i18n/dicts/ja/settings.ts create mode 100644 src/shared/i18n/dicts/ja/ssh.ts create mode 100644 src/shared/i18n/dicts/ja/terminal.ts create mode 100644 src/shared/i18n/dicts/ja/workspace.ts create mode 100644 src/shared/i18n/dicts/zh-CN/common.ts create mode 100644 src/shared/i18n/dicts/zh-CN/index.ts create mode 100644 src/shared/i18n/dicts/zh-CN/main.ts create mode 100644 src/shared/i18n/dicts/zh-CN/panels.ts create mode 100644 src/shared/i18n/dicts/zh-CN/settings.ts create mode 100644 src/shared/i18n/dicts/zh-CN/ssh.ts create mode 100644 src/shared/i18n/dicts/zh-CN/terminal.ts create mode 100644 src/shared/i18n/dicts/zh-CN/workspace.ts create mode 100644 src/shared/i18n/dicts/zh-TW/common.ts create mode 100644 src/shared/i18n/dicts/zh-TW/index.ts create mode 100644 src/shared/i18n/dicts/zh-TW/main.ts create mode 100644 src/shared/i18n/dicts/zh-TW/panels.ts create mode 100644 src/shared/i18n/dicts/zh-TW/settings.ts create mode 100644 src/shared/i18n/dicts/zh-TW/ssh.ts create mode 100644 src/shared/i18n/dicts/zh-TW/terminal.ts create mode 100644 src/shared/i18n/dicts/zh-TW/workspace.ts create mode 100644 src/shared/i18n/index.ts create mode 100644 tests/settings-store.mjs diff --git a/.gitignore b/.gitignore index 7217ce6..c831f26 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ research/ .sftp-dl-* tests/.commands-store.cjs +tests/.settings-store.cjs tests/.session-e2e.cjs tests/.hl-split-smoke.cjs tests/.zmodem-e2e.cjs @@ -33,8 +34,8 @@ release/ .min-test.bin .min2-test.bin -# release notes draft (consumed by scripts/release.cjs) -RELEASE_NOTES.md +# release notes drafts (consumed by scripts/release.cjs + sync-changelog.cjs) +RELEASE_NOTES*.md tmp-test/ tmp-test* diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md new file mode 100644 index 0000000..0a76d11 --- /dev/null +++ b/CHANGELOG.en.md @@ -0,0 +1,37 @@ +# OpenTerminal Changelog + +## v1.0.13 - 2026-09-20 + +- Simplified Chinese, Traditional Chinese, English and Japanese, switchable in Settings → System → Language; applies instantly and persists +- The whole app is translated: settings pages, workspace, terminal context menu and search, SSH connections, file panel, system monitor, command panel, tray menu and the close prompt +- antd's built-in component texts (empty states, pagination, …) follow the interface language + +- The changelog now ships **inside the app**, so it works offline instead of relying on a network endpoint +- The changelog is multi-language: shown in the active interface language, falling back to Simplified Chinese for versions without a translation + +- Fixed "Close button → Ask every time" doing nothing (it was silently rewritten to "Minimize to tray") +- Highlight rules are no longer silently dropped over shape problems: string priorities, 0/1 booleans and a missing foreground colour are repaired, and unknown fields are preserved +- Settings parse warnings are written to `settings-warnings.log` so a broken config is traceable + +- The terminal toolbar is configurable (Settings → Rendering & behaviour): open working directory (on by default), session-log recording, open logs folder +- "Open working directory" opens the terminal's current folder in the file manager, tracked live through `cd` +- The global show/hide shortcut is now **press-to-record**: focus the field and hit the combo; Esc cancels, Backspace clears; a modifier or function key is required so plain typing is never hijacked +- "Input suggestions" and "Record command history" now default to off (existing installs are reset once; later manual changes are never overwritten) +- Fixed the settings dialog content column not scrolling (an antd v6 container class rename); the theme page no longer shows a second nested scrollbar +- UI polish: render-mode and language pickers are joined segments, with corrected text contrast on the selected segment + +`scripts/sync-changelog.cjs` merges `RELEASE_NOTES.en.md` into this file at +release time. The content ships inside the app; the About tab shows the +changelog for the active interface language and falls back to Simplified +Chinese for versions that have no translation yet. + +## v1.0.12 - 2026-09-20 + +### Light-theme contrast fixes +- Tab bar and sidebar colours now derive from the theme background's luminance: on light themes the tab bar stays in the terminal's own colour family (no more muddy grey strip), while dark themes are unchanged +- Fixed tabs rendering as dark blocks with white text on light themes: tab surfaces and labels now come from theme variables — inactive tabs are light with dark text, and active-tab text is darkened automatically +- The settings dialog now follows light themes throughout: labels, descriptions, tables and palette text on the font, highlight and theme pages track the theme foreground and stay readable + +### Stability +- Session-log writes now use a per-file buffer with a single drain loop: burst output is merged and stays ordered, with no unbounded queue growth +- Settings writes are serialized: changing the close action from the tray no longer clobbers a concurrent save from the settings UI diff --git a/CHANGELOG.ja.md b/CHANGELOG.ja.md new file mode 100644 index 0000000..8e58cae --- /dev/null +++ b/CHANGELOG.ja.md @@ -0,0 +1,36 @@ +# OpenTerminal 更新履歴 + +## v1.0.13 - 2026-09-20 + +- 簡体字中国語 / 繁体字中国語 / English / 日本語 に対応。設定 → システム → 表示言語 から切り替え、即時反映され保存されます +- アプリ全体を翻訳:設定の各ページ、ワークスペース、ターミナルの右クリックメニューと検索、SSH 接続管理、ファイルパネル、システムモニター、コマンドパネル、トレイメニューと終了確認 +- antd コンポーネントの内蔵文言(空状態・ページネーションなど)も表示言語に追従 + +- 更新履歴は**アプリに同梱**され、オフラインでも表示できます(ネットワーク不要) +- 更新履歴も多言語対応:現在の表示言語で表示し、未翻訳のバージョンは簡体字中国語にフォールバック + +- 「閉じるボタンの動作 = 毎回確認」が機能しない問題を修正(従来は「トレイに最小化」へ無言で書き換えられていました) +- ハイライトルールが形式の問題で無言で破棄されなくなりました:優先度の文字列、0/1 の真偽値、前景色の欠落などは自動修復し、未知のフィールドは保持します +- 設定の解析警告を `settings-warnings.log` に記録し、設定異常を追跡可能にしました + +- ターミナル右上のツールバーを設定可能に(設定 → 描画と動作):作業ディレクトリを開く(既定で有効)、セッションログの記録、ログフォルダを開く +- 「作業ディレクトリを開く」:ターミナルの現在のフォルダをエクスプローラーで開きます(`cd` に追従) +- グローバル表示ショートカットを**キー録音式**に変更:入力欄をクリックして組み合わせキーを押すだけ。Esc でキャンセル、Backspace で消去。修飾キーまたはファンクションキーが必要で、通常の文字入力を奪いません +- 「入力候補」「コマンド履歴の記録」は既定でオフ(既存インストールは一度だけリセットされ、以後の手動設定は上書きされません) +- 設定ダイアログの内容欄がスクロールしない問題を修正(antd v6 のコンテナクラス名変更による)。テーマページの二重スクロールバーも解消 +- UI 調整:描画モードと言語のボタンを連結セグメント化し、選択時の文字コントラストを修正 + +リリース時に `scripts/sync-changelog.cjs` が `RELEASE_NOTES.ja.md` をこのファイルへ +マージします。内容はアプリに同梱され、「情報」タブは現在の表示言語の更新履歴を +表示します(未翻訳のバージョンは簡体字中国語にフォールバック)。 + +## v1.0.12 - 2026-09-20 + +### ライトテーマのコントラスト修正 +- タブバーとサイドバーの色をテーマ背景の明度から算出するように変更:ライトテーマではタブバーがターミナルと同じ色味になり(濁ったグレーの帯が解消)、ダークテーマの見た目は変わりません +- ライトテーマでタブが濃いブロック+白文字になっていた問題を修正:タブの背景と文字をテーマ変数から取得し、非アクティブタブは淡色背景+濃い文字、アクティブタブの文字は自動的に濃くなります +- 設定ダイアログ全体をライトテーマに対応:フォント/ハイライト/テーマ各ページのラベル・説明・表・パレットの文字がテーマの前景色に追従し、明るい背景でも読みやすくなりました + +### 安定性 +- セッションログの書き込みをファイル単位のバッファ + 単一の書き込みループに変更:大量出力時はまとめて書き込まれ、順序も保証され、書き込み回数に応じて無限に溜まることはなくなりました +- 設定の保存を直列化:トレイからの終了動作の変更と設定画面からの保存が同時でも上書きして更新を失わなくなりました diff --git a/CHANGELOG.md b/CHANGELOG.md index 8994800..8b8d732 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # OpenTerminal 更新日志 +## v1.0.13 - 2026-09-20 + +- 支持简体中文 / 繁體中文 / English / 日本語,入口在「设置 → 系统 → 界面语言」,切换即时生效并持久化 +- 全应用文案完成迁移:设置各页、工作区、终端右键菜单与搜索、SSH 连接管理、文件面板、系统监控、命令面板、托盘菜单与关闭询问 +- antd 组件内置文案(空状态、分页等)随界面语言切换 + +- 更新日志改为**随安装包内置**,离线也能查看;不再依赖网络接口 +- 更新日志支持多语言:按当前界面语言显示,某版本缺翻译时自动回退简体中文 + +- 修复「关闭按钮行为 = 每次询问」不生效的问题(此前会被静默改回「最小化到托盘」,选项形同虚设) +- 高亮规则不再因字段形状问题被静默丢弃:优先级字符串、0/1 布尔值、缺失前景色等会被自动修复,未知字段保留 +- 设置解析告警写入 `settings-warnings.log`,配置异常可追溯 + +- 终端右上角工具条可配置(设置 → 渲染与行为):打开工作区目录(默认开启)、记录会话日志、打开日志目录 +- 「打开工作区目录」按钮:一键在资源管理器打开终端当前目录,随 `cd` 实时跟踪 +- 全局唤起快捷键改为**按键录制**:点击输入框后直接按组合键,Esc 取消、退格清空;需带修饰键或功能键,避免误占普通按键 +- 「输入建议」「记录命令历史」默认关闭(已有安装会一次性重置,之后手动开关不再被覆盖) +- 修复设置弹窗内容列无法滚动的问题(antd v6 容器类名变更导致),主题页去掉多余的双层滚动条 +- 界面细节:渲染模式与界面语言按钮改为连体分段样式,选中态文字对比度修正 + 发布时由 `scripts/sync-changelog.cjs` 自动把 RELEASE_NOTES.md 合并进此文件; 内容随安装包内置,「关于」页的更新日志直接读这里,不依赖网络。 diff --git a/CHANGELOG.zh-TW.md b/CHANGELOG.zh-TW.md new file mode 100644 index 0000000..14dccba --- /dev/null +++ b/CHANGELOG.zh-TW.md @@ -0,0 +1,35 @@ +# OpenTerminal 更新日誌 + +## v1.0.13 - 2026-09-20 + +- 支援簡體中文 / 繁體中文 / English / 日本語,入口在「設定 → 系統 → 介面語言」,切換即時生效並持久化 +- 全應用文案完成遷移:設定各頁、工作區、終端右鍵選單與搜尋、SSH 連線管理、檔案面板、系統監控、命令面板、系統匣選單與關閉詢問 +- antd 元件內建文案(空狀態、分頁等)隨介面語言切換 + +- 更新日誌改為**隨安裝包內建**,離線也能查看,不再依賴網路介面 +- 更新日誌支援多語言:依目前介面語言顯示,某版本缺翻譯時自動回退簡體中文 + +- 修正「關閉按鈕行為 = 每次詢問」無效的問題(先前會被靜默改回「最小化到系統匣」) +- 高亮規則不再因欄位形狀問題被靜默丟棄:優先級字串、0/1 布林值、缺少前景色等會自動修復,未知欄位保留 +- 設定解析警告寫入 `settings-warnings.log`,設定異常可追溯 + +- 終端右上角工具列可設定(設定 → 渲染與行為):開啟工作區目錄(預設開啟)、記錄工作階段日誌、開啟日誌目錄 +- 「開啟工作區目錄」按鈕:一鍵在檔案總管開啟終端目前目錄,隨 `cd` 即時追蹤 +- 全域喚起快速鍵改為**按鍵錄製**:點擊輸入框後直接按組合鍵,Esc 取消、退格清空;需帶修飾鍵或功能鍵,避免誤佔一般按鍵 +- 「輸入建議」「記錄命令歷史」預設關閉(既有安裝會一次性重置,之後手動開關不再被覆寫) +- 修正設定對話框內容欄無法捲動的問題(antd v6 容器類名變更),主題頁移除多餘的雙層捲軸 +- 介面細節:渲染模式與介面語言按鈕改為連體分段樣式,選取狀態文字對比度修正 + +發佈時由 `scripts/sync-changelog.cjs` 自動把 `RELEASE_NOTES.zh-TW.md` 合併進此檔案; +內容隨安裝包內建,「關於」頁的更新日誌會依當前介面語言讀取(缺翻譯的版本自動回退簡中)。 + +## v1.0.12 - 2026-09-20 + +### 淺色主題對比度修復 +- 分頁列/側邊欄顏色改為依主題背景亮度自動調整:淺色主題下分頁列與終端同色系(不再出現混濁灰色條),深色主題外觀不變 +- 修復淺色主題下分頁呈深色塊、白字的問題:分頁底色與文字改由主題變數接管,非作用中分頁淺底深字,作用中分頁文字自動加深 +- 設定對話框全面適配淺色主題:字型、高亮、主題等頁面的標籤/說明/表格/調色盤文字改為跟隨主題前景色,淺色下清晰可讀 + +### 穩定性 +- 工作階段日誌寫入改為每檔案緩衝 + 單一寫入迴圈:高輸出情境下合併寫入、順序有保障,不再隨寫入次數無限堆積 +- 設定儲存序列化:系統匣修改關閉行為與設定介面儲存並行時不再互相覆蓋而遺失更新 diff --git a/package-lock.json b/package-lock.json index cfbf439..2b5aa08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-terminal", - "version": "1.0.12", + "version": "1.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-terminal", - "version": "1.0.12", + "version": "1.0.13", "dependencies": { "@lydell/node-pty": "^1.2.0-beta.15", "@xterm/addon-fit": "^0.11.0", diff --git a/package.json b/package.json index 3cb1392..1bcc8aa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "open-terminal", "productName": "OpenTerminal", - "version": "1.0.12", + "version": "1.0.13", "description": "Open-source terminal with SSH, split panes, themes and fonts", "main": "out/main/index.js", "author": "Bill", diff --git a/scripts/sync-changelog.cjs b/scripts/sync-changelog.cjs index b5207ad..90e746b 100644 --- a/scripts/sync-changelog.cjs +++ b/scripts/sync-changelog.cjs @@ -1,39 +1,62 @@ -/* Merge RELEASE_NOTES.md into CHANGELOG.md as the section for the current - package.json version. Idempotent: skips if the version is already present. - Run BEFORE `npm run dist` — the changelog is bundled into the app. - Usage: node scripts/sync-changelog.cjs */ +/* Merge per-language release notes into the matching CHANGELOG file as the + section for the current package.json version. Idempotent: a version already + present is skipped. Run BEFORE `npm run dist` — the changelogs are bundled + into the app and shown per interface language. + Usage: node scripts/sync-changelog.cjs + + RELEASE_NOTES.md (Simplified Chinese) is required; the other three are + optional — a version without a translation falls back to zh-CN in the UI. */ const fs = require('node:fs') const path = require('node:path') const ROOT = path.join(__dirname, '..') const pkgVersion = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version -const notesPath = path.join(ROOT, 'RELEASE_NOTES.md') -const changelogPath = path.join(ROOT, 'CHANGELOG.md') -const HEADER = '# OpenTerminal 更新日志' -const notes = fs.readFileSync(notesPath, 'utf8') - // Drop the notes' own leading title / `## vX` heading — the section heading - // is constructed here so CHANGELOG stays uniform (`## vX.Y.Z - date`). - .replace(/^#.*\n/gm, '') - .replace(new RegExp(`^## v${pkgVersion}.*\\n`), '') - .replace(/^\s+/, '') - .trimEnd() +const TARGETS = [ + { notes: 'RELEASE_NOTES.md', changelog: 'CHANGELOG.md', header: '# OpenTerminal 更新日志', required: true }, + { notes: 'RELEASE_NOTES.zh-TW.md', changelog: 'CHANGELOG.zh-TW.md', header: '# OpenTerminal 更新日誌' }, + { notes: 'RELEASE_NOTES.en.md', changelog: 'CHANGELOG.en.md', header: '# OpenTerminal Changelog' }, + { notes: 'RELEASE_NOTES.ja.md', changelog: 'CHANGELOG.ja.md', header: '# OpenTerminal 更新履歴' } +] -if (!notes) { - console.error('RELEASE_NOTES.md is empty — write release notes first') - process.exit(1) +const sync = ({ notes, changelog, header, required }) => { + const notesPath = path.join(ROOT, notes) + const changelogPath = path.join(ROOT, changelog) + if (!fs.existsSync(notesPath)) { + if (required) { + console.error(`${notes} is missing — write the release notes first`) + process.exit(1) + } + console.log(`${changelog}: no ${notes} — left unchanged`) + return + } + + const body = fs + .readFileSync(notesPath, 'utf8') + // Drop the notes' own title / `## vX` heading: the section heading is built + // here so every changelog stays uniform (`## vX.Y.Z - date`). + .replace(/^#.*\n/gm, '') + .replace(new RegExp(`^## v${pkgVersion}.*\\n`), '') + .replace(/^\s+/, '') + .trimEnd() + + if (!body) { + console.error(`${notes} is empty — write the release notes first`) + process.exit(1) + } + + const existing = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '' + if (existing.includes(`## v${pkgVersion} `)) { + console.log(`${changelog}: already has v${pkgVersion} — nothing to do`) + return + } + + const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${body}\n` + const updated = existing + ? existing.replace(new RegExp(`^(${header}\\n\\n)`), `$1${section}\n`) + : `${header}\n\n${section}` + fs.writeFileSync(changelogPath, updated, 'utf8') + console.log(`${changelog}: added v${pkgVersion} (${body.split('\n').length} lines)`) } -const section = `## v${pkgVersion} - ${new Date().toISOString().slice(0, 10)}\n\n${notes}\n` -const existing = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '' -if (existing.includes(`## v${pkgVersion} `)) { - console.log(`CHANGELOG.md already has v${pkgVersion} — nothing to do`) - process.exit(0) -} - -const updated = existing - ? existing.replace(new RegExp(`^(${HEADER}\\n\\n)`), `$1${section}\n`) - : `${HEADER}\n\n${section}` - -fs.writeFileSync(changelogPath, updated, 'utf8') -console.log(`CHANGELOG.md: added v${pkgVersion} (${notes.split('\n').length} lines)`) +for (const target of TARGETS) sync(target) diff --git a/src/main/index.ts b/src/main/index.ts index 31c40e3..bc6c05d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,9 +4,10 @@ import { join } from 'path' import { registerIpc } from './ipc' import { killAllPtys } from './pty' import { applyStartupSystemSettings, loadSettings } from './settingsStore' -import { initTray, markQuitting, onMainWindowClose } from './tray' +import { initTray, markQuitting, onMainWindowClose, refreshTrayMenu } from './tray' import { configureAutoUpdater, registerUpdateIpc } from './updater' import { applyWindowChrome } from './windowChrome' +import { onLanguageChange } from '@shared/i18n' import { getThemeById } from '@shared/theme' /** Re-create the main window (tray restore path after all windows are gone). */ @@ -122,6 +123,9 @@ app.whenReady().then(() => { applyStartupSystemSettings(loadSettings()) createWindow() initTray(showOrCreate) + // Tray labels are resolved from the dictionary at build time, so the menu has + // to be rebuilt whenever the interface language changes. + onLanguageChange(() => refreshTrayMenu()) configureAutoUpdater() app.on('activate', () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index cf1ff79..cc09f92 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3,6 +3,7 @@ import fontList from 'font-list' import { homedir } from 'os' import { statSync } from 'fs' import { Ipc, type AppInfo, type LayoutMeta, type PtyCreateOptions } from '../shared/ipc' +import { t } from '../shared/i18n' import type { HostKeyAction, SessionOpenOptions, SshConnection, SshConnectionInput } from '../shared/connections' import { getLayout, listLayouts, saveLayout, deleteLayout } from './layouts' import { startPolling, stopPolling } from './sysinfo' @@ -64,7 +65,7 @@ export function registerIpc(): void { broadcast: (channel, ...args) => broadcast(channel, ...args), getConnection: (connectionId) => { const found = connectionsStore.listConnections().find((c) => c.id === connectionId) - if (!found) throw new Error(`连接书签不存在 (${connectionId})`) + if (!found) throw new Error(t('main.ipc.connectionMissing', { id: connectionId })) return found }, getSecret: (conn, field) => connectionsStore.getSecret(conn, field), diff --git a/src/main/logSanitizer.ts b/src/main/logSanitizer.ts index 8b961c0..29c54b6 100644 --- a/src/main/logSanitizer.ts +++ b/src/main/logSanitizer.ts @@ -17,7 +17,9 @@ * emulation; their committed lines are preserved as-is. */ -const MARKER = '\n──── [全屏界面(TUI)输出已省略] ────\n' +import { t } from '../shared/i18n' + +const MARKER = (): string => `\n──── [${t('main.log.tuiOmitted')}] ────\n` type Mode = 'text' | 'esc' | 'esc-skip1' | 'csi' | 'osc' | 'osc-esc' @@ -88,7 +90,7 @@ export class LogSanitizer { this.alt = false if (this.altDirty) { this.altDirty = false - out += MARKER + out += MARKER() } } this.seq = '' @@ -128,7 +130,7 @@ export class LogSanitizer { } if (this.altDirty) { this.altDirty = false - out += MARKER + out += MARKER() } return out } diff --git a/src/main/pty.ts b/src/main/pty.ts index 5b60f47..7bf3caa 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -1,3 +1,4 @@ +import { t } from '../shared/i18n' import { spawn, type IPty } from '@lydell/node-pty' import { randomUUID } from 'crypto' import { homedir } from 'os' @@ -224,10 +225,10 @@ export async function openSession(opts: SessionOpenOptions): Promise<{ id: strin const deps = runtimeDeps if (!deps) { - throw new Error('SSH 会话服务尚未初始化') + throw new Error(t('main.pty.serviceNotReady')) } if (!opts.connectionId) { - throw new Error('SSH 会话缺少 connectionId') + throw new Error(t('main.pty.missingConnectionId')) } const conn = deps.getConnection(opts.connectionId) diff --git a/src/main/settingsStore.ts b/src/main/settingsStore.ts index 9587e13..f3b80ad 100644 --- a/src/main/settingsStore.ts +++ b/src/main/settingsStore.ts @@ -11,6 +11,7 @@ import { type TerminalSettings } from '../shared/settings' import type { TerminalTheme } from '../shared/theme' +import { DEFAULT_LANGUAGE, isLanguage, setLanguage } from '../shared/i18n' import { broadcast } from './broadcast' import { applyGlobalShortcut } from './globalShortcuts' import { applyWindowChrome } from './windowChrome' @@ -24,32 +25,87 @@ const DEFAULT_SYSTEM: SystemSettings = { // Must match DEFAULT_SETTINGS.system in @shared/settings — an "ask" here made // a fresh install prompt on close while the docs and UI promised tray. closeAction: 'tray', - autoCheckUpdate: true + autoCheckUpdate: true, + language: DEFAULT_LANGUAGE } const TERMINAL_KEYS = new Set(Object.keys(DEFAULT_SETTINGS.terminal) as (keyof TerminalSettings)[]) -/** Light structural check for a persisted highlight rule. */ -function isHighlightRule(value: unknown): value is HighlightRule { - if (value === null || typeof value !== 'object') return false +/** Load-time warnings (bad terminal keys, repaired/dropped rules). Surfaced via + * reportWarnings() so a hand-edited or migrated settings.json leaves a trace + * instead of silently losing data. */ +type Warnings = string[] + +/** Fallback colour for a rule whose own colour is unusable. */ +const DEFAULT_RULE_COLOR = '#e3b341' + +/** Repair one persisted rule instead of dropping it. Shape problems with an + * obvious fix (priority as "5", enabled as 1/0, a missing fg colour) are + * corrected; only entries that cannot be a rule at all are skipped. Unknown + * extra fields are preserved so newer/older schemas survive a round trip. */ +function coerceRule(value: unknown, index: number, warnings: Warnings): HighlightRule | null { + if (value === null || typeof value !== 'object') { + warnings.push(`highlightRules[${index}]: not an object — skipped`) + return null + } const rule = value as Record - return ( - typeof rule.id === 'string' && - typeof rule.pattern === 'string' && - typeof rule.enabled === 'boolean' && - typeof rule.priority === 'number' && - rule.color !== null && - typeof rule.color === 'object' && - typeof (rule.color as { fg?: unknown }).fg === 'string' - ) + if (typeof rule.id !== 'string' || typeof rule.pattern !== 'string') { + warnings.push(`highlightRules[${index}]: missing id/pattern — skipped`) + return null + } + + let enabled = true + if (typeof rule.enabled === 'boolean') enabled = rule.enabled + else if (rule.enabled === 1 || rule.enabled === 0) { + enabled = rule.enabled === 1 + warnings.push(`highlightRules[${index}].enabled repaired: ${String(rule.enabled)} → ${enabled}`) + } else if (rule.enabled !== undefined) { + warnings.push(`highlightRules[${index}].enabled repaired → true`) + } + + const rawPriority = typeof rule.priority === 'number' ? rule.priority : Number(rule.priority) + let priority = 10 + if (Number.isFinite(rawPriority)) { + priority = Math.min(100, Math.max(1, Math.round(rawPriority))) + if (priority !== rule.priority) { + warnings.push(`highlightRules[${index}].priority repaired: ${String(rule.priority)} → ${priority}`) + } + } else { + warnings.push(`highlightRules[${index}].priority repaired → ${priority}`) + } + + const color: { fg: string; bg?: string } = { fg: DEFAULT_RULE_COLOR } + if (rule.color !== null && typeof rule.color === 'object') { + const candidate = rule.color as Record + if (typeof candidate.fg === 'string' && candidate.fg !== '') color.fg = candidate.fg + else warnings.push(`highlightRules[${index}].color.fg repaired → ${DEFAULT_RULE_COLOR}`) + if (typeof candidate.bg === 'string' && candidate.bg !== '') color.bg = candidate.bg + } else { + warnings.push(`highlightRules[${index}].color repaired → ${DEFAULT_RULE_COLOR}`) + } + + return { + ...rule, + id: rule.id, + pattern: rule.pattern, + enabled, + priority, + color, + note: typeof rule.note === 'string' ? rule.note : undefined + } as HighlightRule } -function sanitizeRules(value: unknown): HighlightRule[] { +function sanitizeRules(value: unknown, warnings: Warnings): HighlightRule[] { // An explicit empty array is a valid choice ("no highlighting"); only // malformed data falls back to the built-in rules. Returning the defaults for // [] made deleting the last rule look like it silently failed. if (!Array.isArray(value)) return DEFAULT_HIGHLIGHT_RULES - return value.filter(isHighlightRule) + const rules: HighlightRule[] = [] + value.forEach((entry, index) => { + const rule = coerceRule(entry, index, warnings) + if (rule) rules.push(rule) + }) + return rules } function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { @@ -89,7 +145,9 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { const globalShowHide = typeof candidate.globalShowHide === 'string' ? candidate.globalShowHide : '' const closeAction = - candidate.closeAction === 'tray' || candidate.closeAction === 'exit' + candidate.closeAction === 'tray' || + candidate.closeAction === 'exit' || + candidate.closeAction === 'ask' ? candidate.closeAction : 'tray' system = { @@ -100,7 +158,8 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { autoCheckUpdate: candidate.autoCheckUpdate !== false, // Both default on/off as in DEFAULT_SETTINGS; absent means "not chosen". restoreSession: candidate.restoreSession !== false, - shellIntegration: candidate.shellIntegration === true + shellIntegration: candidate.shellIntegration === true, + language: isLanguage(candidate.language) ? candidate.language : DEFAULT_LANGUAGE } } } @@ -111,7 +170,7 @@ function deepMerge(raw: unknown): { settings: AppSettings; errors: string[] } { settings: { terminal, customThemes: themes, - highlightRules: sanitizeRules(highlightRules), + highlightRules: sanitizeRules(highlightRules, errors), system: system as SystemSettings }, errors @@ -124,6 +183,9 @@ let sleepBlockerId: number | undefined /** Apply OS-level effects of the system settings (login item, sleep blocker). */ function applySystemSettings(system: SystemSettings): void { + // The main process renders its own strings (tray menu, dialogs), so it tracks + // the language itself; subscribers rebuild whatever they own. + setLanguage(system.language ?? DEFAULT_LANGUAGE) try { app.setLoginItemSettings({ openAtLogin: system.launchAtLogin }) } catch (err) { @@ -144,10 +206,38 @@ export function applyStartupSystemSettings(settings: AppSettings): void { applySystemSettings(settings.system) } +/** + * Persist load-time warnings to `/settings-warnings.log`. + * + * Repeated identical warnings are collapsed (loadSettings runs on every save and + * on every command-history write) and the file is trimmed when it grows, so a + * broken config leaves a trace without filling the disk. + */ +let lastWarnings = '' + +function reportWarnings(warnings: string[]): void { + const line = warnings.join('; ') + if (!line) { + lastWarnings = '' + return + } + if (line === lastWarnings) return + lastWarnings = line + try { + const file = join(app.getPath('userData'), 'settings-warnings.log') + const existing = existsSync(file) ? readFileSync(file, 'utf8') : '' + const kept = existing.length > 64 * 1024 ? existing.slice(-32 * 1024) : existing + writeFileSync(file, `${kept}[${new Date().toISOString()}] ${line}\n`, 'utf8') + } catch { + // best effort: warnings must never break settings loading + } +} + export function loadSettings(): AppSettings { try { const raw: unknown = JSON.parse(readFileSync(settingsPath(), 'utf8')) - const { settings } = deepMerge(raw) + const { settings, errors } = deepMerge(raw) + reportWarnings(errors) return settings } catch { return { diff --git a/src/main/sftp.ts b/src/main/sftp.ts index c7ca88a..52e821e 100644 --- a/src/main/sftp.ts +++ b/src/main/sftp.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'crypto' import { createWriteStream, promises as fsp } from 'fs' import { basename, join } from 'path' import { Ipc } from '../shared/ipc' +import { t } from '../shared/i18n' import type { SftpEntry, TransferProgressEvent } from '../shared/sftp' /** ssh2 Client lookup injected by pty.ts (kind === 'ssh' sessions only). */ @@ -51,13 +52,20 @@ export function formatMode(mode: number): string { */ const sftpCache = new Map() +/** + * Our own "session is gone" error. `isTransportError` classifies on the class, + * not on the message text: the message is translated, the classifier must not + * depend on the active locale. + */ +class SessionGoneError extends Error {} + async function sftpOf(sessionId: string): Promise { const cached = sftpCache.get(sessionId) if (cached) return cached const client = clientProvider?.(sessionId) if (!client) { console.error(`[sftp-debug] provider=${typeof clientProvider} sessionId=${sessionId} cacheSize=${sftpCache.size}`) - throw new Error('SSH 会话不存在或已断开') + throw new SessionGoneError(t('main.sftp.sessionGone')) } const sftp = await new Promise((resolve, reject) => { client.sftp((err, sftp_) => (err != null ? reject(err) : resolve(sftp_))) @@ -77,8 +85,9 @@ function evictSftp(sessionId: string): void { * dropping the whole connection. Only transport-level death retries. */ function isTransportError(err: unknown): boolean { + if (err instanceof SessionGoneError) return true const msg = (err as Error | undefined)?.message ?? '' - return /not connected|会话不存在|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg) + return /not connected|ECONNRESET|EPIPE|timed out|disconnected|channel|read past end/i.test(msg) } /** Run `fn` with the session's cached sftp; a dead cached channel is evicted and retried once. */ @@ -185,7 +194,7 @@ export function deleteRemote(sessionId: string, paths: string[]): Promise } } if (paths.length > 0 && failures.length === paths.length) { - throw new Error(`删除失败: ${failures.join('; ')}`) + throw new Error(t('main.sftp.deleteFailed', { detail: failures.join('; ') })) } }) } @@ -205,19 +214,21 @@ function shQuote(value: string): string { } export function chmodRemote(sessionId: string, path: string, mode: string): Promise { - if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(`非法权限值: ${mode}`) + if (!/^[0-7]{1,4}$/.test(mode)) throw new Error(t('main.sftp.invalidMode', { mode })) return execQuiet(sessionId, `chmod ${mode} ${shQuote(path)}`) } export function chownRemote(sessionId: string, path: string, uid: number, gid: number): Promise { - if (!Number.isInteger(uid) || !Number.isInteger(gid)) throw new Error('非法 uid/gid') + if (!Number.isInteger(uid) || !Number.isInteger(gid)) { + throw new Error(t('main.sftp.invalidUidGid')) + } return execQuiet(sessionId, `chown ${uid}:${gid} ${shQuote(path)}`) } /** Run a command on the session's shell channel and wait for it to finish. */ function execQuiet(sessionId: string, cmd: string): Promise { const client = clientProvider?.(sessionId) - if (!client) throw new Error('SSH 会话不存在或已断开') + if (!client) throw new SessionGoneError(t('main.sftp.sessionGone')) return new Promise((resolve, reject) => { client.exec(cmd, (err, stream) => { if (err) { @@ -230,7 +241,7 @@ function execQuiet(sessionId: string, cmd: string): Promise { stderr += d.toString('utf8') }) stream.on('close', (code: number) => { - if (code) reject(new Error(stderr || `命令退出码 ${code}`)) + if (code) reject(new Error(stderr || t('main.sftp.commandExitCode', { code }))) else resolve() }) }) @@ -290,7 +301,7 @@ export function uploadRemote( let lastEmit = 0 const buf = Buffer.alloc(CHUNK) for (;;) { - if (transfer.cancelled) throw new Error('已取消') + if (transfer.cancelled) throw new Error(t('main.sftp.cancelled')) if (firstError) throw firstError while (inflight.size >= UPLOAD_WINDOW) { await Promise.race(inflight) @@ -378,7 +389,7 @@ export function downloadRemote( let lastEmit = 0 const buf = Buffer.alloc(CHUNK) for (;;) { - if (transfer.cancelled) throw new Error('已取消') + if (transfer.cancelled) throw new Error(t('main.sftp.cancelled')) const { bytesRead } = await p<{ bytesRead: number; buffer: Buffer }>(cb => sftp.read(handle, buf, 0, CHUNK, pos, cb) ) diff --git a/src/main/ssh.ts b/src/main/ssh.ts index 09260b7..0944600 100644 --- a/src/main/ssh.ts +++ b/src/main/ssh.ts @@ -14,6 +14,7 @@ */ import type { SshConnection, SshSecretOverride } from '../shared/connections' +import { t } from '../shared/i18n' import { Ipc } from '../shared/ipc' import { randomUUID } from 'crypto' import { readFileSync } from 'fs' @@ -85,7 +86,7 @@ export async function connectSsh( const armConnectTimer = (): void => { if (connectTimer) clearTimeout(connectTimer) connectTimer = setTimeout(() => { - fail(new Error(`连接超时 (${conn.host}:${conn.port})`)) + fail(new Error(t('main.ssh.connectTimeout', { host: conn.host, port: conn.port }))) }, timeouts.connect) } @@ -129,13 +130,13 @@ export async function connectSsh( try { deps.knownHosts.accept(conn.host, conn.port, hostKey, fingerprint) } catch (err) { - verifierErr = `保存主机指纹失败: ${(err as Error).message}` + verifierErr = t('main.ssh.saveFingerprintFailed', { detail: (err as Error).message }) verify(false) return } verify(true) } else { - verifierErr = '用户拒绝了主机指纹' + verifierErr = t('main.ssh.hostKeyRejected') verify(false) } }) @@ -156,7 +157,7 @@ export async function connectSsh( console.error(`[ssh] client error: ${err.message}`) const message = verifierErr ?? err.message if (!settled) { - fail(new Error(`连接失败 ${conn.host}:${conn.port}: ${message}`)) + fail(new Error(t('main.ssh.connectFailed', { host: conn.host, port: conn.port, detail: message }))) return } // Session already established: surface as a session exit and clean up. @@ -177,7 +178,11 @@ export async function connectSsh( { term: 'xterm-256color', cols: 80, rows: 24 }, (err: Error | undefined, shell: ClientChannel) => { if (err) { - fail(new Error(`无法打开 SSH shell (${conn.host}:${conn.port}): ${err.message}`)) + fail( + new Error( + t('main.ssh.shellOpenFailed', { host: conn.host, port: conn.port, detail: err.message }) + ) + ) return } if (settled) { @@ -240,7 +245,11 @@ export async function connectSsh( handshake.connect(cfg) } catch (err) { // e.g. unparseable privateKey is thrown synchronously by ssh2 - fail(new Error(`SSH 连接初始化失败 (${conn.host}:${conn.port}): ${(err as Error).message}`)) + fail( + new Error( + t('main.ssh.initFailed', { host: conn.host, port: conn.port, detail: (err as Error).message }) + ) + ) } }) } @@ -305,6 +314,6 @@ function readKeyFile(keyPath: string): string { try { return readFileSync(keyPath, 'utf8') } catch (err) { - throw new Error(`无法读取私钥文件 ${keyPath}: ${(err as Error).message}`) + throw new Error(t('main.ssh.readKeyFailed', { path: keyPath, detail: (err as Error).message })) } } \ No newline at end of file diff --git a/src/main/sysinfo.ts b/src/main/sysinfo.ts index e9d0cc1..d9f58df 100644 --- a/src/main/sysinfo.ts +++ b/src/main/sysinfo.ts @@ -14,6 +14,7 @@ */ import { Ipc } from '../shared/ipc' +import { t } from '../shared/i18n' import type { Client } from 'ssh2' import type { SysinfoMeta, SysinfoSample } from '../shared/sysinfo' @@ -116,7 +117,7 @@ function pollOnce(id: string, state: PollState): void { if (state.stopped) return const client = clientProvider?.(id) if (!client) { - handleError(id, state, 'SSH 会话不存在或已断开') + handleError(id, state, t('main.sysinfo.sessionGone')) return } @@ -124,7 +125,7 @@ function pollOnce(id: string, state: PollState): void { client.exec(COLLECT_CMD, (err: Error | undefined, stream) => { if (state.stopped) return if (err || !stream) { - handleError(id, state, err?.message || 'SSH exec 失败') + handleError(id, state, err?.message || t('main.sysinfo.execFailed')) return } let out = '' @@ -220,7 +221,7 @@ function handleOutput(id: string, state: PollState, out: string): void { broadcastMeta(id, { hostname: parsed.hostname, os: parsed.osInfo }) } } catch (err) { - handleError(id, state, `解析失败: ${(err as Error).message}`) + handleError(id, state, t('main.sysinfo.parseFailed', { message: (err as Error).message })) return } armNext(id, state) diff --git a/src/main/tray.ts b/src/main/tray.ts index 6cc6a4d..7cbd47d 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -1,5 +1,6 @@ import { app, BrowserWindow, dialog, Menu, nativeImage, Tray } from 'electron' import type { SystemSettings } from '@shared/settings' +import { t } from '@shared/i18n' import { loadSettings, mutateSettings } from './settingsStore' import trayIconPath from './assets/tray.png?asset' @@ -8,6 +9,8 @@ export type CloseAction = NonNullable let tray: Tray | null = null let quitting = false let balloonShown = false +/** Window-restore callback handed to initTray; kept so the menu can be rebuilt. */ +let showOrCreateRef: (() => void) | null = null /** Once true, window close events pass through and the app really quits. */ export function markQuitting(): void { @@ -42,17 +45,19 @@ function refreshContextMenu(showOrCreate: () => void): void { persistCloseAction(value) refreshContextMenu(showOrCreate) } + // Labels are resolved while the template is built, so rebuilding the menu + // after a language change picks up the new strings. tray.setContextMenu( Menu.buildFromTemplate([ - { label: '显示主窗口', click: (): void => showMainWindow(showOrCreate) }, + { label: t('main.tray.showWindow'), click: (): void => showMainWindow(showOrCreate) }, { type: 'separator' }, - { label: '关闭按钮行为', enabled: false }, - { label: '每次询问', type: 'radio', checked: action === 'ask', click: setAction('ask') }, - { label: '最小化到托盘', type: 'radio', checked: action === 'tray', click: setAction('tray') }, - { label: '直接退出', type: 'radio', checked: action === 'exit', click: setAction('exit') }, + { label: t('main.tray.closeAction'), enabled: false }, + { label: t('main.tray.askEveryTime'), type: 'radio', checked: action === 'ask', click: setAction('ask') }, + { label: t('main.tray.minimizeToTray'), type: 'radio', checked: action === 'tray', click: setAction('tray') }, + { label: t('main.tray.exitDirectly'), type: 'radio', checked: action === 'exit', click: setAction('exit') }, { type: 'separator' }, { - label: '退出', + label: t('main.tray.exit'), click: (): void => { markQuitting() app.quit() @@ -62,10 +67,20 @@ function refreshContextMenu(showOrCreate: () => void): void { ) } +/** + * Rebuild the tray menu (after a language change, or any time the labels must + * be current). Safe to call before the tray exists. + */ +export function refreshTrayMenu(): void { + if (!tray || !showOrCreateRef) return + refreshContextMenu(showOrCreateRef) +} + /** Create the tray icon and wire left-click to window restore. */ export function initTray(showOrCreate: () => void): void { const icon = nativeImage.createFromPath(trayIconPath) tray = new Tray(icon) + showOrCreateRef = showOrCreate tray.setToolTip('OpenTerminal') tray.on('click', (): void => showMainWindow(showOrCreate)) refreshContextMenu(showOrCreate) @@ -79,8 +94,8 @@ function hideToTray(win: BrowserWindow): void { iconType: 'info', // The Windows toast already prints the app name as its header, so the // balloon title carries the state only — no "OpenTerminal" twice. - title: '已最小化到托盘', - content: '终端会话仍在后台运行,点击托盘图标可恢复窗口。' + title: t('main.tray.balloonTitle'), + content: t('main.tray.balloonContent') }) } } @@ -101,13 +116,13 @@ export async function onMainWindowClose(win: BrowserWindow, e: Electron.Event, s e.preventDefault() const { response, checkboxChecked } = await dialog.showMessageBox(win, { type: 'question', - title: '关闭 OpenTerminal', - message: '最小化到托盘,还是直接退出?', - detail: '最小化到托盘时终端会话保持运行。', - buttons: ['最小化到托盘', '直接退出'], + title: t('main.tray.closeTitle'), + message: t('main.tray.closeMessage'), + detail: t('main.tray.closeDetail'), + buttons: [t('main.tray.minimizeToTray'), t('main.tray.exitDirectly')], defaultId: 0, cancelId: 1, - checkboxLabel: '记住我的选择,不再询问', + checkboxLabel: t('main.tray.rememberChoice'), checkboxChecked: false, noLink: true }) diff --git a/src/main/updater.ts b/src/main/updater.ts index 14f385d..75bcf2a 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -1,6 +1,7 @@ import { app, ipcMain, net, session } from 'electron' import { autoUpdater } from 'electron-updater' import { Ipc, type ReleaseNote, type UpdateState } from '../shared/ipc' +import { t } from '../shared/i18n' import { broadcast } from './broadcast' import { loadSettings } from './settingsStore' import { markQuitting } from './tray' @@ -77,7 +78,7 @@ async function checkWithFallback(): Promise { await autoUpdater.checkForUpdates() } catch (err2) { const ghErr = err2 instanceof Error ? err2.message : String(err2) - throw new Error(`国内源: ${giteaErr};GitHub: ${ghErr}`) + throw new Error(t('main.updater.feedFailed', { gitea: giteaErr, github: ghErr })) } } } diff --git a/src/main/zmodem.ts b/src/main/zmodem.ts index 1d3e4e8..5165a4d 100644 --- a/src/main/zmodem.ts +++ b/src/main/zmodem.ts @@ -23,6 +23,7 @@ import * as Zmodem from 'zmodem.js' import { Ipc } from '../shared/ipc' import type { ZmodemDoneEvent, ZmodemResponse } from '../shared/ipc' import type { TransferProgressEvent } from '../shared/sftp' +import { t } from '../shared/i18n' /** How long to wait for the renderer to answer a ZMODEM_OFFER (ms). */ const OFFER_TIMEOUT_MS = 120_000 @@ -137,7 +138,7 @@ function finalize( engine.progressEmitted = true if (ok) emitDone(engine, true) - else emitDone(engine, false, message ?? '传输失败') + else emitDone(engine, false, message ?? t('main.zmodem.transferFailed')) } /** Called by the zsession's own 'session_end' event (its `this` is the session). */ @@ -149,7 +150,7 @@ function touchActivity(engine: Engine): void { if (engine.stallTimer) clearTimeout(engine.stallTimer) const timer = setTimeout(() => { if (engine.stallTimer === null) return // already finalized - finalize(engine, false, '传输超时') + finalize(engine, false, t('main.zmodem.transferTimeout')) }, STALL_TIMEOUT_MS) engine.stallTimer = timer } @@ -196,7 +197,7 @@ async function sendOneFile( xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)) } catch (err) { rs.destroy() - reject(err instanceof Error ? err : new Error('发送失败')) + reject(err instanceof Error ? err : new Error(t('main.zmodem.sendFailed'))) return } engine.bytes += buf.length @@ -233,7 +234,7 @@ async function runSend(engine: Engine, paths: string[]): Promise { // Final state + ZMODEM_DONE come from the session_end event fired by close(). await session.close() } catch (err) { - finalize(engine, false, err instanceof Error ? err.message : '上传失败') + finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.uploadFailed')) } } @@ -277,7 +278,7 @@ function handleOffer(engine: Engine, offer: Zmodem.Offer): void { } catch { // already gone } - finalize(engine, false, err instanceof Error ? err.message : '接收失败') + finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed')) }) } @@ -329,7 +330,9 @@ export function attachZmodem(sessionId: string, deps: ZmodemDeps): void { } touchActivity(engine) engine.offerTimer = setTimeout(() => { - if (engine.detection && !engine.confirmed) abortWithoutSession(engine, '未选择文件,已取消') + if (engine.detection && !engine.confirmed) { + abortWithoutSession(engine, t('main.zmodem.offerTimedOut')) + } }, OFFER_TIMEOUT_MS) }, on_retract: () => { @@ -405,7 +408,9 @@ export function feedZmodem(sessionId: string, data: Buffer): void { engine.sentry.consume(data) } catch (err) { // A zmodem.js Error (peer abort / corrupt frame) ends the session. - if (!engine.doneEmitted) finalize(engine, false, err instanceof Error ? err.message : 'ZMODEM 传输异常') + if (!engine.doneEmitted) { + finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.abnormal')) + } } } @@ -423,13 +428,13 @@ export function respondZmodem(resp: ZmodemResponse): void { if (!engine || engine.confirmed) return if (resp.cancelled) { - abortWithoutSession(engine, '已取消') + abortWithoutSession(engine, t('main.zmodem.cancelled')) return } const detection = engine.detection if (!detection || !detection.is_valid()) { - abortWithoutSession(engine, '会话已失效') + abortWithoutSession(engine, t('main.zmodem.sessionInvalid')) return } @@ -441,14 +446,14 @@ export function respondZmodem(resp: ZmodemResponse): void { } engine.session = detection.confirm() if (!engine.session) { - abortWithoutSession(engine, '无法建立 ZMODEM 会话') + abortWithoutSession(engine, t('main.zmodem.sessionCreateFailed')) return } wireSession(engine) if (engine.mode === 'receive') { if (!resp.dir) { - abortSession(engine, '未指定保存目录') + abortSession(engine, t('main.zmodem.noSaveDir')) return } engine.dir = resp.dir @@ -456,11 +461,13 @@ export function respondZmodem(resp: ZmodemResponse): void { session.on('offer', (offer) => handleOffer(engine, offer)) void session .start() - .catch((err) => finalize(engine, false, err instanceof Error ? err.message : '接收失败')) + .catch((err) => + finalize(engine, false, err instanceof Error ? err.message : t('main.zmodem.receiveFailed')) + ) } else { const paths = resp.paths ?? [] if (paths.length === 0) { - abortSession(engine, '未选择文件') + abortSession(engine, t('main.zmodem.noFiles')) return } void runSend(engine, paths) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c4cb907..960de8f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,12 +1,26 @@ import { useEffect, useState } from 'react' +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import zhTW from 'antd/locale/zh_TW' +import enUS from 'antd/locale/en_US' +import jaJP from 'antd/locale/ja_JP' import Workspace from '@renderer/workspace/Workspace' import { SettingsDialog } from '@renderer/settings/SettingsDialog' import { TransferPanel } from '@renderer/sftp/TransferPanel' import { useSettingsStore } from '@renderer/settings/store' import { getThemeById } from '@shared/theme' +import { DEFAULT_LANGUAGE, setLanguage, type Language } from '@shared/i18n' import { applyChromeTheme, applyTabAccent } from '@renderer/theme/chrome' import appIconUrl from '../../../build/icon.png' +/** antd's own component texts (empty states, pagination, date pickers). */ +const ANTD_LOCALES: Record = { + 'zh-CN': zhCN, + 'zh-TW': zhTW, + en: enUS, + ja: jaJP +} + /** Custom window title bar: draggable strip themed with the active terminal * theme; native min/max/close buttons come from BrowserWindow titleBarOverlay. */ function TitleBar(): React.JSX.Element { @@ -28,12 +42,18 @@ export default function App(): React.JSX.Element { const themeId = useSettingsStore((s) => s.settings.terminal.themeId) const customThemes = useSettingsStore((s) => s.settings.customThemes) const tabAccentColor = useSettingsStore((s) => s.settings.terminal.tabAccentColor) + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) const [settingsOpen, setSettingsOpen] = useState(false) useEffect(() => { void hydrate() }, [hydrate]) + // Interface language: drives t() and antd's built-in component texts. + useEffect(() => { + setLanguage(language) + }, [language]) + // Sidebar / tab bars / dividers follow the active terminal theme. useEffect(() => { applyChromeTheme(getThemeById(themeId, customThemes).colors) @@ -45,11 +65,13 @@ export default function App(): React.JSX.Element { }, [tabAccentColor]) return ( -
- - setSettingsOpen(true)} /> - setSettingsOpen(false)} /> - -
+ +
+ + setSettingsOpen(true)} /> + setSettingsOpen(false)} /> + +
+
) } diff --git a/src/renderer/src/ErrorBoundary.tsx b/src/renderer/src/ErrorBoundary.tsx index ee77766..1333cd7 100644 --- a/src/renderer/src/ErrorBoundary.tsx +++ b/src/renderer/src/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { Component } from 'react' import type { ErrorInfo, ReactNode } from 'react' +import { t } from '@shared/i18n' interface ErrorBoundaryProps { children: ReactNode @@ -38,7 +39,7 @@ export class ErrorBoundary extends Component -
界面出现异常
+
{t('workspace.error.title')}
 window.location.reload()}
             style={{ padding: '6px 18px', cursor: 'pointer' }}
           >
-            重新加载
+            {t('workspace.error.reload')}
           
         
       )
diff --git a/src/renderer/src/commands/CommandsPanel.tsx b/src/renderer/src/commands/CommandsPanel.tsx
index 81d4527..0238c26 100644
--- a/src/renderer/src/commands/CommandsPanel.tsx
+++ b/src/renderer/src/commands/CommandsPanel.tsx
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
 import { Button, Modal, Popconfirm, Tabs } from 'antd'
 import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'
 import type { CommandItem } from '@shared/commands'
+import { t } from '@shared/i18n'
 import { useWorkspaceModeStore } from '../workspace/workspaceModeStore'
 import './commands.css'
 
@@ -88,8 +89,10 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element
   return (
     
- 发送目标 - {workspaceMode === 'ssh' ? 'SSH 服务器' : '终端'} + {t('panels.commands.target')} + + {workspaceMode === 'ssh' ? t('panels.commands.targetSsh') : t('panels.commands.targetTerminal')} +
- 历史命令 + {t('panels.commands.historyTitle')} {history.length > 0 && ( void handleClearHistory()} > )}
{history.length === 0 ? ( -
{loaded ? '暂无历史命令' : '加载中…'}
+
+ {loaded ? t('panels.commands.noHistory') : t('panels.commands.loading')} +
) : (
    {history.map((item) => ( @@ -130,22 +135,24 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element }, { key: 'library', - label: '命令库', + label: t('panels.commands.tabLibrary'), children: (
    - 命令库 + {t('panels.commands.libraryTitle')}
    {library.length === 0 ? ( -
    {loaded ? '命令库为空' : '加载中…'}
    +
    + {loaded ? t('panels.commands.libraryEmpty') : t('panels.commands.loading')} +
    ) : (
      {library.map((item) => ( @@ -166,9 +173,9 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element void handleSave()} onCancel={() => setAddOpen(false)} @@ -176,23 +183,23 @@ export function CommandsPanel({ onRun }: CommandsPanelProps): React.JSX.Element >
      {item.name && onDelete && ( @@ -252,7 +259,7 @@ function Row({ danger icon={} className="commands-row-del" - aria-label="删除命令" + aria-label={t('panels.commands.deleteAria')} /> )} diff --git a/src/renderer/src/connections/Common.tsx b/src/renderer/src/connections/Common.tsx index 177cf23..f27a289 100644 --- a/src/renderer/src/connections/Common.tsx +++ b/src/renderer/src/connections/Common.tsx @@ -1,4 +1,5 @@ import type { SshAuthMethod, SshConnection, SshConnectionInput, SshSavedAuthFlags } from '@shared/connections' +import { t } from '@shared/i18n' /** * Shared helpers for the connection manager UI (connections/**, renderer). @@ -22,7 +23,11 @@ export function hasSavedKind(kind: SecretKind, savedAuth: SshSavedAuthFlags | un } export function authLabel(kind: SshAuthMethod): string { - return kind === 'privateKey' ? '私钥' : kind === 'agent' ? 'Agent' : '密码' + return kind === 'privateKey' + ? t('ssh.auth.privateKey') + : kind === 'agent' + ? t('ssh.auth.agent') + : t('ssh.auth.password') } export function AuthTags({ auth }: { auth: SshAuthMethod }): React.JSX.Element { diff --git a/src/renderer/src/connections/ConnectionEditDialog.tsx b/src/renderer/src/connections/ConnectionEditDialog.tsx index 05b6426..eedda42 100644 --- a/src/renderer/src/connections/ConnectionEditDialog.tsx +++ b/src/renderer/src/connections/ConnectionEditDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select } from 'antd' import type { SshAuthMethod, SshConnection, SshConnectionInput } from '@shared/connections' +import { getLanguage, t } from '@shared/i18n' import { AuthFieldsRenderer } from './Fields' import { buildInput, num, hasSavedKind, type SecretKind } from './Common' @@ -87,7 +88,7 @@ export function ConnectionEditDialog({ onSaved() onClose() } catch (err) { - setErrorMsg(err instanceof Error ? err.message : '保存失败,请检查输入后重试') + setErrorMsg(err instanceof Error ? err.message : t('ssh.dialog.saveFailed')) } finally { setSaving(false) } @@ -97,21 +98,24 @@ export function ConnectionEditDialog({ if (!saving) onClose() } + /** Active language; a dep of the memo below so translated labels refresh. */ + const language = getLanguage() + const savedNotes = useMemo(() => { if (!editing) return [] const kinds: Array<{ kind: SecretKind; label: string }> = [ - { kind: 'password', label: '密码' }, - { kind: 'keyContent', label: '私钥' }, - { kind: 'passphrase', label: '口令' } + { kind: 'password', label: t('ssh.auth.password') }, + { kind: 'keyContent', label: t('ssh.auth.privateKey') }, + { kind: 'passphrase', label: t('ssh.secret.passphrase') } ] const active = auth === 'password' ? ['password'] : auth === 'privateKey' ? ['keyContent', 'passphrase'] : [] return kinds.filter((k) => active.includes(k.kind) && hasSavedKind(k.kind, editing.savedAuth)) - }, [editing, auth]) + }, [editing, auth, language]) return ( {errorMsg}
    } @@ -136,17 +140,17 @@ export function ConnectionEditDialog({ > - + - + + - + setAuth(e.target.value as SshAuthMethod)}> - 密码 - 私钥 - Agent + {t('ssh.auth.password')} + {t('ssh.auth.privateKey')} + {t('ssh.auth.agent')} @@ -191,7 +195,7 @@ export function ConnectionEditDialog({ type="info" showIcon className="connections-secret-note" - message={`已保存${n.label},留空将保留原值`} + message={t('ssh.dialog.savedSecret', { kind: n.label })} /> ))} @@ -199,9 +203,9 @@ export function ConnectionEditDialog({ diff --git a/src/renderer/src/connections/ConnectionSidebar.tsx b/src/renderer/src/connections/ConnectionSidebar.tsx index e636be6..db69fd9 100644 --- a/src/renderer/src/connections/ConnectionSidebar.tsx +++ b/src/renderer/src/connections/ConnectionSidebar.tsx @@ -2,6 +2,7 @@ import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'r import { Button, Collapse, Popconfirm, Tooltip } from 'antd' import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons' import type { SshConnection } from '@shared/connections' +import { t } from '@shared/i18n' import { AuthTags } from './Common' import { ConnectionEditDialog } from './ConnectionEditDialog' import { CommandsPanel } from '../commands/CommandsPanel' @@ -28,18 +29,16 @@ export interface ConnectionSidebarHandle { refresh: () => Promise } -const DEFAULT_GROUP = '默认' - /** Short human "time ago" for the recent-session rows. */ function formatRelativeTime(ts: number): string { const diffSec = Math.max(0, Math.floor((Date.now() - ts) / 1000)) - if (diffSec < 60) return '刚刚' + if (diffSec < 60) return t('ssh.sidebar.timeJustNow') const mins = Math.floor(diffSec / 60) - if (mins < 60) return `${mins} 分钟前` + if (mins < 60) return t('ssh.sidebar.timeMinutesAgo', { n: mins }) const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours} 小时前` + if (hours < 24) return t('ssh.sidebar.timeHoursAgo', { n: hours }) const days = Math.floor(hours / 24) - if (days < 30) return `${days} 天前` + if (days < 30) return t('ssh.sidebar.timeDaysAgo', { n: days }) return new Date(ts).toLocaleDateString() } @@ -87,27 +86,28 @@ export const ConnectionSidebar = forwardRef { const groups = new Set() - for (const c of connections) groups.add(c.group ?? DEFAULT_GROUP) + for (const c of connections) groups.add(c.group ?? defaultGroup) return [...groups] - }, [connections]) + }, [connections, defaultGroup]) const groups = useMemo(() => { const map = new Map() for (const c of connections) { - const key = c.group ?? DEFAULT_GROUP + const key = c.group ?? defaultGroup const list = map.get(key) if (list) list.push(c) else map.set(key, [c]) } const entries = [...map.entries()].sort((a, b) => { - if (a[0] === DEFAULT_GROUP) return -1 - if (b[0] === DEFAULT_GROUP) return 1 + if (a[0] === defaultGroup) return -1 + if (b[0] === defaultGroup) return 1 return a[0].localeCompare(b[0]) }) return entries.map(([group, list]) => ({ group, list })) - }, [connections]) + }, [connections, defaultGroup]) const openCreate = (): void => { setEditing(null) @@ -163,16 +163,16 @@ export const ConnectionSidebar = forwardRef
    - {isTerminal ? '终端工作区' : 'SSH 工作区'} + {isTerminal ? t('ssh.sidebar.modeTerminal') : t('ssh.sidebar.modeSsh')}
    - 连接 + {t('ssh.sidebar.title')}
    @@ -180,11 +180,11 @@ export const ConnectionSidebar = forwardRef
    - 终端 + {t('ssh.sidebar.sectionTerminals')} {localPanels.length}
    {localPanels.length === 0 ? ( -
    无打开的本地终端
    +
    {t('ssh.sidebar.noLocalTerminals')}
    ) : (
      {localPanels.map((panel) => ( @@ -195,7 +195,7 @@ export const ConnectionSidebar = forwardRef {panel.title} - 本地 + {t('ssh.sidebar.local')} ))}
    @@ -206,16 +206,16 @@ export const ConnectionSidebar = forwardRef
    - SSH 服务器 + {t('ssh.sidebar.sectionServers')}
    {connections.length === 0 ? (
    {loaded ? ( ) : ( - 加载中… + {t('ssh.sidebar.loading')} )}
    ) : ( @@ -231,7 +231,7 @@ export const ConnectionSidebar = forwardRef 0 && (
    - 最近会话 + {t('ssh.sidebar.recent')}
    {recent.map((conn) => (
    -
diff --git a/src/renderer/src/connections/Fields.tsx b/src/renderer/src/connections/Fields.tsx index 6568bd9..7e305ef 100644 --- a/src/renderer/src/connections/Fields.tsx +++ b/src/renderer/src/connections/Fields.tsx @@ -2,6 +2,7 @@ import { Form, Input, Switch } from 'antd' import useFormInstance from 'antd/es/form/hooks/useFormInstance' import { useWatch } from 'antd/es/form/Form' import type { SshAuthMethod } from '@shared/connections' +import { t } from '@shared/i18n' /** * Auth-method dependent fields rendered inside the edit dialog's
. @@ -16,7 +17,7 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX const askPassphraseAtConnect = useWatch('askPassphraseAtConnect', form) ?? false if (auth === 'agent') { - return
使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。
+ return
{t('ssh.fields.agentDesc')}
} return ( @@ -25,36 +26,36 @@ export function AuthFieldsRenderer({ auth }: { auth: SshAuthMethod }): React.JSX <> {!askPasswordAtConnect && ( - - + + )} ) : ( <> - + - + {!askPassphraseAtConnect && ( - - + + )} diff --git a/src/renderer/src/monitor/MonitorPanel.tsx b/src/renderer/src/monitor/MonitorPanel.tsx index 52301b3..6b5def1 100644 --- a/src/renderer/src/monitor/MonitorPanel.tsx +++ b/src/renderer/src/monitor/MonitorPanel.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { SysinfoMeta, SysinfoSample } from '@shared/sysinfo' +import { t } from '@shared/i18n' import './monitor.css' export interface MonitorPanelProps { @@ -106,12 +107,12 @@ function formatSize(mb: number): string { /** human-readable uptime */ function humanizeUptime(sec: number): string { const s = Math.floor(sec) - if (s < 60) return `${s}秒` + if (s < 60) return t('ssh.monitor.uptimeSeconds', { n: s }) const mins = Math.floor(s / 60) - if (s < 3600) return `${mins}分钟` + if (s < 3600) return t('ssh.monitor.uptimeMinutes', { n: mins }) const hours = Math.floor(s / 3600) - if (s < 86400) return `${hours}小时${Math.floor((s % 3600) / 60)}分` - return `${Math.floor(s / 86400)}天` + if (s < 86400) return t('ssh.monitor.uptimeHours', { h: hours, m: Math.floor((s % 3600) / 60) }) + return t('ssh.monitor.uptimeDays', { n: Math.floor(s / 86400) }) } function Bar({ pct, color }: { pct: number; color: string }) { @@ -166,7 +167,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen return (
- 采集中… + {t('ssh.monitor.collecting')}
) } @@ -180,7 +181,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen return (
- {failed &&
采集中断:{sample.error}
} + {failed && ( +
{t('ssh.monitor.interrupted', { error: sample.error ?? '' })}
+ )}
{meta.hostname || 'unknown'} @@ -191,19 +194,19 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
CPU - {cpu.usage.toFixed(0)}% · {cpu.cores} 核 + {cpu.usage.toFixed(0)}% · {t('ssh.monitor.cores', { n: cpu.cores })}
- 负载 {cpu.loadavg.map((v) => v.toFixed(1)).join(' / ')} + {t('ssh.monitor.loadavg', { value: cpu.loadavg.map((v) => v.toFixed(1)).join(' / ') })}
-
+
- 内存 + {t('ssh.monitor.mem')} {formatSize(mem.usedMb)} / {formatSize(mem.totalMb)} @@ -217,9 +220,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
{disks.length > 0 && ( -
+
- 磁盘 + {t('ssh.monitor.disks')}
{disks.slice(0, 6).map((d) => { @@ -242,9 +245,9 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
)} -
+
- 网络 + {t('ssh.monitor.net')}
↓ @@ -265,7 +268,7 @@ export function MonitorPanel({ sessionId }: MonitorPanelProps): React.JSX.Elemen
- 运行时间 {humanizeUptime(sample.uptimeSec)} + {t('ssh.monitor.uptime')} {humanizeUptime(sample.uptimeSec)}
) diff --git a/src/renderer/src/settings/AboutTab.tsx b/src/renderer/src/settings/AboutTab.tsx index 9e010d0..bc24f45 100644 --- a/src/renderer/src/settings/AboutTab.tsx +++ b/src/renderer/src/settings/AboutTab.tsx @@ -1,55 +1,84 @@ import { useEffect, useState } from 'react' import { Button, Progress, Select, Switch, Tag } from 'antd' +import { DEFAULT_LANGUAGE, t, type Language } from '@shared/i18n' import type { AppInfo, ReleaseNote, UpdateState, UpdateStatus } from '@shared/ipc' import { useSettingsStore } from './store' import appIconUrl from '../../../../build/icon.png' -import changelogMd from '../../../../CHANGELOG.md?raw' +import changelogZhCN from '../../../../CHANGELOG.md?raw' +import changelogZhTW from '../../../../CHANGELOG.zh-TW.md?raw' +import changelogEn from '../../../../CHANGELOG.en.md?raw' +import changelogJa from '../../../../CHANGELOG.ja.md?raw' -const STATUS_TEXT: Record = { - idle: '点击检查更新', - checking: '正在检查更新…', - available: '发现新版本', - latest: '已经是最新版本', - downloading: '正在下载…', - downloaded: '下载完成,可重启安装', - error: '检查失败', - dev: '开发模式不支持更新检查' +/** Bundled changelog per language; zh-CN is the canonical source. */ +const CHANGELOGS: Record = { + 'zh-CN': changelogZhCN, + 'zh-TW': changelogZhTW, + en: changelogEn, + ja: changelogJa } -/** Parse the bundled CHANGELOG.md into release notes. Sections look like - * `## v1.0.12 - 2026-09-20`; the body below the heading is kept verbatim. */ -function parseChangelog(md: string): ReleaseNote[] { - const notes: ReleaseNote[] = [] +const STATUS_TEXT: Record = { + idle: 'settings.about.statusIdle', + checking: 'settings.about.statusChecking', + available: 'settings.about.statusAvailable', + latest: 'settings.about.statusLatest', + downloading: 'settings.about.statusDownloading', + downloaded: 'settings.about.statusDownloaded', + error: 'settings.about.statusError', + dev: 'settings.about.statusDev' +} + +/** Parse a bundled changelog into release notes, keyed by version. Sections + * look like `## v1.0.12 - 2026-09-20`; the body below is kept verbatim. */ +function parseChangelog(md: string): Map { + const notes = new Map() for (const section of md.split(/^## /m).slice(1)) { const lineEnd = section.indexOf('\n') if (lineEnd < 0) continue const head = section.slice(0, lineEnd).trim() const m = /^(v[\d.]+)\s*-\s*(\d{4}-\d{2}-\d{2})/.exec(head) const body = section.slice(lineEnd + 1).trim() - if (m && body) notes.push({ version: m[1], date: m[2], body }) + if (m && body) notes.set(m[1], { version: m[1], date: m[2], body }) } return notes } -/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置 CHANGELOG 优先,网络兜底)。 */ +/** Notes for `language`, newest first, falling back to zh-CN per version so a + * missing translation shows the canonical text instead of a gap. */ +function notesFor(language: Language): ReleaseNote[] { + const primary = parseChangelog(CHANGELOGS[language]) + const fallback = language === DEFAULT_LANGUAGE ? primary : parseChangelog(CHANGELOGS[DEFAULT_LANGUAGE]) + const versions = new Set([...primary.keys(), ...fallback.keys()]) + return [...versions] + .map((v) => primary.get(v) ?? fallback.get(v)) + .filter((n): n is ReleaseNote => n !== undefined) + .sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true })) +} + +/** 关于页:版本信息 + 更新检查/下载/安装 + 更新日志(内置多语言 CHANGELOG 优先,网络兜底)。 */ export function AboutTab(): React.JSX.Element { const [version, setVersion] = useState('') const [state, setState] = useState(null) const [notes, setNotes] = useState([]) const updateSystem = useSettingsStore((s) => s.updateSystem) const autoCheck = useSettingsStore((s) => s.settings.system.autoCheckUpdate !== false) + const language = useSettingsStore((s) => s.settings.system.language ?? DEFAULT_LANGUAGE) + + useEffect(() => { + // Embedded changelog first (works offline, follows the UI language); + // network sources as fallback. + const local = notesFor(language) + if (local.length > 0) setNotes(local) + else void window.api.updateChangelog().then(setNotes).catch(() => undefined) + }, [language]) useEffect(() => { void window.api.appInfo().then((i: AppInfo) => setVersion(i.appVersion)) - // Embedded changelog first (works offline); network sources as fallback. - const local = parseChangelog(changelogMd) - if (local.length > 0) setNotes(local) - else void window.api.updateChangelog().then(setNotes).catch(() => undefined) return window.api.onUpdateState(setState) }, []) const status = state?.status ?? 'idle' - const statusText = STATUS_TEXT[status] + (state?.version ? ` v${state.version}` : '') + const statusText = t(STATUS_TEXT[status]) + (state?.version ? ` v${state.version}` : '') return (
@@ -58,7 +87,7 @@ export function AboutTab(): React.JSX.Element {
OpenTerminal
- 当前版本: {version || '…'}{' '} + {t('settings.about.currentVersion')}: {version || '…'}{' '} {statusText} @@ -70,7 +99,7 @@ export function AboutTab(): React.JSX.Element {
patch({ note: e.target.value })} - placeholder="规则说明(可选)" + placeholder={t('settings.highlight.notePlaceholder')} maxLength={60} />
- 启用 + {t('settings.highlight.enabled')} patch({ enabled: c })} />
diff --git a/src/renderer/src/settings/SettingsDialog.tsx b/src/renderer/src/settings/SettingsDialog.tsx index 8c1882a..2e02f98 100644 --- a/src/renderer/src/settings/SettingsDialog.tsx +++ b/src/renderer/src/settings/SettingsDialog.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react' import { Button, Modal, Tabs } from 'antd' import type { TabsProps } from 'antd' +import { t } from '@shared/i18n' import { DEFAULT_SETTINGS } from '@shared/settings' import { useSettingsStore } from './store' import { CursorSettingsTab, FontSettingsTab, RenderSettingsTab, SystemSettingsTab } from './SettingsTabs' @@ -69,13 +70,13 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS } const tabItems: TabsProps['items'] = [ - { key: 'font', label: '字体', children: }, - { key: 'cursor', label: '光标与滚动', children: }, - { key: 'render', label: '渲染与行为', children: }, - { key: 'highlight', label: '高亮', children: }, + { key: 'font', label: t('settings.tabs.font'), children: }, + { key: 'cursor', label: t('settings.tabs.cursor'), children: }, + { key: 'render', label: t('settings.tabs.render'), children: }, + { key: 'highlight', label: t('settings.tabs.highlight'), children: }, { key: 'theme', - label: '主题', + label: t('settings.tabs.theme'), children: ( setEditorState({ open: true, themeId })} @@ -83,8 +84,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS /> ) }, - { key: 'system', label: '系统', children: }, - { key: 'about', label: '关于', children: } + { key: 'system', label: t('settings.tabs.system'), children: }, + { key: 'about', label: t('settings.tabs.about'), children: } ] const closeEditor = (): void => setEditorState((prev) => (prev.open ? { ...prev, open: false } : prev)) @@ -99,12 +100,12 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS onCancel={onClose} centered width={dialogSize.w} - title="设置" + title={t('settings.title')} footer={
- +
} @@ -115,8 +116,8 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps): React.JS {/* Drag corner: resizes the dialog until it is closed. */} 默认 }, + { + value: 'default', + label: {t('settings.font.default')} + }, ...fontOptions ]} filterOption={(input, option) => @@ -78,8 +82,8 @@ export function FontSettingsTab(): React.JSX.Element { } /> { if (v !== null) void updateTerminal({ cursorStyle: v }) }} - options={cursorStyleOptions} + options={cursorStyleOptions()} /> } /> { if (v !== null) void updateTerminal({ cursorInactiveStyle: v }) }} - options={inactiveCursorStyleOptions} + options={inactiveCursorStyleOptions()} /> } /> s.settings) const updateTerminal = useSettingsStore((s) => s.updateTerminal) + const rendererModes = rendererModeOptions() return (
- 渲染模式 - 终端单元渲染加速方式,改动将应用到所有终端 + {t('settings.render.modeLabel')} + {t('settings.render.modeHint')}
void updateTerminal({ rendererMode: e.target.value })} - options={rendererModeOptions} + options={rendererModes} optionType="button" buttonStyle="solid" />
- {rendererModeOptions.find((o) => o.value === settings.terminal.rendererMode)?.hint} + {rendererModes.find((o) => o.value === settings.terminal.rendererMode)?.hint}
s.updateSystem) return (
+ {/* Interface language — first row: the whole dialog re-renders on change. */} +
{t('common.language')}
+ void updateSystem({ language: e.target.value as Language })} + options={LANGUAGES.map((l) => ({ value: l.value, label: l.label }))} + optionType="button" + buttonStyle="solid" + /> +
{t('common.languageDesc')}
void updateSystem({ closeAction: value })} options={[ - { value: 'tray', label: '最小化到托盘' }, - { value: 'exit', label: '直接退出' }, - { value: 'ask', label: '每次询问' } + { value: 'tray', label: t('settings.system.closeActionTray') }, + { value: 'exit', label: t('settings.system.closeActionExit') }, + { value: 'ask', label: t('settings.system.closeActionAsk') } ]} /> } />
- 配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。 + {t('settings.system.globalShortcutHint')}
) @@ -481,8 +497,8 @@ function ShortcutInput({ value, onChange }: { value: string; onChange: (v: strin setRecording(true)} onBlur={() => setRecording(false)} onKeyDown={(e) => { @@ -557,7 +573,7 @@ function TerminalPreview({ } return (
- 预览 + {t('settings.preview')}
{lines.map((l, i) => (
diff --git a/src/renderer/src/settings/ThemeSettingsTab.tsx b/src/renderer/src/settings/ThemeSettingsTab.tsx index 52941d6..2825ac6 100644 --- a/src/renderer/src/settings/ThemeSettingsTab.tsx +++ b/src/renderer/src/settings/ThemeSettingsTab.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { Button, ColorPicker, Popconfirm } from 'antd' +import { t } from '@shared/i18n' import type { TerminalSettings } from '@shared/settings' import { DEFAULT_SETTINGS } from '@shared/settings' import type { TerminalTheme, ThemeColors } from '@shared/theme' @@ -49,8 +50,8 @@ export function ThemeSettingsTab({
-
标签强调色
-
活动标签边框、侧栏指示条与 SSH 徽章的强调颜色
+
{t('settings.theme.accent')}
+
{t('settings.theme.accentDesc')}
void updateTerminal({ tabAccentColor: DEFAULT_SETTINGS.terminal.tabAccentColor })} > - 恢复默认 + {t('common.reset')}
- {themeCards.map((t) => { - const selected = t.id === activeTheme.id - const isCustom = t.builtin === false + {themeCards.map((theme) => { + const selected = theme.id === activeTheme.id + const isCustom = theme.builtin === false return (
void updateTerminal({ themeId: t.id })} - title={t.name} + onClick={() => void updateTerminal({ themeId: theme.id })} + title={theme.name} > -
+
- {t.name} - {isCustom && 自定义} + {theme.name} + {isCustom && {t('settings.theme.customBadge')}}
{ANSI_KEYS.map((k) => ( ))}
@@ -98,29 +99,29 @@ export function ThemeSettingsTab({ size="small" onClick={(e) => { e.stopPropagation() - onEditTheme(t.id) + onEditTheme(theme.id) }} > - 编辑 + {t('common.edit')} { e?.stopPropagation() - void handleDelete(t.id) + void handleDelete(theme.id) }} > @@ -133,7 +134,7 @@ export function ThemeSettingsTab({
+
-
新建主题
+
{t('settings.theme.create')}
diff --git a/src/renderer/src/settings/fields.tsx b/src/renderer/src/settings/fields.tsx index 688e6f6..18043e1 100644 --- a/src/renderer/src/settings/fields.tsx +++ b/src/renderer/src/settings/fields.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { t } from '@shared/i18n' import type { TerminalSettings } from '@shared/settings' /** font stack shown as the persistent "默认" option */ @@ -8,31 +9,35 @@ export const WEIGHT_OPTIONS = [100, 200, 300, 400, 500, 600, 700, 800, 900] export const weightOptions = WEIGHT_OPTIONS.map((w) => ({ value: w, label: String(w) })) -export const cursorStyleOptions: Array<{ value: TerminalSettings['cursorStyle']; label: string }> = [ - { value: 'bar', label: '竖线 (bar)' }, - { value: 'block', label: '方块 (block)' }, - { value: 'underline', label: '下划线 (underline)' } +export const cursorStyleOptions = (): Array<{ value: TerminalSettings['cursorStyle']; label: string }> => [ + { value: 'bar', label: t('settings.cursorStyle.bar') }, + { value: 'block', label: t('settings.cursorStyle.block') }, + { value: 'underline', label: t('settings.cursorStyle.underline') } ] -export const inactiveCursorStyleOptions: Array<{ +export const inactiveCursorStyleOptions = (): Array<{ value: TerminalSettings['cursorInactiveStyle'] label: string -}> = [ - { value: 'outline', label: '轮廓 (outline)' }, - { value: 'block', label: '方块 (block)' }, - { value: 'bar', label: '竖线 (bar)' }, - { value: 'underline', label: '下划线 (underline)' }, - { value: 'none', label: '无 (none)' } +}> => [ + { value: 'outline', label: t('settings.cursorStyle.outline') }, + { value: 'block', label: t('settings.cursorStyle.block') }, + { value: 'bar', label: t('settings.cursorStyle.bar') }, + { value: 'underline', label: t('settings.cursorStyle.underline') }, + { value: 'none', label: t('settings.cursorStyle.none') } ] -export const rendererModeOptions: Array<{ +export const rendererModeOptions = (): Array<{ value: TerminalSettings['rendererMode'] label: string hint: string -}> = [ - { value: 'auto', label: '自动', hint: '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染' }, - { value: 'webgl', label: '高性能 WebGL', hint: '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动' }, - { value: 'dom', label: '兼容 DOM', hint: '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境' } +}> => [ + { value: 'auto', label: t('settings.rendererMode.auto'), hint: t('settings.rendererMode.autoHint') }, + { + value: 'webgl', + label: t('settings.rendererMode.webgl'), + hint: t('settings.rendererMode.webglHint') + }, + { value: 'dom', label: t('settings.rendererMode.dom'), hint: t('settings.rendererMode.domHint') } ] /** one label + control row inside a settings pane */ diff --git a/src/renderer/src/settings/settings.css b/src/renderer/src/settings/settings.css index 7432b60..84e91cc 100644 --- a/src/renderer/src/settings/settings.css +++ b/src/renderer/src/settings/settings.css @@ -163,10 +163,10 @@ color: color-mix(in srgb, var(--chrome-fg, #cccccc) 45%, transparent); } +/* Button-style radio groups render as one joined strip (no gaps, shared + borders) — matches the language picker and the reference UI. */ .settings-radio-group { - display: flex; - flex-wrap: wrap; - gap: 12px; + display: inline-flex; margin-bottom: 16px; } @@ -175,6 +175,36 @@ text-align: center; } +/* Language picker: one joined strip (no gaps, shared borders) instead of the + spread-out radio buttons used elsewhere. */ +.settings-language-group { + display: inline-flex; + margin-bottom: 16px; +} + +.settings-language-group .ant-radio-button-wrapper { + min-width: 104px; + text-align: center; +} + +/* Selected segment: the filled (primary) background needs a light label — + antd's own checked colour loses to our text overrides, so pin it here. */ +.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled), +.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) { + color: #fff; +} + +.settings-radio-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover, +.settings-language-group .ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover { + color: #fff; +} + +/* Own positive spacing: the shared `.settings-render-hint` uses a negative + top margin that lands the text on top of the button strip here. */ +.settings-language-hint { + margin: 6px 0 18px; +} + .settings-render-hint { margin: -8px 0 16px; } diff --git a/src/renderer/src/sftp/FilePanel.tsx b/src/renderer/src/sftp/FilePanel.tsx index 50515d3..b4a1bc9 100644 --- a/src/renderer/src/sftp/FilePanel.tsx +++ b/src/renderer/src/sftp/FilePanel.tsx @@ -17,6 +17,7 @@ import { import { App, Button, Checkbox, Dropdown, Input, Modal, Popconfirm, Tooltip } from 'antd' import type { MenuProps } from 'antd' import type { SftpEntry, TransferProgressEvent } from '@shared/sftp' +import { getLanguage, t } from '@shared/i18n' import './sftp.css' /** Remote file browser for one SSH session (M4). */ @@ -70,8 +71,14 @@ function fmtTime(ms: number): string { return `${d.getMonth() + 1}/${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } -const PERM_ROW_LABELS = ['所有者', '组', '其他'] as const -const PERM_COL_LABELS = ['读', '写', '执行'] as const +/** Permission-grid labels are resolved per render so they follow the language. */ +function permRowLabels(): string[] { + return [t('ssh.file.permRowOwner'), t('ssh.file.permRowGroup'), t('ssh.file.permRowOther')] +} + +function permColLabels(): string[] { + return [t('ssh.file.permColRead'), t('ssh.file.permColWrite'), t('ssh.file.permColExec')] +} interface PermissionModalProps { open: boolean @@ -89,6 +96,8 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio const [gid, setGid] = useState('') const [saving, setSaving] = useState(false) const hasUidGid = entry != null && (entry.uid != null || entry.gid != null) + const rowLabels = permRowLabels() + const colLabels = permColLabels() const modeStr = useMemo(() => { if (!entry) return { type: '-', str: '---------' } @@ -164,7 +173,7 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio await window.api.chownRemote(sessionId, entry.path, u, g) } if (octalStr !== curOctal || ownerChanged) { - message.success('已应用权限') + message.success(t('ssh.file.permApplied')) } onClose() onSaved() @@ -178,13 +187,13 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio return (
@@ -198,14 +207,14 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
- {PERM_COL_LABELS.map((l) => ( + {colLabels.map((l) => ( {l} ))}
- {PERM_ROW_LABELS.map((row, r) => ( + {rowLabels.map((row, r) => (
{row} - {PERM_COL_LABELS.map((_, c) => { + {colLabels.map((_, c) => { const idx = r * 3 + c return ( toggleBit(idx)} /> @@ -216,11 +225,11 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
- 八进制 + {t('ssh.file.octal')} applyOctal(e.target.value)} - placeholder="如 755" + placeholder={t('ssh.file.octalPlaceholder')} addonAfter={bitsToModeStr(modeStr.type, bits)} />
@@ -228,14 +237,22 @@ function PermissionModal({ open, sessionId, entry, onClose, onSaved }: Permissio
UID - setUid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} /> + setUid(e.target.value)} + placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} + />
GID - setGid(e.target.value)} placeholder={hasUidGid ? '不改则留空' : '0'} /> + setGid(e.target.value)} + placeholder={hasUidGid ? t('ssh.file.keepEmpty') : '0'} + />
-
UID/GID 留空表示不修改属主
+
{t('ssh.file.ownerHint')}
) @@ -363,13 +380,16 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { void (async () => { try { await navigator.clipboard.writeText(entry.path) - message.success('已复制路径') + message.success(t('ssh.file.pathCopied')) } catch { - message.error('复制路径失败') + message.error(t('ssh.file.copyPathFailed')) } })() } + /** Active language; a dep of the menu below so translated labels refresh. */ + const language = getLanguage() + /** opens the mkdir modal in create-a-file mode (no-op: new files unsupported) */ const menu: MenuProps = useMemo(() => { const target = menuTarget @@ -377,51 +397,51 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { const canAction = target != null return { items: [ - { key: 'refresh', label: '刷新', icon: }, + { key: 'refresh', label: t('ssh.file.refresh'), icon: }, { type: 'divider' }, { key: 'open', - label: isDir ? '打开' : '下载', + label: isDir ? t('ssh.file.open') : t('ssh.file.download'), icon: isDir ? : , disabled: !canAction }, { key: 'download', - label: '下载', + label: t('ssh.file.download'), icon: , disabled: !canAction || Boolean(isDir) }, - { key: 'upload', label: '上传…', icon: }, + { key: 'upload', label: t('ssh.file.upload'), icon: }, { key: 'rename', - label: '重命名', + label: t('ssh.file.rename'), icon: , disabled: !canAction }, { type: 'submenu', key: 'new', - label: '新建', + label: t('ssh.file.new'), icon: , children: [ - { key: 'mkdir', label: '新建文件夹', icon: } + { key: 'mkdir', label: t('ssh.file.newFolder'), icon: } // 新建文件:无现成 SFTP 创建文件 API(main 仅 mkdir/rename/chmod/chown),不做 ] }, { type: 'divider' }, { key: 'copyPath', - label: '复制路径', + label: t('ssh.file.copyPath'), icon: , disabled: !canAction }, { key: 'permission', - label: '文件权限…', + label: t('ssh.file.permission'), icon: , disabled: !canAction }, - { key: 'delete', label: '删除', icon: , danger: true, disabled: !canAction } + { key: 'delete', label: t('common.delete'), icon: , danger: true, disabled: !canAction } ], onClick: ({ key }) => { const entry = menuTarget @@ -447,11 +467,11 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { setPermOpen(true) } else if (key === 'delete') { modal.confirm({ - title: '确认删除?', + title: t('ssh.file.deleteTitle'), content: entry ? `${entry.name}(${entry.path})` : undefined, - okText: '删除', + okText: t('common.delete'), okButtonProps: { danger: true }, - cancelText: '取消', + cancelText: t('common.cancel'), onOk: () => new Promise((resolve, reject) => { if (!entry) { @@ -471,12 +491,12 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [menuTarget, dir, refresh]) + }, [menuTarget, dir, refresh, language]) return (
- +
@@ -552,8 +577,8 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element {
{error}
) : (
- {loading && entries.length === 0 &&
加载中…
} - {!loading && entries.length === 0 &&
空目录
} + {loading && entries.length === 0 &&
{t('ssh.file.loading')}
} + {!loading && entries.length === 0 &&
{t('ssh.file.empty')}
} {entries.map((e) => ( {e.name} - + {e.mode ?? '----------'} @@ -601,29 +626,29 @@ export function FilePanel({ sessionId }: FilePanelProps): React.JSX.Element { onSaved={() => void refresh(dir)} /> setMkdirOpen(false)} - okText="创建" - cancelText="取消" + okText={t('ssh.file.create')} + cancelText={t('common.cancel')} width={360} > setInputValue(e.target.value)} onPressEnter={doMkdir} /> setRenameOpen(false)} - okText="确定" - cancelText="取消" + okText={t('common.ok')} + cancelText={t('common.cancel')} width={360} > - {list.map(([id, t]) => { - const pct = t.totalBytes > 0 ? Math.min(100, Math.round((t.bytes / t.totalBytes) * 100)) : t.state === 'done' ? 100 : 0 + {list.map(([id, tr]) => { + const pct = tr.totalBytes > 0 ? Math.min(100, Math.round((tr.bytes / tr.totalBytes) * 100)) : tr.state === 'done' ? 100 : 0 return ( -
+
- {t.state === 'error' - ? '传输失败' - : t.state === 'cancelled' - ? '已取消' - : t.state === 'done' - ? '传输完成' - : t.kind === 'upload' - ? '上传中' - : t.kind === 'download' - ? '下载中' - : t.kind === 'zmodem-upload' - ? 'ZMODEM 上传中' - : 'ZMODEM 下载中'} + {tr.state === 'error' + ? t('panels.transfer.failed') + : tr.state === 'cancelled' + ? t('panels.transfer.cancelled') + : tr.state === 'done' + ? t('panels.transfer.done') + : tr.kind === 'upload' + ? t('panels.transfer.uploading') + : tr.kind === 'download' + ? t('panels.transfer.downloading') + : tr.kind === 'zmodem-upload' + ? t('panels.transfer.zmodemUploading') + : t('panels.transfer.zmodemDownloading')} - {(t.state === 'error' || t.state === 'done') && ( + {(tr.state === 'error' || tr.state === 'done') && (
-
{t.file || t.error || ''}
- {t.state === 'running' && t.totalBytes > 0 && ( +
{tr.file || tr.error || ''}
+ {tr.state === 'running' && tr.totalBytes > 0 && (
)} - {t.error &&
{t.error}
} + {tr.error &&
{tr.error}
}
) })} diff --git a/src/renderer/src/terminal/TerminalView.tsx b/src/renderer/src/terminal/TerminalView.tsx index 5f9e081..22ec99e 100644 --- a/src/renderer/src/terminal/TerminalView.tsx +++ b/src/renderer/src/terminal/TerminalView.tsx @@ -8,6 +8,7 @@ import { SearchAddon } from '@xterm/addon-search' import '@xterm/xterm/css/xterm.css' import { ClearOutlined, CopyOutlined, ExportOutlined, FolderOpenOutlined, PauseOutlined, SearchOutlined, SelectOutlined, SnippetsOutlined, SoundOutlined } from '@ant-design/icons' import { Checkbox, Modal } from 'antd' +import { t } from '@shared/i18n' import { getThemeById } from '@shared/theme' import type { CommandItem } from '@shared/commands' import type { TerminalSettings } from '@shared/settings' @@ -113,7 +114,7 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result } className="term-searchbar-input" type="text" value={value} - placeholder="搜索" + placeholder={t('terminal.search.placeholder')} spellCheck={false} autoFocus onChange={(e) => onChange(e.target.value)} @@ -123,10 +124,10 @@ function SearchBar({ value, onChange, onPrev, onNext, onClose, enabled, result } else if (e.key === 'Escape') onClose() }} /> - - + + {result} - +
) } @@ -454,7 +455,7 @@ export const TerminalView: ForwardRefExoticComponent runMenuAction(() => void copySelection())} > - 复制 + {t('common.copy')} Ctrl+Shift+C
@@ -1170,8 +1171,8 @@ export const TerminalView: ForwardRefExoticComponent { if (recording) void handleLogStop() else void handleLogStart() @@ -1184,8 +1185,8 @@ export const TerminalView: ForwardRefExoticComponent window.api.openLogsDir()} > @@ -1195,8 +1196,8 @@ export const TerminalView: ForwardRefExoticComponent { const dir = getSessionCwd(sessionId) if (dir) void window.api.openDirectory(dir) @@ -1208,29 +1209,29 @@ export const TerminalView: ForwardRefExoticComponent
-

检测到多行或潜在危险命令,确定要粘贴到终端吗?

+

{t('terminal.paste.message')}

setPaste((prev) => (prev ? { ...prev, noPrompt: e.target.checked } : prev))} > - 本次会话不再提示 + {t('terminal.paste.noPrompt')} setPaste((prev) => (prev ? { ...prev, disableDetection: e.target.checked } : prev))} > - 关闭后续粘贴检测 + {t('terminal.paste.disableDetection')} -

可在通用终端设置中重新开启或切换检测策略。

+

{t('terminal.paste.hint')}

{/* Relative-positioned wrapper around the xterm surface: the suggestion @@ -1258,14 +1259,14 @@ export const TerminalView: ForwardRefExoticComponent ))}
-
↑/↓ 选择 · Tab 接受 · Esc 关闭
+
{t('terminal.suggest.hint')}
)}
{dead && (
-
进程已退出 (代码 {exitCode})
- +
{t('terminal.dead.message', { code: exitCode })}
+
)}
diff --git a/src/renderer/src/theme/editor/ThemeEditor.tsx b/src/renderer/src/theme/editor/ThemeEditor.tsx index 5bbb867..40c43fd 100644 --- a/src/renderer/src/theme/editor/ThemeEditor.tsx +++ b/src/renderer/src/theme/editor/ThemeEditor.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { Input, Modal } from 'antd' +import { t } from '@shared/i18n' import type { ThemeColors, TerminalTheme } from '@shared/theme' import { DEFAULT_DARK, getThemeById } from '@shared/theme' import { useSettingsStore } from '../../settings/store' @@ -22,11 +23,11 @@ interface FieldSpec { label: string } -const HEADER_KEYS: FieldSpec[] = [ - { key: 'foreground', label: '前景' }, - { key: 'background', label: '背景' }, - { key: 'cursor', label: '光标' }, - { key: 'selectionBackground', label: '选区' } +const headerKeys = (): FieldSpec[] => [ + { key: 'foreground', label: t('settings.color.foreground') }, + { key: 'background', label: t('settings.color.background') }, + { key: 'cursor', label: t('settings.color.cursor') }, + { key: 'selectionBackground', label: t('settings.color.selection') } ] const ANSI_KEYS: FieldSpec[] = [ @@ -78,7 +79,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React } else { /** create mode: seed a fresh copy based on the currently active theme */ const active = getThemeById(settings.terminal.themeId, settings.customThemes) - setName(active.name + '(副本)') + setName(active.name + t('settings.themeEditor.copySuffix')) setColors({ ...active.colors }) } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -122,27 +123,27 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React open={open} onCancel={onClose} onOk={handleSave} - okText="保存" - cancelText="取消" + okText={t('common.save')} + cancelText={t('common.cancel')} confirmLoading={saving} destroyOnHidden width={560} - title={isCreate ? '新建主题' : '编辑主题'} + title={isCreate ? t('settings.theme.create') : t('settings.theme.editTitle')} okButtonProps={{ disabled: name.trim() === '' }} >
- 名称 + {t('settings.themeEditor.name')} setName(e.target.value)} - placeholder="主题名称" + placeholder={t('settings.themeEditor.namePlaceholder')} maxLength={60} />
- {HEADER_KEYS.map((spec) => ( + {headerKeys().map((spec) => ( ))}
@@ -152,7 +153,7 @@ export function ThemeEditor({ open, onClose, themeId }: ThemeEditorProps): React
- 预览 + {t('settings.preview')}
diff --git a/src/renderer/src/workspace/ConnectFlow.tsx b/src/renderer/src/workspace/ConnectFlow.tsx index e4f3b7e..0cc9865 100644 --- a/src/renderer/src/workspace/ConnectFlow.tsx +++ b/src/renderer/src/workspace/ConnectFlow.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Button, Input, Modal } from 'antd' import { LoadingOutlined } from '@ant-design/icons' import type { SshConnection, SshSecretOverride } from '@shared/connections' +import { t } from '@shared/i18n' /** * Connect-time gateways for one SSH connection attempt. @@ -54,9 +55,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP return ( setPassword(v)} onOk={() => onConfirmed({ password })} @@ -69,9 +70,9 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP return ( setPassphrase(v)} onOk={() => onConfirmed({ passphrase })} @@ -85,7 +86,7 @@ export function ConnectFlow({ conn, phase, onConfirmed, onCancel }: ConnectFlowP - 正在连接 {conn?.name != null ? conn.name : '…'} + {t('workspace.connect.connectingNamed', { name: conn?.name != null ? conn.name : '…' })}
@@ -137,8 +138,8 @@ function SecretModal({ key={key} open title={title} - okText="连接" - cancelText="取消" + okText={t('workspace.connect.action')} + cancelText={t('common.cancel')} onOk={handleOk} onCancel={onCancel} confirmLoading={submitting} @@ -169,7 +170,7 @@ export function ConnectSpinnerButton({ busy }: { busy: boolean }): React.JSX.Ele return busy ? ( ) : null } \ No newline at end of file diff --git a/src/renderer/src/workspace/HostKeyModal.tsx b/src/renderer/src/workspace/HostKeyModal.tsx index 5b14f29..8a5ae58 100644 --- a/src/renderer/src/workspace/HostKeyModal.tsx +++ b/src/renderer/src/workspace/HostKeyModal.tsx @@ -1,5 +1,6 @@ import { Alert, Button, Modal } from 'antd' import type { HostKeyPromptEvent } from '@shared/connections' +import { t } from '@shared/i18n' /** * Host key verification dialog. Shown one at a time (Workspace keeps a FIFO @@ -27,13 +28,13 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS open title={ isChanged ? ( -
主机指纹已变更!
+
{t('ssh.hostKey.changedTitle')}
) : ( - '未知主机' + t('ssh.hostKey.unknownHost') ) } - okText="接受并连接" - cancelText="拒绝" + okText={t('ssh.hostKey.accept')} + cancelText={t('ssh.hostKey.reject')} okButtonProps={isChanged ? { danger: true } : { type: 'primary' }} cancelButtonProps={{ danger: !isChanged }} onOk={() => onDecision('accept')} @@ -45,15 +46,17 @@ export function HostKeyModal({ event, onDecision }: HostKeyModalProps): React.JS ) : ( -
首次连接该主机,尚未记录其指纹。
+
{t('ssh.hostKey.firstConnect')}
)} -
主机:{target}
-
指纹:{event.fingerprint}
+
{t('ssh.hostKey.target', { target })}
+
+ {t('ssh.hostKey.fingerprint', { fingerprint: event.fingerprint })} +
) diff --git a/src/renderer/src/workspace/SshBottomPanel.tsx b/src/renderer/src/workspace/SshBottomPanel.tsx index 6d2c834..b40035d 100644 --- a/src/renderer/src/workspace/SshBottomPanel.tsx +++ b/src/renderer/src/workspace/SshBottomPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { t } from '@shared/i18n' import { FilePanel } from '../sftp/FilePanel' export interface SshBottomPanelProps { @@ -55,7 +56,7 @@ export function SshBottomPanel({ sessionId, terminal }: SshBottomPanelProps): Re className="ssh-bottom-divider" role="separator" aria-orientation="horizontal" - aria-label="调整文件面板高度" + aria-label={t('ssh.bottomPanel.resize')} onMouseDown={onDividerMouseDown} />
diff --git a/src/renderer/src/workspace/SshHostBadge.tsx b/src/renderer/src/workspace/SshHostBadge.tsx index adb6411..b33071c 100644 --- a/src/renderer/src/workspace/SshHostBadge.tsx +++ b/src/renderer/src/workspace/SshHostBadge.tsx @@ -1,3 +1,5 @@ +import { t } from '@shared/i18n' + /** * Small capsule badge showing the SSH host label (e.g. `root@36.151.147.123`) * on an SSH terminal tab. Host labels are often long, so the display string is @@ -9,7 +11,7 @@ export function SshHostBadge({ label }: { label: string }): React.JSX.Element { {label} diff --git a/src/renderer/src/workspace/TerminalPanel.tsx b/src/renderer/src/workspace/TerminalPanel.tsx index b4d8fcd..15c65e8 100644 --- a/src/renderer/src/workspace/TerminalPanel.tsx +++ b/src/renderer/src/workspace/TerminalPanel.tsx @@ -3,6 +3,7 @@ import type { IDockviewPanelProps } from 'dockview-react' import { Button } from 'antd' import { AreaChartOutlined } from '@ant-design/icons' +import { t } from '@shared/i18n' import { TerminalView } from '../terminal/TerminalView' import { MonitorPanel } from '../monitor/MonitorPanel' import { SshBottomPanel } from './SshBottomPanel' @@ -78,8 +79,8 @@ export function TerminalPanel({ params, api, onSessionDead }: TerminalPanelProps size="small" className="workspace-terminal-monitor-toggle" icon={} - aria-label={sideOpen ? '收起侧边栏' : '展开侧边栏'} - title={sideOpen ? '收起侧边栏' : '展开侧边栏'} + aria-label={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} + title={sideOpen ? t('workspace.panel.hideSidebar') : t('workspace.panel.showSidebar')} onClick={() => setSideOpen((prev) => !prev)} /> )} diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx index 3c587aa..b0beb42 100644 --- a/src/renderer/src/workspace/TerminalTab.tsx +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -6,6 +6,7 @@ import type { IDockviewPanelHeaderProps } from 'dockview-react' import { useBroadcastStore } from './broadcastStore' import { SshHostBadge } from './SshHostBadge' +import { t } from '@shared/i18n' import type { LayoutMeta } from '@shared/ipc' export type TerminalTabProps = IDockviewPanelHeaderProps<{ @@ -35,7 +36,7 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen ? hostLabel && hostLabel !== title ? `${title} — ${hostLabel}` : (hostLabel || title) - : '本地终端' + : t('workspace.tab.localTerminal') // M6 broadcast: show a badge on tabs that are current broadcast targets. const isBroadcastTarget = useBroadcastStore((state) => state.targets.has(params?.sessionId ?? '')) @@ -54,9 +55,9 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen const menu: MenuProps = { items: [ - { key: 'close', label: '关闭' }, - { key: 'closeOthers', label: '关闭其他', disabled: panel.panels.length <= 1 }, - { key: 'closeRight', label: '关闭右侧', disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } + { key: 'close', label: t('common.close') }, + { key: 'closeOthers', label: t('workspace.tab.closeOthers'), disabled: panel.panels.length <= 1 }, + { key: 'closeRight', label: t('workspace.tab.closeRight'), disabled: selfIndex === -1 || selfIndex === panel.panels.length - 1 } ], onClick: ({ key }) => { if (key === 'close') { @@ -85,16 +86,16 @@ export function TerminalTab({ api, params }: TerminalTabProps): React.JSX.Elemen {isBroadcastTarget && ( - 播 + {t('workspace.tab.broadcastBadge')} )} void handleDelete(meta)} > diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 44b20a7..349c8bc 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -25,6 +25,7 @@ import type { import 'dockview-react/dist/styles/dockview.css' import type { HostKeyPromptEvent, SshConnection, SshSecretOverride } from '@shared/connections' +import { t } from '@shared/i18n' import { ConnectionSidebar } from '../connections/ConnectionSidebar' import type { ConnectionSidebarHandle } from '../connections/ConnectionSidebar' import { ConnectFlow } from './ConnectFlow' @@ -68,13 +69,21 @@ const TERMINAL_TAB_COMPONENT = 'terminal-tab' */ function nextTerminalTitle(existing: Iterable): string { const used = new Set() + // The pattern is translated, so the number is read back through that same + // pattern (the `{n}` placeholder marks the digits) instead of a fixed one. + const [head, tail] = t('workspace.tab.terminalTitle', { n: '\u0000' }).split('\u0000') + const matcher = new RegExp(`^${escapeRegExp(head)}(\\d+)${escapeRegExp(tail ?? '')}$`) for (const title of existing) { - const n = title?.match(/^终端 (\d+)$/)?.[1] + const n = title?.match(matcher)?.[1] if (n) used.add(Number(n)) } let n = 1 while (used.has(n)) n++ - return `终端 ${n}` + return t('workspace.tab.terminalTitle', { n }) +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function sessionIdOf(panel: IDockviewPanel | undefined): string | undefined { @@ -184,7 +193,7 @@ function createTabActions(mode: WorkspaceMode): (props: IDockviewHeaderActionsPr const { message } = App.useApp() const handleAdd = (): void => { if (mode === 'ssh') { - message.info('SSH 会话请从服务器列表连接') + message.info(t('workspace.hint.sshFromList')) return } void (async (): Promise => { @@ -665,7 +674,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // B's sidebar refreshes lastConnectedAt on connect. void sidebarRef.current?.refresh() } catch (error) { - message.error(typeof error === 'string' ? error : 'SSH 连接失败') + message.error(typeof error === 'string' ? error : t('workspace.connect.failed')) } finally { setConnecting(false) setRequestedConn(null) @@ -714,7 +723,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const handleRailNewTerminal = useCallback((): void => { const mode = useWorkspaceModeStore.getState().mode if (mode === 'ssh') { - message.info('SSH 会话请从服务器列表连接') + message.info(t('workspace.hint.sshFromList')) return } void addTerminal() @@ -732,7 +741,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const current = sshApiRef.current const hasSession = current ? current.panels.some((p) => sessionIdOf(p)) : false if (!hasSession) { - message.info('请先连接 SSH 服务器') + message.info(t('workspace.hint.connectSshFirst')) return } const reference = activeSshPanelRef.current @@ -947,12 +956,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX (cmd: string): void => { const panel = activePanelRef.current if (!panel) { - message.info('没有激活的终端:请至少打开一个本地终端或 SSH 会话') + message.info(t('workspace.hint.noActiveTerminal')) return } const sid = sessionIdOf(panel) if (!sid) { - message.info('当前激活面板没有可用的会话') + message.info(t('workspace.hint.noSessionInPanel')) return } writeBroadcast(sid, `${cmd}\r`) @@ -963,11 +972,11 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const layoutMenu: MenuProps = { items: [ - { key: 'split-right', label: '水平分屏' }, - { key: 'split-below', label: '垂直分屏' }, + { key: 'split-right', label: t('workspace.layout.splitRight') }, + { key: 'split-below', label: t('workspace.layout.splitBelow') }, { type: 'divider' }, - { key: 'save', label: '保存为模板' }, - { key: 'apply', label: '应用模板' } + { key: 'save', label: t('workspace.layout.saveTemplate') }, + { key: 'apply', label: t('workspace.layout.applyTemplate') } ], onClick: ({ key }) => { if (key === 'split-right') handleSplit('right') @@ -1022,14 +1031,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX {terminalCount === 0 && (
-
暂无终端
+
{t('workspace.empty.terminalTitle')}
@@ -1056,8 +1065,8 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX {sshCount === 0 && (
-
暂无 SSH 会话
-
请从左侧服务器列表连接
+
{t('workspace.empty.sshTitle')}
+
{t('workspace.empty.sshHint')}
)} @@ -1102,8 +1111,8 @@ function AddTerminalGroupButton({ onClick }: { onClick: () => void }): React.JSX - + } > - {isReceive - ? '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。' - : '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。'} + {isReceive ? t('ssh.zmodem.receiveDesc') : t('ssh.zmodem.sendDesc')} ) diff --git a/src/shared/i18n/dicts/en/common.ts b/src/shared/i18n/dicts/en/common.ts new file mode 100644 index 0000000..06315ba --- /dev/null +++ b/src/shared/i18n/dicts/en/common.ts @@ -0,0 +1,17 @@ +/** Namespace: common (i18n plumbing, generic buttons/status). Owner: i18n infrastructure. */ +const common: Record = { + 'common.ok': 'OK', + 'common.cancel': 'Cancel', + 'common.close': 'Close', + 'common.save': 'Save', + 'common.delete': 'Delete', + 'common.edit': 'Edit', + 'common.add': 'Add', + 'common.copy': 'Copy', + 'common.paste': 'Paste', + 'common.reset': 'Reset to defaults', + 'common.language': 'Language', + 'common.languageDesc': 'Switches the interface language immediately; saved permanently.' +} + +export default common diff --git a/src/shared/i18n/dicts/en/index.ts b/src/shared/i18n/dicts/en/index.ts new file mode 100644 index 0000000..dab0b03 --- /dev/null +++ b/src/shared/i18n/dicts/en/index.ts @@ -0,0 +1,19 @@ +import common from './common' +import settings from './settings' +import workspace from './workspace' +import terminal from './terminal' +import ssh from './ssh' +import panels from './panels' +import main from './main' + +/** English entries. Namespace files are owned per area — see the ownership map + * in src/shared/i18n/index.ts. */ +export default { + ...common, + ...settings, + ...workspace, + ...terminal, + ...ssh, + ...panels, + ...main +} diff --git a/src/shared/i18n/dicts/en/main.ts b/src/shared/i18n/dicts/en/main.ts new file mode 100644 index 0000000..bf0ea24 --- /dev/null +++ b/src/shared/i18n/dicts/en/main.ts @@ -0,0 +1,56 @@ +/** Namespace: main (main-process strings: tray, close prompt, updater). Owner: main process. */ +const main: Record = { + 'main.tray.showWindow': 'Show main window', + 'main.tray.closeAction': 'Close button action', + 'main.tray.askEveryTime': 'Ask every time', + 'main.tray.minimizeToTray': 'Minimize to tray', + 'main.tray.exitDirectly': 'Exit directly', + 'main.tray.exit': 'Exit', + 'main.tray.balloonTitle': 'Minimized to tray', + 'main.tray.balloonContent': 'Terminal sessions keep running in the background. Click the tray icon to restore the window.', + 'main.tray.closeTitle': 'Close OpenTerminal', + 'main.tray.closeMessage': 'Minimize to tray, or exit now?', + 'main.tray.closeDetail': 'Minimizing to tray keeps your terminal sessions running.', + 'main.tray.rememberChoice': 'Remember my choice, never ask again', + + 'main.updater.feedFailed': 'Domestic feed: {gitea}; GitHub: {github}', + + 'main.ssh.connectTimeout': 'Connection timed out ({host}:{port})', + 'main.ssh.saveFingerprintFailed': 'Failed to save the host fingerprint: {detail}', + 'main.ssh.hostKeyRejected': 'The user rejected the host key', + 'main.ssh.connectFailed': 'Connection failed {host}:{port}: {detail}', + 'main.ssh.shellOpenFailed': 'Could not open the SSH shell ({host}:{port}): {detail}', + 'main.ssh.initFailed': 'SSH connection initialization failed ({host}:{port}): {detail}', + 'main.ssh.readKeyFailed': 'Could not read the private key file {path}: {detail}', + + 'main.sftp.sessionGone': 'The SSH session does not exist or has disconnected', + 'main.sftp.deleteFailed': 'Delete failed: {detail}', + 'main.sftp.invalidMode': 'Invalid permission value: {mode}', + 'main.sftp.invalidUidGid': 'Invalid uid/gid', + 'main.sftp.commandExitCode': 'Command exited with code {code}', + 'main.sftp.cancelled': 'Cancelled', + + 'main.zmodem.transferFailed': 'Transfer failed', + 'main.zmodem.transferTimeout': 'Transfer timed out', + 'main.zmodem.sendFailed': 'Sending failed', + 'main.zmodem.uploadFailed': 'Upload failed', + 'main.zmodem.receiveFailed': 'Receiving failed', + 'main.zmodem.abnormal': 'ZMODEM transfer error', + 'main.zmodem.cancelled': 'Cancelled', + 'main.zmodem.offerTimedOut': 'No files selected, cancelled', + 'main.zmodem.sessionInvalid': 'The session is no longer valid', + 'main.zmodem.sessionCreateFailed': 'Could not establish the ZMODEM session', + 'main.zmodem.noSaveDir': 'No save directory specified', + 'main.zmodem.noFiles': 'No files selected', + + 'main.ipc.connectionMissing': 'Connection bookmark not found ({id})', + + 'main.sysinfo.sessionGone': 'SSH session is missing or disconnected', + 'main.sysinfo.execFailed': 'SSH exec failed', + 'main.sysinfo.parseFailed': 'Parse failed: {message}', + 'main.pty.serviceNotReady': 'SSH session service is not initialised yet', + 'main.pty.missingConnectionId': 'SSH session is missing its connectionId', + 'main.log.tuiOmitted': 'full-screen (TUI) output omitted' +} + +export default main diff --git a/src/shared/i18n/dicts/en/panels.ts b/src/shared/i18n/dicts/en/panels.ts new file mode 100644 index 0000000..3265aab --- /dev/null +++ b/src/shared/i18n/dicts/en/panels.ts @@ -0,0 +1,36 @@ +/** Namespace: panels (command panel, session-log panel, transfer panel). Owner: panels area. */ +const panels: Record = { + // Command panel + 'panels.commands.sectionTitle': 'Commands', + 'panels.commands.target': 'Send to', + 'panels.commands.targetSsh': 'SSH server', + 'panels.commands.targetTerminal': 'Terminal', + 'panels.commands.tabHistory': 'History', + 'panels.commands.tabLibrary': 'Library', + 'panels.commands.historyTitle': 'Command history', + 'panels.commands.clearTitle': 'Clear all command history?', + 'panels.commands.clear': 'Clear', + 'panels.commands.noHistory': 'No command history', + 'panels.commands.loading': 'Loading…', + 'panels.commands.libraryTitle': 'Command library', + 'panels.commands.libraryEmpty': 'The command library is empty', + 'panels.commands.add': 'Add command', + 'panels.commands.name': 'Name', + 'panels.commands.optional': '(optional)', + 'panels.commands.command': 'Command', + 'panels.commands.commandPlaceholder': 'e.g. ls -la', + 'panels.commands.note': 'Note', + 'panels.commands.deleteTitle': 'Delete this command?', + 'panels.commands.deleteAria': 'Delete command', + + // Transfer panel + 'panels.transfer.failed': 'Transfer failed', + 'panels.transfer.cancelled': 'Cancelled', + 'panels.transfer.done': 'Transfer complete', + 'panels.transfer.uploading': 'Uploading', + 'panels.transfer.downloading': 'Downloading', + 'panels.transfer.zmodemUploading': 'ZMODEM uploading', + 'panels.transfer.zmodemDownloading': 'ZMODEM downloading' +} + +export default panels diff --git a/src/shared/i18n/dicts/en/settings.ts b/src/shared/i18n/dicts/en/settings.ts new file mode 100644 index 0000000..cdbd6ba --- /dev/null +++ b/src/shared/i18n/dicts/en/settings.ts @@ -0,0 +1,153 @@ +/** Namespace: settings (the settings dialog: font/cursor/render/highlight/theme/system/about). Owner: settings page. */ +const settings: Record = { + 'settings.title': 'Settings', + 'settings.tabs.font': 'Font', + 'settings.tabs.cursor': 'Cursor & Scrollback', + 'settings.tabs.render': 'Rendering & Behavior', + 'settings.tabs.highlight': 'Highlighting', + 'settings.tabs.theme': 'Themes', + 'settings.tabs.system': 'System', + 'settings.tabs.about': 'About', + 'settings.resizeHandle': 'Drag to resize', + 'settings.preview': 'Preview', + 'settings.deleteConfirmDesc': 'This cannot be undone. Continue?', + 'settings.color.foreground': 'Foreground', + 'settings.color.background': 'Background', + 'settings.color.cursor': 'Cursor', + 'settings.color.selection': 'Selection', + 'settings.font.label': 'Font', + 'settings.font.desc': 'The built-in default or a system monospace font', + 'settings.font.placeholder': 'Select a font', + 'settings.font.default': 'Default', + 'settings.font.size': 'Font size', + 'settings.font.sizeDesc': 'Pixel size of the terminal text', + 'settings.font.weight': 'Weight', + 'settings.font.weightDesc': '400 is regular, 700 is bold', + 'settings.font.boldWeight': 'Bold weight', + 'settings.font.boldWeightDesc': 'Weight used for bold text', + 'settings.font.letterSpacing': 'Letter spacing', + 'settings.font.letterSpacingDesc': 'Horizontal distance between characters', + 'settings.font.lineHeight': 'Line height', + 'settings.font.lineHeightDesc': 'Vertical distance between adjacent lines', + 'settings.cursor.blink': 'Blinking cursor', + 'settings.cursor.blinkDesc': 'Whether the cursor blinks periodically', + 'settings.cursor.style': 'Cursor style', + 'settings.cursor.styleDesc': 'Shape used to draw the cursor', + 'settings.cursor.inactiveStyle': 'Inactive cursor style', + 'settings.cursor.inactiveStyleDesc': 'Cursor style while the window is unfocused', + 'settings.cursor.scrollback': 'Scrollback buffer', + 'settings.cursor.scrollbackDesc': 'How many lines you can scroll back', + 'settings.cursorStyle.bar': 'Bar', + 'settings.cursorStyle.block': 'Block', + 'settings.cursorStyle.underline': 'Underline', + 'settings.cursorStyle.outline': 'Outline', + 'settings.cursorStyle.none': 'None', + 'settings.rendererMode.auto': 'Automatic', + 'settings.rendererMode.autoHint': 'Prefer WebGL acceleration, falling back to DOM rendering if it fails', + 'settings.rendererMode.webgl': 'High-performance WebGL', + 'settings.rendererMode.webglHint': + 'Force GPU-accelerated rendering; best for high refresh rates and heavy scrolling', + 'settings.rendererMode.dom': 'Compatible DOM', + 'settings.rendererMode.domHint': + 'Pure CPU rendering with the widest compatibility; best without a GPU or over remote desktop', + 'settings.render.modeLabel': 'Renderer', + 'settings.render.modeHint': 'How terminal cells are accelerated; applies to every terminal', + 'settings.render.autoWrap': 'Auto wrap', + 'settings.render.autoWrapDesc': 'Wrap lines that exceed the width', + 'settings.render.copyOnSelect': 'Copy on select', + 'settings.render.copyOnSelectDesc': 'Copy text automatically when you select it with the mouse', + 'settings.render.pasteRiskConfirm': 'Confirm risky paste', + 'settings.render.pasteRiskConfirmDesc': + 'Ask before pasting multi-line text (or a very long single line); single lines paste directly', + 'settings.render.suggest': 'Input suggestions', + 'settings.render.suggestDesc': + 'Show command suggestions while typing; Tab accepts, Enter always runs the command right away', + 'settings.render.history': 'Record command history', + 'settings.render.historyDesc': 'When off, new commands are no longer recorded; saved history is kept', + 'settings.render.historyLimit': 'History limit', + 'settings.render.historyLimitDesc': 'Most commands to keep; duplicates are stored only once', + 'settings.render.recButton': 'Terminal toolbar · Record', + 'settings.render.recButtonDesc': 'Show the "Record session log" button in the top-right corner', + 'settings.render.openLogsButton': 'Terminal toolbar · Open log folder', + 'settings.render.openLogsButtonDesc': 'Show the "Open log folder" button in the top-right corner', + 'settings.render.openCwdButton': 'Terminal toolbar · Open workspace folder', + 'settings.render.openCwdButtonDesc': + 'Show the "Open current folder" button in the top-right corner (hidden in SSH sessions; the folder follows cd in real time and needs shell integration or the cd command)', + 'settings.system.launchAtLogin': 'Launch at startup', + 'settings.system.launchAtLoginDesc': + 'Start OpenTerminal when the system boots (in development mode the dev build is registered)', + 'settings.system.preventSleep': 'Prevent system sleep', + 'settings.system.preventSleepDesc': + 'While on, OpenTerminal keeps the system from sleeping automatically; when off, the system follows its own power settings', + 'settings.system.restoreSession': 'Restore last session on startup', + 'settings.system.restoreSessionDesc': + 'Restore the split layout on the next launch and return each terminal to its last directory; when off, OpenTerminal always starts with a single terminal', + 'settings.system.shellIntegration': 'Shell integration', + 'settings.system.shellIntegrationDesc': + 'Let the shell report its working directory (OSC 7) for more precise path tracking, including cd inside scripts; it wraps your prompt, so turn it off if the prompt theme looks wrong', + 'settings.system.closeAction': 'Close button behavior', + 'settings.system.closeActionDesc': + 'What happens when you click the window close button; after minimizing to the tray you can quit from the tray icon context menu', + 'settings.system.closeActionTray': 'Minimize to tray', + 'settings.system.closeActionExit': 'Quit directly', + 'settings.system.closeActionAsk': 'Ask every time', + 'settings.system.globalShortcut': 'Global show/hide shortcut', + 'settings.system.globalShortcutDesc': + 'Click the field, then press the key combination; Esc cancels, Backspace clears and disables it', + 'settings.system.globalShortcutHint': + 'Together with the global key this shows or hides the window from anywhere; if registration fails (a conflict with another app) it simply has no effect and the app reports no error.', + 'settings.system.shortcutRecording': 'Press the keys…', + 'settings.system.shortcutPlaceholder': 'Click and press a shortcut, leave empty to disable', + 'settings.highlight.alert': + 'Custom highlighting improves readability at a small performance cost. Rules are applied in priority order. PS: colors are injected as text is written to the terminal, so existing scrollback is unaffected', + 'settings.highlight.enabled': 'Enabled', + 'settings.highlight.pattern': 'Pattern', + 'settings.highlight.patternPlaceholder': 'e.g. \\b(ERROR|FAILED)\\b', + 'settings.highlight.invalidRegex': 'Invalid regular expression', + 'settings.highlight.priority': 'Priority', + 'settings.highlight.priorityHint': 'Lower values apply first', + 'settings.highlight.color': 'Color', + 'settings.highlight.clearBg': 'Clear', + 'settings.highlight.addBg': 'Add', + 'settings.highlight.palette': 'Palette', + 'settings.highlight.note': 'Note', + 'settings.highlight.notePlaceholder': 'Rule description (optional)', + 'settings.highlight.actions': 'Actions', + 'settings.highlight.addRule': '+ Add rule', + 'settings.highlight.empty': 'No highlight rules yet — click "+ Add rule" to create one', + 'settings.highlight.deleteTitle': 'Delete highlight rule', + 'settings.highlight.resetTitle': 'Restore default highlight rules', + 'settings.highlight.resetDesc': 'Replaces every current rule with the built-in presets. This cannot be undone.', + 'settings.highlight.resetOk': 'Restore', + 'settings.highlight.createTitle': 'New highlight rule', + 'settings.highlight.editTitle': 'Edit highlight rule', + 'settings.theme.accent': 'Tab accent color', + 'settings.theme.accentDesc': 'Accent color for the active tab border, the sidebar indicator, and SSH badges', + 'settings.theme.customBadge': 'Custom', + 'settings.theme.deleteTitle': 'Delete custom theme', + 'settings.theme.create': 'New theme', + 'settings.theme.editTitle': 'Edit theme', + 'settings.themeEditor.name': 'Name', + 'settings.themeEditor.namePlaceholder': 'Theme name', + 'settings.themeEditor.copySuffix': ' (copy)', + 'settings.about.currentVersion': 'Current version', + 'settings.about.statusIdle': 'Click to check for updates', + 'settings.about.statusChecking': 'Checking for updates…', + 'settings.about.statusAvailable': 'A new version is available', + 'settings.about.statusLatest': 'Already up to date', + 'settings.about.statusDownloading': 'Downloading…', + 'settings.about.statusDownloaded': 'Download complete — restart to install', + 'settings.about.statusError': 'Check failed', + 'settings.about.statusDev': 'Update checks are unavailable in development mode', + 'settings.about.channelStable': 'Stable channel', + 'settings.about.autoCheck': 'Check automatically at startup', + 'settings.about.download': 'Download update', + 'settings.about.downloadGithub': ' (GitHub source)', + 'settings.about.install': 'Restart and install', + 'settings.about.check': 'Check for updates', + 'settings.about.checkingButton': 'Checking', + 'settings.about.changelog': 'Changelog', + 'settings.about.changelogEmpty': 'No changelog available' +} + +export default settings diff --git a/src/shared/i18n/dicts/en/ssh.ts b/src/shared/i18n/dicts/en/ssh.ts new file mode 100644 index 0000000..23d9b71 --- /dev/null +++ b/src/shared/i18n/dicts/en/ssh.ts @@ -0,0 +1,150 @@ +/** Namespace: ssh (ssh connections, host key prompt, file panel, zmodem, monitor). Owner: ssh area. */ +const ssh: Record = { + // Auth method / secret kind + 'ssh.auth.password': 'Password', + 'ssh.auth.privateKey': 'Private key', + 'ssh.auth.agent': 'Agent', + 'ssh.secret.passphrase': 'Passphrase', + + // Connection dialog: auth fields + 'ssh.fields.agentDesc': + 'Authenticate with a key already loaded in the local SSH Agent; this app stores nothing for you.', + 'ssh.fields.askPasswordLabel': 'Ask for password on connect', + 'ssh.fields.askPasswordHint': + 'Prompts for the password on every connection instead of using the saved one', + 'ssh.fields.passwordPlaceholder': 'Leave empty to keep the saved password', + 'ssh.fields.keyPath': 'Private key path (path on the server, optional)', + 'ssh.fields.keyContent': 'Private key content', + 'ssh.fields.keyContentPlaceholder': + 'Paste the private key; leave empty to use the path above (leave empty while editing to keep the saved key)', + 'ssh.fields.askPassphraseLabel': 'Ask for passphrase on connect', + 'ssh.fields.askPassphraseHint': 'Prompts for the key passphrase on every connection', + 'ssh.fields.passphrase': 'Key passphrase', + 'ssh.fields.passphrasePlaceholder': 'Leave empty to keep the saved passphrase', + + // Connection dialog + 'ssh.dialog.titleEdit': 'Edit connection', + 'ssh.dialog.titleNew': 'New connection', + 'ssh.dialog.saveFailed': 'Save failed. Check your input and try again.', + 'ssh.dialog.name': 'Name', + 'ssh.dialog.nameRequired': 'Please enter a connection name', + 'ssh.dialog.namePlaceholder': 'e.g. Production server', + 'ssh.dialog.group': 'Group', + 'ssh.dialog.groupPlaceholder': 'Pick or type a new group; leave empty for “Default”', + 'ssh.dialog.host': 'Host', + 'ssh.dialog.hostRequired': 'Please enter a host address', + 'ssh.dialog.hostPlaceholder': '192.168.1.10 or example.com', + 'ssh.dialog.port': 'Port', + 'ssh.dialog.portRange': 'Port must be between 1 and 65535', + 'ssh.dialog.username': 'Username', + 'ssh.dialog.usernameRequired': 'Please enter a username', + 'ssh.dialog.authMethod': 'Authentication', + 'ssh.dialog.savedSecret': 'A {kind} is saved; leave empty to keep it', + 'ssh.dialog.keepalive': 'Keepalive interval (seconds)', + 'ssh.dialog.keepaliveTooltip': '0 disables keepalive', + 'ssh.dialog.keepaliveMin': 'Cannot be negative', + + // Connection sidebar + 'ssh.sidebar.defaultGroup': 'Default', + 'ssh.sidebar.timeJustNow': 'just now', + 'ssh.sidebar.timeMinutesAgo': '{n} min ago', + 'ssh.sidebar.timeHoursAgo': '{n} h ago', + 'ssh.sidebar.timeDaysAgo': '{n} d ago', + 'ssh.sidebar.modeTerminal': 'Terminal workspace', + 'ssh.sidebar.modeSsh': 'SSH workspace', + 'ssh.sidebar.title': 'Connections', + 'ssh.sidebar.newConnection': 'New connection', + 'ssh.sidebar.sectionTerminals': 'Terminals', + 'ssh.sidebar.noLocalTerminals': 'No local terminal open', + 'ssh.sidebar.local': 'Local', + 'ssh.sidebar.sectionServers': 'SSH servers', + 'ssh.sidebar.empty': 'No connections yet — click + to create one', + 'ssh.sidebar.loading': 'Loading…', + 'ssh.sidebar.recent': 'Recent sessions', + 'ssh.sidebar.dblClickConnect': 'Double-click to connect to {host}', + 'ssh.sidebar.deleteTitle': 'Delete this connection?', + 'ssh.sidebar.deleteDesc': + 'A deleted connection cannot be restored. Its saved key material is removed as well.', + + // Host key verification + 'ssh.hostKey.changedTitle': 'Host fingerprint changed!', + 'ssh.hostKey.unknownHost': 'Unknown host', + 'ssh.hostKey.accept': 'Accept and connect', + 'ssh.hostKey.reject': 'Reject', + 'ssh.hostKey.mitmTitle': 'Possible man-in-the-middle attack', + 'ssh.hostKey.mitmDesc': + 'The remote host key fingerprint differs from the one recorded earlier. Unless you changed the server key yourself, someone may be impersonating this host.', + 'ssh.hostKey.firstConnect': + 'First connection to this host; its fingerprint has not been recorded yet.', + 'ssh.hostKey.target': 'Host: {target}', + 'ssh.hostKey.fingerprint': 'Fingerprint: {fingerprint}', + + // SSH workspace: file panel container, host badge + 'ssh.bottomPanel.resize': 'Resize the file panel', + 'ssh.hostBadge.aria': 'SSH host {label}', + + // ZMODEM + 'ssh.zmodem.done': 'ZMODEM transfer complete', + 'ssh.zmodem.failed': 'ZMODEM transfer failed: {message}', + 'ssh.zmodem.unknownError': 'unknown error', + 'ssh.zmodem.titleReceive': 'Download to local', + 'ssh.zmodem.titleSend': 'Upload to server', + 'ssh.zmodem.pickDir': 'Choose save folder', + 'ssh.zmodem.pickFiles': 'Choose files', + 'ssh.zmodem.receiveDesc': + 'An sz download request was detected (the remote side is sending files). Choose the local folder to save them in.', + 'ssh.zmodem.sendDesc': + 'An rz upload request was detected (the remote side is receiving files). Choose the files to upload.', + + // File panel (SFTP) + 'ssh.file.up': 'Parent folder', + 'ssh.file.refresh': 'Refresh', + 'ssh.file.uploadFile': 'Upload files', + 'ssh.file.downloadSelected': 'Download selection', + 'ssh.file.permOwner': 'Permissions / owner', + 'ssh.file.open': 'Open', + 'ssh.file.download': 'Download', + 'ssh.file.upload': 'Upload…', + 'ssh.file.rename': 'Rename', + 'ssh.file.new': 'New', + 'ssh.file.newFolder': 'New folder', + 'ssh.file.copyPath': 'Copy path', + 'ssh.file.copyPathFailed': 'Failed to copy the path', + 'ssh.file.pathCopied': 'Path copied', + 'ssh.file.permission': 'Permissions…', + 'ssh.file.deleteTitle': 'Delete?', + 'ssh.file.loading': 'Loading…', + 'ssh.file.empty': 'Empty folder', + 'ssh.file.modeUnavailable': 'Permissions unavailable', + 'ssh.file.folderName': 'Folder name', + 'ssh.file.create': 'Create', + 'ssh.file.renameTitle': 'Rename {name}', + 'ssh.file.permTitle': 'Permissions · {name}', + 'ssh.file.permApplied': 'Permissions applied', + 'ssh.file.octal': 'Octal', + 'ssh.file.octalPlaceholder': 'e.g. 755', + 'ssh.file.keepEmpty': 'Leave empty to keep', + 'ssh.file.ownerHint': 'Leave UID/GID empty to keep the current owner', + 'ssh.file.permRowOwner': 'Owner', + 'ssh.file.permRowGroup': 'Group', + 'ssh.file.permRowOther': 'Other', + 'ssh.file.permColRead': 'Read', + 'ssh.file.permColWrite': 'Write', + 'ssh.file.permColExec': 'Execute', + + // System monitor + 'ssh.monitor.collecting': 'Collecting…', + 'ssh.monitor.interrupted': 'Sampling stopped: {error}', + 'ssh.monitor.mem': 'Memory', + 'ssh.monitor.disks': 'Disks', + 'ssh.monitor.net': 'Network', + 'ssh.monitor.cores': '{n} cores', + 'ssh.monitor.loadavg': 'Load {value}', + 'ssh.monitor.uptime': 'Uptime', + 'ssh.monitor.uptimeSeconds': '{n}s', + 'ssh.monitor.uptimeMinutes': '{n} min', + 'ssh.monitor.uptimeHours': '{h} h {m} min', + 'ssh.monitor.uptimeDays': '{n} d' +} + +export default ssh diff --git a/src/shared/i18n/dicts/en/terminal.ts b/src/shared/i18n/dicts/en/terminal.ts new file mode 100644 index 0000000..98fb6c1 --- /dev/null +++ b/src/shared/i18n/dicts/en/terminal.ts @@ -0,0 +1,27 @@ +/** Namespace: terminal (terminal view: context menu, search, paste confirm, toolbar, hints). Owner: workspace/terminal. */ +const terminal: Record = { + 'terminal.menu.find': 'Find', + 'terminal.menu.selectAll': 'Select all', + 'terminal.menu.clear': 'Clear', + 'terminal.search.placeholder': 'Search', + 'terminal.search.prev': 'Previous', + 'terminal.search.next': 'Next', + 'terminal.search.found': 'Found', + 'terminal.search.notFound': 'Not found', + 'terminal.suggest.hint': '↑/↓ select · Tab accept · Esc close', + 'terminal.rec.start': 'Start recording', + 'terminal.rec.stop': 'Stop recording', + 'terminal.rec.startLog': 'Start session log', + 'terminal.rec.stopLog': 'Stop session log', + 'terminal.rec.openLogs': 'Open logs folder', + 'terminal.rec.openCwd': 'Open working directory', + 'terminal.paste.title': 'Confirm paste', + 'terminal.paste.message': 'Multi-line or potentially risky input detected. Paste it into the terminal?', + 'terminal.paste.noPrompt': "Don't ask again this session", + 'terminal.paste.disableDetection': 'Disable paste detection from now on', + 'terminal.paste.hint': + 'You can turn it back on or change the detection policy in the general terminal settings.', + 'terminal.dead.message': 'Process exited (code {code})' +} + +export default terminal diff --git a/src/shared/i18n/dicts/en/workspace.ts b/src/shared/i18n/dicts/en/workspace.ts new file mode 100644 index 0000000..26ff46e --- /dev/null +++ b/src/shared/i18n/dicts/en/workspace.ts @@ -0,0 +1,61 @@ +/** Namespace: workspace (workspace shell: sidebar, tabs, layout menu, split, empty states). Owner: workspace. */ +const workspace: Record = { + 'workspace.tab.terminalTitle': 'Terminal {n}', + 'workspace.tab.localTerminal': 'Local terminal', + 'workspace.tab.closeOthers': 'Close others', + 'workspace.tab.closeRight': 'Close to the right', + 'workspace.tab.broadcastTarget': 'Broadcast target', + 'workspace.tab.broadcastBadge': 'B', + 'workspace.tab.addInSplit': 'New terminal in this split', + 'workspace.newTerminal': 'New terminal', + 'workspace.empty.terminalTitle': 'No terminals', + 'workspace.empty.sshTitle': 'No SSH sessions', + 'workspace.empty.sshHint': 'Connect from the server list on the left', + 'workspace.rail.hideSidebar': 'Collapse sidebar', + 'workspace.rail.showSidebar': 'Expand sidebar', + 'workspace.rail.layout': 'Layout', + 'workspace.rail.settings': 'Settings', + 'workspace.rail.connecting': 'Connecting…', + 'workspace.rail.terminalWorkspace': 'Terminal workspace', + 'workspace.rail.sshWorkspace': 'SSH workspace', + 'workspace.panel.hideSidebar': 'Collapse sidebar', + 'workspace.panel.showSidebar': 'Expand sidebar', + 'workspace.layout.splitRight': 'Split horizontally', + 'workspace.layout.splitBelow': 'Split vertically', + 'workspace.layout.saveTemplate': 'Save as template', + 'workspace.layout.applyTemplate': 'Apply template', + 'workspace.template.saveTitle': 'Save layout template', + 'workspace.template.namePlaceholder': 'Template name', + 'workspace.template.applyTitle': 'Apply layout template', + 'workspace.template.empty': 'No saved templates', + 'workspace.template.apply': 'Apply', + 'workspace.template.deleteConfirm': 'Delete this template?', + 'workspace.broadcast.toggle': 'Broadcast input', + 'workspace.broadcast.activeTitle': 'Broadcasting ({count} targets)', + 'workspace.broadcast.popoverTerminal': 'Broadcast input (Terminal)', + 'workspace.broadcast.popoverSsh': 'Broadcast input (SSH)', + 'workspace.broadcast.switch': 'Broadcast switch', + 'workspace.broadcast.enabled': 'Enabled ({count})', + 'workspace.broadcast.needTwo': 'Select at least 2 targets', + 'workspace.broadcast.disabled': 'Disabled', + 'workspace.broadcast.emptySsh': 'No SSH server sessions', + 'workspace.broadcast.emptyTerminal': 'No terminals', + 'workspace.broadcast.hint': + 'Broadcasting turns on automatically once 2 or more terminals are picked, and turns off once fewer than 2 remain. Only sessions in the current mode can be picked.', + 'workspace.hint.sshFromList': 'Connect SSH sessions from the server list', + 'workspace.hint.connectSshFirst': 'Connect to an SSH server first', + 'workspace.hint.noActiveTerminal': 'No active terminal — open at least one local terminal or SSH session', + 'workspace.hint.noSessionInPanel': 'The active pane has no session available', + 'workspace.connect.failed': 'SSH connection failed', + 'workspace.connect.passwordTitle': 'Enter connection password', + 'workspace.connect.passwordPlaceholder': 'Password', + 'workspace.connect.passphraseTitle': 'Enter private key passphrase', + 'workspace.connect.passphrasePlaceholder': 'Passphrase', + 'workspace.connect.connecting': 'Connecting…', + 'workspace.connect.connectingNamed': 'Connecting to {name}', + 'workspace.connect.action': 'Connect', + 'workspace.error.title': 'The interface hit an error', + 'workspace.error.reload': 'Reload' +} + +export default workspace diff --git a/src/shared/i18n/dicts/ja/common.ts b/src/shared/i18n/dicts/ja/common.ts new file mode 100644 index 0000000..7fdd258 --- /dev/null +++ b/src/shared/i18n/dicts/ja/common.ts @@ -0,0 +1,17 @@ +/** 名前空間: common(i18n 基盤、汎用ボタン・状態)。担当: i18n 基盤。 */ +const common: Record = { + 'common.ok': 'OK', + 'common.cancel': 'キャンセル', + 'common.close': '閉じる', + 'common.save': '保存', + 'common.delete': '削除', + 'common.edit': '編集', + 'common.add': '追加', + 'common.copy': 'コピー', + 'common.paste': '貼り付け', + 'common.reset': '既定値に戻す', + 'common.language': '表示言語', + 'common.languageDesc': '切り替えるとすぐに表示言語が反映され、保存後は永続化されます。' +} + +export default common diff --git a/src/shared/i18n/dicts/ja/index.ts b/src/shared/i18n/dicts/ja/index.ts new file mode 100644 index 0000000..8e4b828 --- /dev/null +++ b/src/shared/i18n/dicts/ja/index.ts @@ -0,0 +1,19 @@ +import common from './common' +import settings from './settings' +import workspace from './workspace' +import terminal from './terminal' +import ssh from './ssh' +import panels from './panels' +import main from './main' + +/** 日本語の文言。名前空間ファイルは領域ごとに分担して保守します。詳細は + * src/shared/i18n/index.ts の担当表を参照。 */ +export default { + ...common, + ...settings, + ...workspace, + ...terminal, + ...ssh, + ...panels, + ...main +} diff --git a/src/shared/i18n/dicts/ja/main.ts b/src/shared/i18n/dicts/ja/main.ts new file mode 100644 index 0000000..311f807 --- /dev/null +++ b/src/shared/i18n/dicts/ja/main.ts @@ -0,0 +1,56 @@ +/** 名前空間: main(メインプロセスの文言: トレイ、終了確認、アップデータ)。担当: メインプロセス。 */ +const main: Record = { + 'main.tray.showWindow': 'メインウィンドウを表示', + 'main.tray.closeAction': '閉じるボタンの動作', + 'main.tray.askEveryTime': '毎回確認する', + 'main.tray.minimizeToTray': 'トレイに最小化', + 'main.tray.exitDirectly': '直接終了', + 'main.tray.exit': '終了', + 'main.tray.balloonTitle': 'トレイに最小化しました', + 'main.tray.balloonContent': 'ターミナルセッションはバックグラウンドで実行中です。トレイアイコンをクリックするとウィンドウを復元できます。', + 'main.tray.closeTitle': 'OpenTerminal を終了', + 'main.tray.closeMessage': 'トレイに最小化しますか、それとも終了しますか?', + 'main.tray.closeDetail': 'トレイに最小化すると、ターミナルセッションは実行を続けます。', + 'main.tray.rememberChoice': '選択を記憶して今後確認しない', + + 'main.updater.feedFailed': '国内フィード: {gitea};GitHub: {github}', + + 'main.ssh.connectTimeout': '接続がタイムアウトしました ({host}:{port})', + 'main.ssh.saveFingerprintFailed': 'ホスト鍵のフィンガープリントを保存できませんでした: {detail}', + 'main.ssh.hostKeyRejected': 'ユーザーがホスト鍵を拒否しました', + 'main.ssh.connectFailed': '接続に失敗しました {host}:{port}: {detail}', + 'main.ssh.shellOpenFailed': 'SSH シェルを開けません ({host}:{port}): {detail}', + 'main.ssh.initFailed': 'SSH 接続の初期化に失敗しました ({host}:{port}): {detail}', + 'main.ssh.readKeyFailed': '秘密鍵ファイルを読み取れません {path}: {detail}', + + 'main.sftp.sessionGone': 'SSH セッションが存在しないか、切断されています', + 'main.sftp.deleteFailed': '削除に失敗しました: {detail}', + 'main.sftp.invalidMode': '不正なパーミッション値: {mode}', + 'main.sftp.invalidUidGid': '不正な uid/gid', + 'main.sftp.commandExitCode': 'コマンドの終了コード {code}', + 'main.sftp.cancelled': 'キャンセルしました', + + 'main.zmodem.transferFailed': '転送に失敗しました', + 'main.zmodem.transferTimeout': '転送がタイムアウトしました', + 'main.zmodem.sendFailed': '送信に失敗しました', + 'main.zmodem.uploadFailed': 'アップロードに失敗しました', + 'main.zmodem.receiveFailed': '受信に失敗しました', + 'main.zmodem.abnormal': 'ZMODEM 転送エラー', + 'main.zmodem.cancelled': 'キャンセルしました', + 'main.zmodem.offerTimedOut': 'ファイルが選択されなかったためキャンセルしました', + 'main.zmodem.sessionInvalid': 'セッションが無効になりました', + 'main.zmodem.sessionCreateFailed': 'ZMODEM セッションを確立できません', + 'main.zmodem.noSaveDir': '保存先ディレクトリが指定されていません', + 'main.zmodem.noFiles': 'ファイルが選択されていません', + + 'main.ipc.connectionMissing': '接続ブックマークが見つかりません ({id})', + + 'main.sysinfo.sessionGone': 'SSH セッションが存在しないか切断されています', + 'main.sysinfo.execFailed': 'SSH exec に失敗しました', + 'main.sysinfo.parseFailed': '解析に失敗しました: {message}', + 'main.pty.serviceNotReady': 'SSH セッションサービスが未初期化です', + 'main.pty.missingConnectionId': 'SSH セッションに connectionId がありません', + 'main.log.tuiOmitted': '全画面 (TUI) 出力を省略しました' +} + +export default main diff --git a/src/shared/i18n/dicts/ja/panels.ts b/src/shared/i18n/dicts/ja/panels.ts new file mode 100644 index 0000000..e2055b0 --- /dev/null +++ b/src/shared/i18n/dicts/ja/panels.ts @@ -0,0 +1,36 @@ +/** 名前空間: panels(コマンドパネル、セッションログパネル、転送パネル)。担当: パネル。 */ +const panels: Record = { + // コマンドパネル + 'panels.commands.sectionTitle': 'コマンド', + 'panels.commands.target': '送信先', + 'panels.commands.targetSsh': 'SSH サーバー', + 'panels.commands.targetTerminal': 'ターミナル', + 'panels.commands.tabHistory': '履歴', + 'panels.commands.tabLibrary': 'コマンドライブラリ', + 'panels.commands.historyTitle': '履歴コマンド', + 'panels.commands.clearTitle': 'すべてのコマンド履歴を消去しますか?', + 'panels.commands.clear': '消去', + 'panels.commands.noHistory': 'コマンド履歴はありません', + 'panels.commands.loading': '読み込み中…', + 'panels.commands.libraryTitle': 'コマンドライブラリ', + 'panels.commands.libraryEmpty': 'コマンドライブラリは空です', + 'panels.commands.add': 'コマンドを追加', + 'panels.commands.name': '名前', + 'panels.commands.optional': '(任意)', + 'panels.commands.command': 'コマンド', + 'panels.commands.commandPlaceholder': '例:ls -la', + 'panels.commands.note': 'メモ', + 'panels.commands.deleteTitle': 'このコマンドを削除しますか?', + 'panels.commands.deleteAria': 'コマンドを削除', + + // 転送パネル + 'panels.transfer.failed': '転送に失敗しました', + 'panels.transfer.cancelled': 'キャンセル済み', + 'panels.transfer.done': '転送が完了しました', + 'panels.transfer.uploading': 'アップロード中', + 'panels.transfer.downloading': 'ダウンロード中', + 'panels.transfer.zmodemUploading': 'ZMODEM アップロード中', + 'panels.transfer.zmodemDownloading': 'ZMODEM ダウンロード中' +} + +export default panels diff --git a/src/shared/i18n/dicts/ja/settings.ts b/src/shared/i18n/dicts/ja/settings.ts new file mode 100644 index 0000000..a8bd2a4 --- /dev/null +++ b/src/shared/i18n/dicts/ja/settings.ts @@ -0,0 +1,150 @@ +/** 名前空間: settings(設定ダイアログ: フォント/カーソル/描画/ハイライト/テーマ/システム/情報)。担当: 設定画面。 */ +const settings: Record = { + 'settings.title': '設定', + 'settings.tabs.font': 'フォント', + 'settings.tabs.cursor': 'カーソルとスクロール', + 'settings.tabs.render': '描画と動作', + 'settings.tabs.highlight': 'ハイライト', + 'settings.tabs.theme': 'テーマ', + 'settings.tabs.system': 'システム', + 'settings.tabs.about': 'このアプリについて', + 'settings.resizeHandle': 'ドラッグしてサイズ変更', + 'settings.preview': 'プレビュー', + 'settings.deleteConfirmDesc': '削除すると元に戻せません。続けますか?', + 'settings.color.foreground': '前景', + 'settings.color.background': '背景', + 'settings.color.cursor': 'カーソル', + 'settings.color.selection': '選択範囲', + 'settings.font.label': 'フォント', + 'settings.font.desc': '組み込みの既定フォント、またはシステムの等幅フォント', + 'settings.font.placeholder': 'フォントを選択', + 'settings.font.default': 'デフォルト', + 'settings.font.size': '文字サイズ', + 'settings.font.sizeDesc': 'ターミナル文字のピクセルサイズ', + 'settings.font.weight': '文字の太さ', + 'settings.font.weightDesc': '400 が標準、700 が太字', + 'settings.font.boldWeight': '太字の太さ', + 'settings.font.boldWeightDesc': '太字テキストに使う太さ', + 'settings.font.letterSpacing': '文字間隔', + 'settings.font.letterSpacingDesc': '文字どうしの水平方向の間隔', + 'settings.font.lineHeight': '行の高さ', + 'settings.font.lineHeightDesc': '隣り合う行の垂直方向の距離', + 'settings.cursor.blink': 'カーソルの点滅', + 'settings.cursor.blinkDesc': 'カーソルを定期的に点滅させるかどうか', + 'settings.cursor.style': 'カーソルの形状', + 'settings.cursor.styleDesc': 'カーソルの表示形状', + 'settings.cursor.inactiveStyle': '非アクティブ時のカーソル形状', + 'settings.cursor.inactiveStyleDesc': 'ウィンドウがフォーカスを失ったときのカーソル形状', + 'settings.cursor.scrollback': 'スクロールバック', + 'settings.cursor.scrollbackDesc': 'さかのぼって表示できる行数', + 'settings.cursorStyle.bar': '縦線 (bar)', + 'settings.cursorStyle.block': 'ブロック (block)', + 'settings.cursorStyle.underline': '下線 (underline)', + 'settings.cursorStyle.outline': '輪郭 (outline)', + 'settings.cursorStyle.none': 'なし (none)', + 'settings.rendererMode.auto': '自動', + 'settings.rendererMode.autoHint': 'WebGL による高速化を優先し、失敗したら DOM 描画に自動で切り替えます', + 'settings.rendererMode.webgl': '高性能 WebGL', + 'settings.rendererMode.webglHint': 'GPU 描画を強制します。高リフレッシュレートや大量行のスクロールに適します', + 'settings.rendererMode.dom': '互換 DOM', + 'settings.rendererMode.domHint': 'CPU のみで描画し、互換性を最優先します。GPU がない環境やリモートデスクトップに適します', + 'settings.render.modeLabel': '描画モード', + 'settings.render.modeHint': 'ターミナルセルの描画を高速化する方式。変更はすべてのターミナルに適用されます', + 'settings.render.autoWrap': '自動折り返し', + 'settings.render.autoWrapDesc': '幅を超えた行を自動で折り返します', + 'settings.render.copyOnSelect': '選択時にコピー', + 'settings.render.copyOnSelectDesc': 'マウスで文字を選択すると自動でコピーします', + 'settings.render.pasteRiskConfirm': '貼り付け前の確認', + 'settings.render.pasteRiskConfirmDesc': + '複数行(または極端に長い 1 行)を貼り付ける前に確認します。1 行はそのまま貼り付けます', + 'settings.render.suggest': '入力候補', + 'settings.render.suggestDesc': + '入力中にコマンド候補を表示します。Tab で確定、Enter は常にそのまま実行します', + 'settings.render.history': 'コマンド履歴を記録', + 'settings.render.historyDesc': 'オフにすると新しいコマンドを記録しません。保存済みの履歴は残ります', + 'settings.render.historyLimit': '履歴の上限件数', + 'settings.render.historyLimitDesc': 'コマンド履歴の最大件数。重複したコマンドは 1 件として記録します', + 'settings.render.recButton': 'ターミナルツールバー · 記録', + 'settings.render.recButtonDesc': '右上に「セッションログを記録」ボタンを表示します', + 'settings.render.openLogsButton': 'ターミナルツールバー · ログフォルダーを開く', + 'settings.render.openLogsButtonDesc': '右上に「ログフォルダーを開く」ボタンを表示します', + 'settings.render.openCwdButton': 'ターミナルツールバー · ワークスペースフォルダーを開く', + 'settings.render.openCwdButtonDesc': + '右上に「現在のフォルダーを開く」ボタンを表示します(SSH セッションでは非表示。フォルダーは cd に追従するため、shell 統合を有効にするか cd コマンドを使ってください)', + 'settings.system.launchAtLogin': 'スタートアップ時に起動', + 'settings.system.launchAtLoginDesc': 'OS の起動時に自動で起動します(開発モードでは開発版が登録されます)', + 'settings.system.preventSleep': 'スリープを防止', + 'settings.system.preventSleepDesc': + 'オンにすると OpenTerminal がシステムの自動スリープを防ぎます。オフにすると OS の設定どおりにスリープします', + 'settings.system.restoreSession': '起動時に前回のセッションを復元', + 'settings.system.restoreSessionDesc': + '次回の起動で分割レイアウトを復元し、各ターミナルを前回のディレクトリに戻します。オフにすると毎回 1 つのターミナルで起動します', + 'settings.system.shellIntegration': 'Shell 統合', + 'settings.system.shellIntegrationDesc': + 'shell に作業ディレクトリを報告させ(OSC 7)、パスの記憶をより正確にします(スクリプト内の cd も含みます)。プロンプトをラップするため、プロンプトの表示が崩れる場合はオフにしてください', + 'settings.system.closeAction': '閉じるボタンの動作', + 'settings.system.closeActionDesc': + 'ウィンドウの閉じるボタンを押したときの動作。タスクトレイに最小化した後は、トレイアイコンの右クリックメニューから終了できます', + 'settings.system.closeActionTray': 'タスクトレイに最小化', + 'settings.system.closeActionExit': 'そのまま終了', + 'settings.system.closeActionAsk': '毎回確認する', + 'settings.system.globalShortcut': 'グローバル呼び出しショートカット', + 'settings.system.globalShortcutDesc': + '入力欄をクリックしてからキーの組み合わせを押します。Esc で取り消し、Backspace でクリアして無効化します', + 'settings.system.globalShortcutHint': + 'グローバルキーと組み合わせると、どの画面からでもウィンドウを呼び出し/非表示にできます。登録に失敗した場合(他のソフトとの競合)は効果がなく、アプリにエラーは表示されません。', + 'settings.system.shortcutRecording': 'キーを押してください…', + 'settings.system.shortcutPlaceholder': 'クリックしてショートカットを押す。空欄で無効', + 'settings.highlight.alert': + '独自のハイライトは見やすさを高めますが、わずかに性能を消費します。ルールは優先順に適用されます。PS: ハイライトはターミナルへの書き込み時に色を注入するため、既存のスクロールバックには反映されません', + 'settings.highlight.enabled': '有効', + 'settings.highlight.pattern': '正規表現', + 'settings.highlight.patternPlaceholder': '例: \\b(ERROR|FAILED)\\b', + 'settings.highlight.invalidRegex': '正規表現が無効です', + 'settings.highlight.priority': '優先度', + 'settings.highlight.priorityHint': '値が小さいほど先に適用されます', + 'settings.highlight.color': '色', + 'settings.highlight.clearBg': 'クリア', + 'settings.highlight.addBg': '追加', + 'settings.highlight.palette': 'パレット', + 'settings.highlight.note': 'メモ', + 'settings.highlight.notePlaceholder': 'ルールの説明(任意)', + 'settings.highlight.actions': '操作', + 'settings.highlight.addRule': '+ ルールを追加', + 'settings.highlight.empty': 'ハイライトルールがありません。「+ ルールを追加」から作成できます', + 'settings.highlight.deleteTitle': 'ハイライトルールを削除', + 'settings.highlight.resetTitle': '既定のハイライトルールに戻す', + 'settings.highlight.resetDesc': '現在のすべてのルールを組み込みのプリセットに置き換えます。元に戻せません。', + 'settings.highlight.resetOk': '元に戻す', + 'settings.highlight.createTitle': 'ハイライトルールを作成', + 'settings.highlight.editTitle': 'ハイライトルールを編集', + 'settings.theme.accent': 'タブのアクセントカラー', + 'settings.theme.accentDesc': 'アクティブなタブの枠線、サイドバーのインジケーター、SSH バッジのアクセントカラー', + 'settings.theme.customBadge': 'カスタム', + 'settings.theme.deleteTitle': 'カスタムテーマを削除', + 'settings.theme.create': '新しいテーマ', + 'settings.theme.editTitle': 'テーマを編集', + 'settings.themeEditor.name': '名前', + 'settings.themeEditor.namePlaceholder': 'テーマ名', + 'settings.themeEditor.copySuffix': '(コピー)', + 'settings.about.currentVersion': '現在のバージョン', + 'settings.about.statusIdle': 'クリックして更新を確認', + 'settings.about.statusChecking': '更新を確認しています…', + 'settings.about.statusAvailable': '新しいバージョンがあります', + 'settings.about.statusLatest': '最新バージョンです', + 'settings.about.statusDownloading': 'ダウンロードしています…', + 'settings.about.statusDownloaded': 'ダウンロード完了。再起動してインストールできます', + 'settings.about.statusError': '確認に失敗しました', + 'settings.about.statusDev': '開発モードでは更新を確認できません', + 'settings.about.channelStable': '安定版チャンネル', + 'settings.about.autoCheck': '起動時に自動で確認', + 'settings.about.download': '更新をダウンロード', + 'settings.about.downloadGithub': '(GitHub ソース)', + 'settings.about.install': '再起動してインストール', + 'settings.about.check': '更新を確認', + 'settings.about.checkingButton': '確認中', + 'settings.about.changelog': '更新履歴', + 'settings.about.changelogEmpty': '更新履歴はありません' +} + +export default settings diff --git a/src/shared/i18n/dicts/ja/ssh.ts b/src/shared/i18n/dicts/ja/ssh.ts new file mode 100644 index 0000000..b85f49a --- /dev/null +++ b/src/shared/i18n/dicts/ja/ssh.ts @@ -0,0 +1,152 @@ +/** 名前空間: ssh(SSH 接続管理、ホストキー、ファイルパネル、zmodem、システム監視)。担当: SSH。 */ +const ssh: Record = { + // 認証方式 / シークレットの種類 + 'ssh.auth.password': 'パスワード', + 'ssh.auth.privateKey': '秘密鍵', + 'ssh.auth.agent': 'Agent', + 'ssh.secret.passphrase': 'パスフレーズ', + + // 接続ダイアログ: 認証関連フィールド + 'ssh.fields.agentDesc': + 'ローカルの SSH Agent に読み込み済みの鍵で認証します。本アプリ内に鍵を保存する必要はありません。', + 'ssh.fields.askPasswordLabel': '接続時にパスワードを確認', + 'ssh.fields.askPasswordHint': + 'オンにすると毎回の接続でパスワード入力ダイアログを表示し、保存済みのパスワードを使いません', + 'ssh.fields.passwordPlaceholder': '編集時に空欄のままにすると保存済みのパスワードを保持します', + 'ssh.fields.keyPath': '秘密鍵のパス(サーバー上の秘密鍵のパス、省略可)', + 'ssh.fields.keyContent': '秘密鍵の内容', + 'ssh.fields.keyContentPlaceholder': + '秘密鍵の内容を貼り付けます。空欄の場合は上のパスを使用します(編集時に空欄のままにすると保存済みの秘密鍵を保持します)', + 'ssh.fields.askPassphraseLabel': '接続時にパスフレーズを確認', + 'ssh.fields.askPassphraseHint': 'オンにすると毎回の接続で秘密鍵のパスフレーズ入力を求めます', + 'ssh.fields.passphrase': '秘密鍵のパスフレーズ', + 'ssh.fields.passphrasePlaceholder': + '編集時に空欄のままにすると保存済みのパスフレーズを保持します', + + // 接続ダイアログ + 'ssh.dialog.titleEdit': '接続を編集', + 'ssh.dialog.titleNew': '新規接続', + 'ssh.dialog.saveFailed': '保存に失敗しました。入力を確認して再試行してください', + 'ssh.dialog.name': '名前', + 'ssh.dialog.nameRequired': '接続名を入力してください', + 'ssh.dialog.namePlaceholder': '例:本番サーバー', + 'ssh.dialog.group': 'グループ', + 'ssh.dialog.groupPlaceholder': + 'グループを選択または入力してください。空欄の場合は「デフォルト」に入ります', + 'ssh.dialog.host': 'ホスト', + 'ssh.dialog.hostRequired': 'ホストアドレスを入力してください', + 'ssh.dialog.hostPlaceholder': '192.168.1.10 または example.com', + 'ssh.dialog.port': 'ポート', + 'ssh.dialog.portRange': 'ポートは 1〜65535 の範囲で指定してください', + 'ssh.dialog.username': 'ユーザー名', + 'ssh.dialog.usernameRequired': 'ユーザー名を入力してください', + 'ssh.dialog.authMethod': '認証方式', + 'ssh.dialog.savedSecret': '{kind}は保存済みです。空欄のままにすると現在の値を使用します', + 'ssh.dialog.keepalive': 'Keepalive 間隔(秒)', + 'ssh.dialog.keepaliveTooltip': '0 で keepalive を無効にします', + 'ssh.dialog.keepaliveMin': '負の値は指定できません', + + // 接続サイドバー + 'ssh.sidebar.defaultGroup': 'デフォルト', + 'ssh.sidebar.timeJustNow': 'たった今', + 'ssh.sidebar.timeMinutesAgo': '{n} 分前', + 'ssh.sidebar.timeHoursAgo': '{n} 時間前', + 'ssh.sidebar.timeDaysAgo': '{n} 日前', + 'ssh.sidebar.modeTerminal': 'ターミナルワークスペース', + 'ssh.sidebar.modeSsh': 'SSH ワークスペース', + 'ssh.sidebar.title': '接続', + 'ssh.sidebar.newConnection': '新規接続', + 'ssh.sidebar.sectionTerminals': 'ターミナル', + 'ssh.sidebar.noLocalTerminals': '開いているローカルターミナルはありません', + 'ssh.sidebar.local': 'ローカル', + 'ssh.sidebar.sectionServers': 'SSH サーバー', + 'ssh.sidebar.empty': '接続がありません。+ をクリックして作成', + 'ssh.sidebar.loading': '読み込み中…', + 'ssh.sidebar.recent': '最近のセッション', + 'ssh.sidebar.dblClickConnect': '{host} にダブルクリックで接続', + 'ssh.sidebar.deleteTitle': 'この接続を削除しますか?', + 'ssh.sidebar.deleteDesc': + '削除した接続は復元できません。保存済みの鍵データも同時に削除されます。', + + // ホストキーの確認 + 'ssh.hostKey.changedTitle': 'ホストのフィンガープリントが変更されました!', + 'ssh.hostKey.unknownHost': '未知のホスト', + 'ssh.hostKey.accept': '承認して接続', + 'ssh.hostKey.reject': '拒否', + 'ssh.hostKey.mitmTitle': '中間者攻撃の可能性があります', + 'ssh.hostKey.mitmDesc': + 'リモートホストの鍵フィンガープリントが以前の記録と一致しません。ご自身でサーバー鍵を変更していない場合、誰かがこのホストになりすましている可能性があります。', + 'ssh.hostKey.firstConnect': + 'このホストへの初回接続です。フィンガープリントはまだ記録されていません。', + 'ssh.hostKey.target': 'ホスト:{target}', + 'ssh.hostKey.fingerprint': 'フィンガープリント:{fingerprint}', + + // SSH ワークスペース: ファイルパネル、ホストバッジ + 'ssh.bottomPanel.resize': 'ファイルパネルの高さを調整', + 'ssh.hostBadge.aria': 'SSH ホスト {label}', + + // ZMODEM + 'ssh.zmodem.done': 'ZMODEM 転送が完了しました', + 'ssh.zmodem.failed': 'ZMODEM 転送に失敗しました:{message}', + 'ssh.zmodem.unknownError': '不明なエラー', + 'ssh.zmodem.titleReceive': 'ローカルにダウンロード', + 'ssh.zmodem.titleSend': 'サーバーへアップロード', + 'ssh.zmodem.pickDir': '保存先フォルダを選択', + 'ssh.zmodem.pickFiles': 'ファイルを選択', + 'ssh.zmodem.receiveDesc': + 'sz のダウンロード要求を検出しました(リモートがファイルを送信中)。ローカルの保存先フォルダを選択してください。', + 'ssh.zmodem.sendDesc': + 'rz のアップロード要求を検出しました(リモートがファイルを受信中)。アップロードするファイルを選択してください。', + + // ファイルパネル(SFTP) + 'ssh.file.up': '親フォルダへ', + 'ssh.file.refresh': '更新', + 'ssh.file.uploadFile': 'ファイルをアップロード', + 'ssh.file.downloadSelected': '選択項目をダウンロード', + 'ssh.file.permOwner': '権限 / 所有者', + 'ssh.file.open': '開く', + 'ssh.file.download': 'ダウンロード', + 'ssh.file.upload': 'アップロード…', + 'ssh.file.rename': '名前を変更', + 'ssh.file.new': '新規作成', + 'ssh.file.newFolder': '新しいフォルダ', + 'ssh.file.copyPath': 'パスをコピー', + 'ssh.file.copyPathFailed': 'パスのコピーに失敗しました', + 'ssh.file.pathCopied': 'パスをコピーしました', + 'ssh.file.permission': 'ファイル権限…', + 'ssh.file.deleteTitle': '削除しますか?', + 'ssh.file.loading': '読み込み中…', + 'ssh.file.empty': '空のフォルダ', + 'ssh.file.modeUnavailable': '権限を取得できません', + 'ssh.file.folderName': 'フォルダ名', + 'ssh.file.create': '作成', + 'ssh.file.renameTitle': '{name} の名前を変更', + 'ssh.file.permTitle': '権限 · {name}', + 'ssh.file.permApplied': '権限を適用しました', + 'ssh.file.octal': '8 進数', + 'ssh.file.octalPlaceholder': '例:755', + 'ssh.file.keepEmpty': '変更しない場合は空欄', + 'ssh.file.ownerHint': 'UID/GID を空欄にすると所有者を変更しません', + 'ssh.file.permRowOwner': '所有者', + 'ssh.file.permRowGroup': 'グループ', + 'ssh.file.permRowOther': 'その他', + 'ssh.file.permColRead': '読み取り', + 'ssh.file.permColWrite': '書き込み', + 'ssh.file.permColExec': '実行', + + // システム監視 + 'ssh.monitor.collecting': '取得中…', + 'ssh.monitor.interrupted': '取得が中断されました:{error}', + 'ssh.monitor.mem': 'メモリ', + 'ssh.monitor.disks': 'ディスク', + 'ssh.monitor.net': 'ネットワーク', + 'ssh.monitor.cores': '{n} コア', + 'ssh.monitor.loadavg': '負荷 {value}', + 'ssh.monitor.uptime': '稼働時間', + 'ssh.monitor.uptimeSeconds': '{n}秒', + 'ssh.monitor.uptimeMinutes': '{n}分', + 'ssh.monitor.uptimeHours': '{h}時間{m}分', + 'ssh.monitor.uptimeDays': '{n}日' +} + +export default ssh diff --git a/src/shared/i18n/dicts/ja/terminal.ts b/src/shared/i18n/dicts/ja/terminal.ts new file mode 100644 index 0000000..c5b57ac --- /dev/null +++ b/src/shared/i18n/dicts/ja/terminal.ts @@ -0,0 +1,26 @@ +/** 名前空間: terminal(ターミナル表示:コンテキストメニュー、検索、貼り付け確認、ツールバー、ヒント)。担当: ワークスペース/ターミナル。 */ +const terminal: Record = { + 'terminal.menu.find': '検索', + 'terminal.menu.selectAll': 'すべて選択', + 'terminal.menu.clear': '画面クリア', + 'terminal.search.placeholder': '検索', + 'terminal.search.prev': '前へ', + 'terminal.search.next': '次へ', + 'terminal.search.found': '見つかりました', + 'terminal.search.notFound': '見つかりません', + 'terminal.suggest.hint': '↑/↓ 選択 · Tab で確定 · Esc で閉じる', + 'terminal.rec.start': '記録開始', + 'terminal.rec.stop': '記録停止', + 'terminal.rec.startLog': 'セッションログの記録を開始', + 'terminal.rec.stopLog': 'セッションログの記録を停止', + 'terminal.rec.openLogs': 'ログフォルダーを開く', + 'terminal.rec.openCwd': 'ワークスペースフォルダーを開く', + 'terminal.paste.title': '貼り付けの確認', + 'terminal.paste.message': '複数行または危険な可能性のあるコマンドを検出しました。ターミナルに貼り付けますか?', + 'terminal.paste.noPrompt': 'このセッションでは表示しない', + 'terminal.paste.disableDetection': '今後の貼り付けチェックを無効にする', + 'terminal.paste.hint': '一般のターミナル設定で再度有効にするか、検出ポリシーを変更できます。', + 'terminal.dead.message': 'プロセスが終了しました (コード {code})' +} + +export default terminal diff --git a/src/shared/i18n/dicts/ja/workspace.ts b/src/shared/i18n/dicts/ja/workspace.ts new file mode 100644 index 0000000..f4683b8 --- /dev/null +++ b/src/shared/i18n/dicts/ja/workspace.ts @@ -0,0 +1,62 @@ +/** 名前空間: workspace(ワークスペース外装:サイドバー、タブ、レイアウトメニュー、分割、空状態)。担当: ワークスペース。 */ +const workspace: Record = { + 'workspace.tab.terminalTitle': 'ターミナル {n}', + 'workspace.tab.localTerminal': 'ローカルターミナル', + 'workspace.tab.closeOthers': 'ほかを閉じる', + 'workspace.tab.closeRight': '右側を閉じる', + 'workspace.tab.broadcastTarget': 'ブロードキャスト対象', + 'workspace.tab.broadcastBadge': '配', + 'workspace.tab.addInSplit': 'この分割に新しいターミナル', + 'workspace.newTerminal': '新規ターミナル', + 'workspace.empty.terminalTitle': 'ターミナルがありません', + 'workspace.empty.sshTitle': 'SSH セッションがありません', + 'workspace.empty.sshHint': '左側のサーバー一覧から接続してください', + 'workspace.rail.hideSidebar': 'サイドバーを折りたたむ', + 'workspace.rail.showSidebar': 'サイドバーを展開', + 'workspace.rail.layout': 'レイアウト', + 'workspace.rail.settings': '設定', + 'workspace.rail.connecting': '接続中…', + 'workspace.rail.terminalWorkspace': 'ターミナルワークスペース', + 'workspace.rail.sshWorkspace': 'SSH ワークスペース', + 'workspace.panel.hideSidebar': 'サイドバーを折りたたむ', + 'workspace.panel.showSidebar': 'サイドバーを展開', + 'workspace.layout.splitRight': '水平分割', + 'workspace.layout.splitBelow': '垂直分割', + 'workspace.layout.saveTemplate': 'テンプレートとして保存', + 'workspace.layout.applyTemplate': 'テンプレートを適用', + 'workspace.template.saveTitle': 'レイアウトテンプレートを保存', + 'workspace.template.namePlaceholder': 'テンプレート名', + 'workspace.template.applyTitle': 'レイアウトテンプレートを適用', + 'workspace.template.empty': '保存済みのテンプレートはありません', + 'workspace.template.apply': '適用', + 'workspace.template.deleteConfirm': 'このテンプレートを削除しますか?', + 'workspace.broadcast.toggle': 'ブロードキャスト入力', + 'workspace.broadcast.activeTitle': 'ブロードキャスト中({count} 件の対象)', + 'workspace.broadcast.popoverTerminal': 'ブロードキャスト入力(ターミナル)', + 'workspace.broadcast.popoverSsh': 'ブロードキャスト入力(SSH)', + 'workspace.broadcast.switch': 'ブロードキャストスイッチ', + 'workspace.broadcast.enabled': '有効({count})', + 'workspace.broadcast.needTwo': '対象を 2 つ以上選択してください', + 'workspace.broadcast.disabled': '無効', + 'workspace.broadcast.emptySsh': 'SSH サーバーセッションがありません', + 'workspace.broadcast.emptyTerminal': 'ターミナルがありません', + 'workspace.broadcast.hint': + 'ターミナルを 2 つ以上選ぶとブロードキャストが自動的に有効になり、対象が 2 つ未満になると自動的に解除されます。選択できるのは現在のモードのセッションだけです。', + 'workspace.hint.sshFromList': 'SSH セッションはサーバー一覧から接続してください', + 'workspace.hint.connectSshFirst': '先に SSH サーバーへ接続してください', + 'workspace.hint.noActiveTerminal': + '有効なターミナルがありません。ローカルターミナルまたは SSH セッションを 1 つ以上開いてください', + 'workspace.hint.noSessionInPanel': '現在アクティブなパネルに利用できるセッションがありません', + 'workspace.connect.failed': 'SSH 接続に失敗しました', + 'workspace.connect.passwordTitle': '接続パスワードを入力', + 'workspace.connect.passwordPlaceholder': 'パスワード', + 'workspace.connect.passphraseTitle': '秘密鍵のパスフレーズを入力', + 'workspace.connect.passphrasePlaceholder': 'パスフレーズ', + 'workspace.connect.connecting': '接続中…', + 'workspace.connect.connectingNamed': '{name} に接続しています', + 'workspace.connect.action': '接続', + 'workspace.error.title': '画面でエラーが発生しました', + 'workspace.error.reload': '再読み込み' +} + +export default workspace diff --git a/src/shared/i18n/dicts/zh-CN/common.ts b/src/shared/i18n/dicts/zh-CN/common.ts new file mode 100644 index 0000000..f2714a8 --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/common.ts @@ -0,0 +1,17 @@ +/** 命名空间:通用(i18n 基础设施、通用按钮与状态)。归属:i18n 基础设施维护者。 */ +const common: Record = { + 'common.ok': '确定', + 'common.cancel': '取消', + 'common.close': '关闭', + 'common.save': '保存', + 'common.delete': '删除', + 'common.edit': '编辑', + 'common.add': '新增', + 'common.copy': '复制', + 'common.paste': '粘贴', + 'common.reset': '恢复默认', + 'common.language': '界面语言', + 'common.languageDesc': '切换后立即预览界面语言,保存后永久生效。' +} + +export default common diff --git a/src/shared/i18n/dicts/zh-CN/index.ts b/src/shared/i18n/dicts/zh-CN/index.ts new file mode 100644 index 0000000..ac192e1 --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/index.ts @@ -0,0 +1,18 @@ +import common from './common' +import settings from './settings' +import workspace from './workspace' +import terminal from './terminal' +import ssh from './ssh' +import panels from './panels' +import main from './main' + +/** 简体中文词条。命名空间文件按区域分工维护,详见 src/shared/i18n/index.ts 的归属表。 */ +export default { + ...common, + ...settings, + ...workspace, + ...terminal, + ...ssh, + ...panels, + ...main +} diff --git a/src/shared/i18n/dicts/zh-CN/main.ts b/src/shared/i18n/dicts/zh-CN/main.ts new file mode 100644 index 0000000..f4da4aa --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/main.ts @@ -0,0 +1,56 @@ +/** 命名空间:main(主进程文案:托盘、关闭询问、更新器)。归属:主进程维护者。 */ +const main: Record = { + 'main.tray.showWindow': '显示主窗口', + 'main.tray.closeAction': '关闭按钮行为', + 'main.tray.askEveryTime': '每次询问', + 'main.tray.minimizeToTray': '最小化到托盘', + 'main.tray.exitDirectly': '直接退出', + 'main.tray.exit': '退出', + 'main.tray.balloonTitle': '已最小化到托盘', + 'main.tray.balloonContent': '终端会话仍在后台运行,点击托盘图标可恢复窗口。', + 'main.tray.closeTitle': '关闭 OpenTerminal', + 'main.tray.closeMessage': '最小化到托盘,还是直接退出?', + 'main.tray.closeDetail': '最小化到托盘时终端会话保持运行。', + 'main.tray.rememberChoice': '记住我的选择,不再询问', + + 'main.updater.feedFailed': '国内源: {gitea};GitHub: {github}', + + 'main.ssh.connectTimeout': '连接超时 ({host}:{port})', + 'main.ssh.saveFingerprintFailed': '保存主机指纹失败: {detail}', + 'main.ssh.hostKeyRejected': '用户拒绝了主机指纹', + 'main.ssh.connectFailed': '连接失败 {host}:{port}: {detail}', + 'main.ssh.shellOpenFailed': '无法打开 SSH shell ({host}:{port}): {detail}', + 'main.ssh.initFailed': 'SSH 连接初始化失败 ({host}:{port}): {detail}', + 'main.ssh.readKeyFailed': '无法读取私钥文件 {path}: {detail}', + + 'main.sftp.sessionGone': 'SSH 会话不存在或已断开', + 'main.sftp.deleteFailed': '删除失败: {detail}', + 'main.sftp.invalidMode': '非法权限值: {mode}', + 'main.sftp.invalidUidGid': '非法 uid/gid', + 'main.sftp.commandExitCode': '命令退出码 {code}', + 'main.sftp.cancelled': '已取消', + + 'main.zmodem.transferFailed': '传输失败', + 'main.zmodem.transferTimeout': '传输超时', + 'main.zmodem.sendFailed': '发送失败', + 'main.zmodem.uploadFailed': '上传失败', + 'main.zmodem.receiveFailed': '接收失败', + 'main.zmodem.abnormal': 'ZMODEM 传输异常', + 'main.zmodem.cancelled': '已取消', + 'main.zmodem.offerTimedOut': '未选择文件,已取消', + 'main.zmodem.sessionInvalid': '会话已失效', + 'main.zmodem.sessionCreateFailed': '无法建立 ZMODEM 会话', + 'main.zmodem.noSaveDir': '未指定保存目录', + 'main.zmodem.noFiles': '未选择文件', + + 'main.ipc.connectionMissing': '连接书签不存在 ({id})', + + 'main.sysinfo.sessionGone': 'SSH 会话不存在或已断开', + 'main.sysinfo.execFailed': 'SSH exec 失败', + 'main.sysinfo.parseFailed': '解析失败: {message}', + 'main.pty.serviceNotReady': 'SSH 会话服务尚未初始化', + 'main.pty.missingConnectionId': 'SSH 会话缺少 connectionId', + 'main.log.tuiOmitted': '全屏界面(TUI)输出已省略' +} + +export default main diff --git a/src/shared/i18n/dicts/zh-CN/panels.ts b/src/shared/i18n/dicts/zh-CN/panels.ts new file mode 100644 index 0000000..c65bd3f --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/panels.ts @@ -0,0 +1,36 @@ +/** 命名空间:panels(命令面板、会话日志面板、传输面板)。归属:SSH/面板维护者。 */ +const panels: Record = { + // 命令面板 + 'panels.commands.sectionTitle': '命令', + 'panels.commands.target': '发送目标', + 'panels.commands.targetSsh': 'SSH 服务器', + 'panels.commands.targetTerminal': '终端', + 'panels.commands.tabHistory': '历史', + 'panels.commands.tabLibrary': '命令库', + 'panels.commands.historyTitle': '历史命令', + 'panels.commands.clearTitle': '清空全部命令历史?', + 'panels.commands.clear': '清空', + 'panels.commands.noHistory': '暂无历史命令', + 'panels.commands.loading': '加载中…', + 'panels.commands.libraryTitle': '命令库', + 'panels.commands.libraryEmpty': '命令库为空', + 'panels.commands.add': '新增命令', + 'panels.commands.name': '名称', + 'panels.commands.optional': '(可选)', + 'panels.commands.command': '命令', + 'panels.commands.commandPlaceholder': '如 ls -la', + 'panels.commands.note': '备注', + 'panels.commands.deleteTitle': '删除该命令?', + 'panels.commands.deleteAria': '删除命令', + + // 传输面板 + 'panels.transfer.failed': '传输失败', + 'panels.transfer.cancelled': '已取消', + 'panels.transfer.done': '传输完成', + 'panels.transfer.uploading': '上传中', + 'panels.transfer.downloading': '下载中', + 'panels.transfer.zmodemUploading': 'ZMODEM 上传中', + 'panels.transfer.zmodemDownloading': 'ZMODEM 下载中' +} + +export default panels diff --git a/src/shared/i18n/dicts/zh-CN/settings.ts b/src/shared/i18n/dicts/zh-CN/settings.ts new file mode 100644 index 0000000..1aa790c --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/settings.ts @@ -0,0 +1,147 @@ +/** 命名空间:settings(设置弹窗:字体/光标/渲染/高亮/主题/系统/关于)。归属:设置页维护者。 */ +const settings: Record = { + 'settings.title': '设置', + 'settings.tabs.font': '字体', + 'settings.tabs.cursor': '光标与滚动', + 'settings.tabs.render': '渲染与行为', + 'settings.tabs.highlight': '高亮', + 'settings.tabs.theme': '主题', + 'settings.tabs.system': '系统', + 'settings.tabs.about': '关于', + 'settings.resizeHandle': '拖拽调整大小', + 'settings.preview': '预览', + 'settings.deleteConfirmDesc': '删除后无法恢复,确定继续?', + 'settings.color.foreground': '前景', + 'settings.color.background': '背景', + 'settings.color.cursor': '光标', + 'settings.color.selection': '选区', + 'settings.font.label': '字体', + 'settings.font.desc': '内置默认字体或系统等宽字体', + 'settings.font.placeholder': '选择字体', + 'settings.font.default': '默认', + 'settings.font.size': '字号', + 'settings.font.sizeDesc': '终端文字的像素大小', + 'settings.font.weight': '字重', + 'settings.font.weightDesc': '400 为常规,700 为粗体', + 'settings.font.boldWeight': '粗体字重', + 'settings.font.boldWeightDesc': '粗体文字所用的字重', + 'settings.font.letterSpacing': '字间距', + 'settings.font.letterSpacingDesc': '字符之间的水平间距', + 'settings.font.lineHeight': '行高', + 'settings.font.lineHeightDesc': '相邻两行之间的垂直距离', + 'settings.cursor.blink': '光标闪烁', + 'settings.cursor.blinkDesc': '光标是否周期性闪烁', + 'settings.cursor.style': '光标样式', + 'settings.cursor.styleDesc': '光标的显示形状', + 'settings.cursor.inactiveStyle': '非活动光标样式', + 'settings.cursor.inactiveStyleDesc': '窗口失焦时光标样式', + 'settings.cursor.scrollback': '滚动缓冲区', + 'settings.cursor.scrollbackDesc': '屏幕上可回滚的行数', + 'settings.cursorStyle.bar': '竖线 (bar)', + 'settings.cursorStyle.block': '方块 (block)', + 'settings.cursorStyle.underline': '下划线 (underline)', + 'settings.cursorStyle.outline': '轮廓 (outline)', + 'settings.cursorStyle.none': '无 (none)', + 'settings.rendererMode.auto': '自动', + 'settings.rendererMode.autoHint': '优先使用 WebGL 加速,失败时自动回退到 DOM 渲染', + 'settings.rendererMode.webgl': '高性能 WebGL', + 'settings.rendererMode.webglHint': '强制使用 GPU 加速渲染,适合高刷新率与大行数滚动', + 'settings.rendererMode.dom': '兼容 DOM', + 'settings.rendererMode.domHint': '纯 CPU 渲染,兼容性最好,适合无 GPU 或远程桌面环境', + 'settings.render.modeLabel': '渲染模式', + 'settings.render.modeHint': '终端单元渲染加速方式,改动将应用到所有终端', + 'settings.render.autoWrap': '自动换行', + 'settings.render.autoWrapDesc': '超出宽度时自动折行', + 'settings.render.copyOnSelect': '选中即复制', + 'settings.render.copyOnSelectDesc': '鼠标选中文字时自动复制', + 'settings.render.pasteRiskConfirm': '粘贴风险确认', + 'settings.render.pasteRiskConfirmDesc': '多行文本(或超长单行)粘贴前弹出确认;单行直接粘贴', + 'settings.render.suggest': '输入建议', + 'settings.render.suggestDesc': '输入时弹出命令建议;Tab 接受,回车始终直接执行', + 'settings.render.history': '记录命令历史', + 'settings.render.historyDesc': '关闭后不再记录新命令;已保存的历史仍然保留', + 'settings.render.historyLimit': '历史条数上限', + 'settings.render.historyLimitDesc': '命令历史最多保留多少条,重复命令只记一次', + 'settings.render.recButton': '终端工具栏 · 录制', + 'settings.render.recButtonDesc': '右上角显示「记录会话日志」按钮', + 'settings.render.openLogsButton': '终端工具栏 · 打开日志目录', + 'settings.render.openLogsButtonDesc': '右上角显示「打开日志目录」按钮', + 'settings.render.openCwdButton': '终端工具栏 · 打开工作区目录', + 'settings.render.openCwdButtonDesc': + '右上角显示「打开当前目录」按钮(SSH 会话不显示;目录随 cd 实时跟踪,需开启 shell 集成或使用 cd 命令)', + 'settings.system.launchAtLogin': '开机时自启动', + 'settings.system.launchAtLoginDesc': '开机时自动启动软件(开发模式下注册的是开发版程序)', + 'settings.system.preventSleep': '阻止系统休眠', + 'settings.system.preventSleepDesc': + '开启后 OpenTerminal 将阻止系统自动休眠;关闭则允许系统按系统设置休眠', + 'settings.system.restoreSession': '启动时恢复上次会话', + 'settings.system.restoreSessionDesc': + '下次启动恢复分屏布局,并让每个终端回到它上次所在的目录;关闭则每次都以单个终端启动', + 'settings.system.shellIntegration': 'Shell 集成', + 'settings.system.shellIntegrationDesc': + '让 shell 主动上报工作目录(OSC 7),路径记忆更精确(含脚本内的 cd);会包装你的命令提示符,遇到提示符主题异常时关掉即可', + 'settings.system.closeAction': '关闭按钮行为', + 'settings.system.closeActionDesc': + '点击窗口关闭按钮时的动作;最小化到托盘后可在托盘图标右键菜单中退出', + 'settings.system.closeActionTray': '最小化到托盘', + 'settings.system.closeActionExit': '直接退出', + 'settings.system.closeActionAsk': '每次询问', + 'settings.system.globalShortcut': '全局唤起快捷键', + 'settings.system.globalShortcutDesc': '点击输入框后直接按下组合键;Esc 取消,退格清空禁用', + 'settings.system.globalShortcutHint': + '配合全局键可在任何界面唤起/隐藏窗口;若注册失败(与其他软件冲突)则不生效,应用不会报错。', + 'settings.system.shortcutRecording': '按下组合键…', + 'settings.system.shortcutPlaceholder': '点击后按下快捷键,留空禁用', + 'settings.highlight.alert': + '自定义高亮可增强显示效果,但会略微增加性能消耗。规则按优先级顺序应用。PS: 高亮在写入终端时注入颜色,对已有回滚内容不生效', + 'settings.highlight.enabled': '启用', + 'settings.highlight.pattern': '正则', + 'settings.highlight.patternPlaceholder': '例如 \\b(ERROR|FAILED)\\b', + 'settings.highlight.invalidRegex': '正则表达式无效', + 'settings.highlight.priority': '优先级', + 'settings.highlight.priorityHint': '值越小越先应用', + 'settings.highlight.color': '颜色', + 'settings.highlight.clearBg': '清空', + 'settings.highlight.addBg': '添加', + 'settings.highlight.palette': '色板', + 'settings.highlight.note': '备注', + 'settings.highlight.notePlaceholder': '规则说明(可选)', + 'settings.highlight.actions': '操作', + 'settings.highlight.addRule': '+ 新增规则', + 'settings.highlight.empty': '暂无高亮规则,点击「+ 新增规则」创建', + 'settings.highlight.deleteTitle': '删除高亮规则', + 'settings.highlight.resetTitle': '恢复默认高亮规则', + 'settings.highlight.resetDesc': '将替换当前全部规则为内置预设,不可恢复。', + 'settings.highlight.resetOk': '恢复', + 'settings.highlight.createTitle': '新建高亮规则', + 'settings.highlight.editTitle': '编辑高亮规则', + 'settings.theme.accent': '标签强调色', + 'settings.theme.accentDesc': '活动标签边框、侧栏指示条与 SSH 徽章的强调颜色', + 'settings.theme.customBadge': '自定义', + 'settings.theme.deleteTitle': '删除自定义主题', + 'settings.theme.create': '新建主题', + 'settings.theme.editTitle': '编辑主题', + 'settings.themeEditor.name': '名称', + 'settings.themeEditor.namePlaceholder': '主题名称', + 'settings.themeEditor.copySuffix': '(副本)', + 'settings.about.currentVersion': '当前版本', + 'settings.about.statusIdle': '点击检查更新', + 'settings.about.statusChecking': '正在检查更新…', + 'settings.about.statusAvailable': '发现新版本', + 'settings.about.statusLatest': '已经是最新版本', + 'settings.about.statusDownloading': '正在下载…', + 'settings.about.statusDownloaded': '下载完成,可重启安装', + 'settings.about.statusError': '检查失败', + 'settings.about.statusDev': '开发模式不支持更新检查', + 'settings.about.channelStable': '稳定更新通道', + 'settings.about.autoCheck': '启动时自动检查', + 'settings.about.download': '下载更新', + 'settings.about.downloadGithub': '(GitHub 源)', + 'settings.about.install': '重启安装', + 'settings.about.check': '检查更新', + 'settings.about.checkingButton': '检查中', + 'settings.about.changelog': '更新日志', + 'settings.about.changelogEmpty': '暂无更新日志' +} + +export default settings diff --git a/src/shared/i18n/dicts/zh-CN/ssh.ts b/src/shared/i18n/dicts/zh-CN/ssh.ts new file mode 100644 index 0000000..9620f35 --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/ssh.ts @@ -0,0 +1,144 @@ +/** 命名空间:ssh(SSH 连接管理、主机密钥、文件面板、zmodem、系统监控)。归属:SSH 维护者。 */ +const ssh: Record = { + // 认证方式 / 密钥种类 + 'ssh.auth.password': '密码', + 'ssh.auth.privateKey': '私钥', + 'ssh.auth.agent': 'Agent', + 'ssh.secret.passphrase': '口令', + + // 连接编辑弹窗:认证相关字段 + 'ssh.fields.agentDesc': '使用本机 SSH Agent 中已加载的密钥进行认证,无需在本应用内保存任何密钥。', + 'ssh.fields.askPasswordLabel': '连接时询问密码', + 'ssh.fields.askPasswordHint': '开启后每次连接弹出对话框输入密码,不再使用已保存的密码', + 'ssh.fields.passwordPlaceholder': '编辑时留空表示保留已保存的密码', + 'ssh.fields.keyPath': '私钥路径(服务器上的本地私钥路径,可留空)', + 'ssh.fields.keyContent': '私钥内容', + 'ssh.fields.keyContentPlaceholder': + '粘贴私钥内容;留空则使用上面的路径(编辑时留空表示保留已保存的私钥)', + 'ssh.fields.askPassphraseLabel': '连接时询问口令', + 'ssh.fields.askPassphraseHint': '开启后每次连接提示输入私钥口令', + 'ssh.fields.passphrase': '私钥口令', + 'ssh.fields.passphrasePlaceholder': '编辑时留空表示保留已保存的口令', + + // 连接编辑弹窗 + 'ssh.dialog.titleEdit': '编辑连接', + 'ssh.dialog.titleNew': '新建连接', + 'ssh.dialog.saveFailed': '保存失败,请检查输入后重试', + 'ssh.dialog.name': '名称', + 'ssh.dialog.nameRequired': '请输入连接名称', + 'ssh.dialog.namePlaceholder': '例如:生产服务器', + 'ssh.dialog.group': '分组', + 'ssh.dialog.groupPlaceholder': '选择或输入新分组,留空归入“默认”', + 'ssh.dialog.host': '主机', + 'ssh.dialog.hostRequired': '请输入主机地址', + 'ssh.dialog.hostPlaceholder': '192.168.1.10 或 example.com', + 'ssh.dialog.port': '端口', + 'ssh.dialog.portRange': '端口需在 1–65535 之间', + 'ssh.dialog.username': '用户名', + 'ssh.dialog.usernameRequired': '请输入用户名', + 'ssh.dialog.authMethod': '认证方式', + 'ssh.dialog.savedSecret': '已保存{kind},留空将保留原值', + 'ssh.dialog.keepalive': 'Keepalive 间隔(秒)', + 'ssh.dialog.keepaliveTooltip': '0 表示关闭 keepalive', + 'ssh.dialog.keepaliveMin': '不能为负数', + + // 连接侧栏 + 'ssh.sidebar.defaultGroup': '默认', + 'ssh.sidebar.timeJustNow': '刚刚', + 'ssh.sidebar.timeMinutesAgo': '{n} 分钟前', + 'ssh.sidebar.timeHoursAgo': '{n} 小时前', + 'ssh.sidebar.timeDaysAgo': '{n} 天前', + 'ssh.sidebar.modeTerminal': '终端工作区', + 'ssh.sidebar.modeSsh': 'SSH 工作区', + 'ssh.sidebar.title': '连接', + 'ssh.sidebar.newConnection': '新建连接', + 'ssh.sidebar.sectionTerminals': '终端', + 'ssh.sidebar.noLocalTerminals': '无打开的本地终端', + 'ssh.sidebar.local': '本地', + 'ssh.sidebar.sectionServers': 'SSH 服务器', + 'ssh.sidebar.empty': '暂无连接,点击+新建', + 'ssh.sidebar.loading': '加载中…', + 'ssh.sidebar.recent': '最近会话', + 'ssh.sidebar.dblClickConnect': '双击连接 {host}', + 'ssh.sidebar.deleteTitle': '删除该连接?', + 'ssh.sidebar.deleteDesc': '连接配置删除后不可恢复。已保存的密钥数据会同步删除。', + + // 主机密钥确认 + 'ssh.hostKey.changedTitle': '主机指纹已变更!', + 'ssh.hostKey.unknownHost': '未知主机', + 'ssh.hostKey.accept': '接受并连接', + 'ssh.hostKey.reject': '拒绝', + 'ssh.hostKey.mitmTitle': '可能遭受中间人攻击', + 'ssh.hostKey.mitmDesc': + '远程主机的密钥指纹与之前记录的不一致。如果这不是你本人更换了服务器密钥,可能有人正在冒充该主机。', + 'ssh.hostKey.firstConnect': '首次连接该主机,尚未记录其指纹。', + 'ssh.hostKey.target': '主机:{target}', + 'ssh.hostKey.fingerprint': '指纹:{fingerprint}', + + // SSH 工作区:文件面板容器、主机徽标 + 'ssh.bottomPanel.resize': '调整文件面板高度', + 'ssh.hostBadge.aria': 'SSH 主机 {label}', + + // ZMODEM + 'ssh.zmodem.done': 'ZMODEM 传输完成', + 'ssh.zmodem.failed': 'ZMODEM 传输失败:{message}', + 'ssh.zmodem.unknownError': '未知错误', + 'ssh.zmodem.titleReceive': '下载到本地', + 'ssh.zmodem.titleSend': '上传到服务器', + 'ssh.zmodem.pickDir': '选择保存目录', + 'ssh.zmodem.pickFiles': '选择文件', + 'ssh.zmodem.receiveDesc': '检测到 sz 下载请求(远端正在发送文件),请选择保存在本地的目录。', + 'ssh.zmodem.sendDesc': '检测到 rz 上传请求(远端正在接收文件),请选择要上传的文件。', + + // 文件面板(SFTP) + 'ssh.file.up': '上一级', + 'ssh.file.refresh': '刷新', + 'ssh.file.uploadFile': '上传文件', + 'ssh.file.downloadSelected': '下载选中项', + 'ssh.file.permOwner': '权限 / 属主', + 'ssh.file.open': '打开', + 'ssh.file.download': '下载', + 'ssh.file.upload': '上传…', + 'ssh.file.rename': '重命名', + 'ssh.file.new': '新建', + 'ssh.file.newFolder': '新建文件夹', + 'ssh.file.copyPath': '复制路径', + 'ssh.file.copyPathFailed': '复制路径失败', + 'ssh.file.pathCopied': '已复制路径', + 'ssh.file.permission': '文件权限…', + 'ssh.file.deleteTitle': '确认删除?', + 'ssh.file.loading': '加载中…', + 'ssh.file.empty': '空目录', + 'ssh.file.modeUnavailable': '权限不可用', + 'ssh.file.folderName': '文件夹名称', + 'ssh.file.create': '创建', + 'ssh.file.renameTitle': '重命名 {name}', + 'ssh.file.permTitle': '权限 · {name}', + 'ssh.file.permApplied': '已应用权限', + 'ssh.file.octal': '八进制', + 'ssh.file.octalPlaceholder': '如 755', + 'ssh.file.keepEmpty': '不改则留空', + 'ssh.file.ownerHint': 'UID/GID 留空表示不修改属主', + 'ssh.file.permRowOwner': '所有者', + 'ssh.file.permRowGroup': '组', + 'ssh.file.permRowOther': '其他', + 'ssh.file.permColRead': '读', + 'ssh.file.permColWrite': '写', + 'ssh.file.permColExec': '执行', + + // 系统监控 + 'ssh.monitor.collecting': '采集中…', + 'ssh.monitor.interrupted': '采集中断:{error}', + 'ssh.monitor.mem': '内存', + 'ssh.monitor.disks': '磁盘', + 'ssh.monitor.net': '网络', + 'ssh.monitor.cores': '{n} 核', + 'ssh.monitor.loadavg': '负载 {value}', + 'ssh.monitor.uptime': '运行时间', + 'ssh.monitor.uptimeSeconds': '{n}秒', + 'ssh.monitor.uptimeMinutes': '{n}分钟', + 'ssh.monitor.uptimeHours': '{h}小时{m}分', + 'ssh.monitor.uptimeDays': '{n}天' +} + +export default ssh diff --git a/src/shared/i18n/dicts/zh-CN/terminal.ts b/src/shared/i18n/dicts/zh-CN/terminal.ts new file mode 100644 index 0000000..0616d7b --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/terminal.ts @@ -0,0 +1,26 @@ +/** 命名空间:terminal(终端视图:右键菜单、搜索、粘贴确认、工具条、提示)。归属:工作区/终端维护者。 */ +const terminal: Record = { + 'terminal.menu.find': '查找', + 'terminal.menu.selectAll': '全选', + 'terminal.menu.clear': '清屏', + 'terminal.search.placeholder': '搜索', + 'terminal.search.prev': '上一个', + 'terminal.search.next': '下一个', + 'terminal.search.found': '已找到', + 'terminal.search.notFound': '未找到', + 'terminal.suggest.hint': '↑/↓ 选择 · Tab 接受 · Esc 关闭', + 'terminal.rec.start': '开始记录', + 'terminal.rec.stop': '停止记录', + 'terminal.rec.startLog': '开始记录会话日志', + 'terminal.rec.stopLog': '停止记录会话日志', + 'terminal.rec.openLogs': '打开日志目录', + 'terminal.rec.openCwd': '打开工作区目录', + 'terminal.paste.title': '确认粘贴', + 'terminal.paste.message': '检测到多行或潜在危险命令,确定要粘贴到终端吗?', + 'terminal.paste.noPrompt': '本次会话不再提示', + 'terminal.paste.disableDetection': '关闭后续粘贴检测', + 'terminal.paste.hint': '可在通用终端设置中重新开启或切换检测策略。', + 'terminal.dead.message': '进程已退出 (代码 {code})' +} + +export default terminal diff --git a/src/shared/i18n/dicts/zh-CN/workspace.ts b/src/shared/i18n/dicts/zh-CN/workspace.ts new file mode 100644 index 0000000..db364be --- /dev/null +++ b/src/shared/i18n/dicts/zh-CN/workspace.ts @@ -0,0 +1,60 @@ +/** 命名空间:workspace(工作区外壳:侧栏、标签、布局菜单、分屏、空状态)。归属:工作区维护者。 */ +const workspace: Record = { + 'workspace.tab.terminalTitle': '终端 {n}', + 'workspace.tab.localTerminal': '本地终端', + 'workspace.tab.closeOthers': '关闭其他', + 'workspace.tab.closeRight': '关闭右侧', + 'workspace.tab.broadcastTarget': '广播目标', + 'workspace.tab.broadcastBadge': '播', + 'workspace.tab.addInSplit': '在此分屏中新建终端', + 'workspace.newTerminal': '新建终端', + 'workspace.empty.terminalTitle': '暂无终端', + 'workspace.empty.sshTitle': '暂无 SSH 会话', + 'workspace.empty.sshHint': '请从左侧服务器列表连接', + 'workspace.rail.hideSidebar': '收起侧栏', + 'workspace.rail.showSidebar': '展开侧栏', + 'workspace.rail.layout': '布局', + 'workspace.rail.settings': '设置', + 'workspace.rail.connecting': '正在连接…', + 'workspace.rail.terminalWorkspace': '终端工作区', + 'workspace.rail.sshWorkspace': 'SSH 工作区', + 'workspace.panel.hideSidebar': '收起侧边栏', + 'workspace.panel.showSidebar': '展开侧边栏', + 'workspace.layout.splitRight': '水平分屏', + 'workspace.layout.splitBelow': '垂直分屏', + 'workspace.layout.saveTemplate': '保存为模板', + 'workspace.layout.applyTemplate': '应用模板', + 'workspace.template.saveTitle': '保存布局模板', + 'workspace.template.namePlaceholder': '模板名称', + 'workspace.template.applyTitle': '应用布局模板', + 'workspace.template.empty': '暂无已保存的模板', + 'workspace.template.apply': '应用', + 'workspace.template.deleteConfirm': '删除该模板?', + 'workspace.broadcast.toggle': '广播输入', + 'workspace.broadcast.activeTitle': '广播中 ({count} 个目标)', + 'workspace.broadcast.popoverTerminal': '广播输入(终端)', + 'workspace.broadcast.popoverSsh': '广播输入(SSH)', + 'workspace.broadcast.switch': '广播开关', + 'workspace.broadcast.enabled': '已启用 ({count})', + 'workspace.broadcast.needTwo': '需至少选择 2 个目标', + 'workspace.broadcast.disabled': '未启用', + 'workspace.broadcast.emptySsh': '暂无 SSH 服务器会话', + 'workspace.broadcast.emptyTerminal': '暂无终端', + 'workspace.broadcast.hint': '选择 ≥2 个终端后广播自动开启;关闭任意一个使目标少于 2 个时自动关闭。仅当前模式组的会话可被勾选。', + 'workspace.hint.sshFromList': 'SSH 会话请从服务器列表连接', + 'workspace.hint.connectSshFirst': '请先连接 SSH 服务器', + 'workspace.hint.noActiveTerminal': '没有激活的终端:请至少打开一个本地终端或 SSH 会话', + 'workspace.hint.noSessionInPanel': '当前激活面板没有可用的会话', + 'workspace.connect.failed': 'SSH 连接失败', + 'workspace.connect.passwordTitle': '输入连接密码', + 'workspace.connect.passwordPlaceholder': '密码', + 'workspace.connect.passphraseTitle': '输入私钥口令', + 'workspace.connect.passphrasePlaceholder': '私钥口令', + 'workspace.connect.connecting': '连接中…', + 'workspace.connect.connectingNamed': '正在连接 {name}', + 'workspace.connect.action': '连接', + 'workspace.error.title': '界面出现异常', + 'workspace.error.reload': '重新加载' +} + +export default workspace diff --git a/src/shared/i18n/dicts/zh-TW/common.ts b/src/shared/i18n/dicts/zh-TW/common.ts new file mode 100644 index 0000000..3ef76bb --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/common.ts @@ -0,0 +1,17 @@ +/** 命名空間:通用(i18n 基礎設施、通用按鈕與狀態)。歸屬:i18n 基礎設施維護者。 */ +const common: Record = { + 'common.ok': '確定', + 'common.cancel': '取消', + 'common.close': '關閉', + 'common.save': '儲存', + 'common.delete': '刪除', + 'common.edit': '編輯', + 'common.add': '新增', + 'common.copy': '複製', + 'common.paste': '貼上', + 'common.reset': '恢復預設', + 'common.language': '介面語言', + 'common.languageDesc': '切換後立即預覽介面語言,儲存後永久生效。' +} + +export default common diff --git a/src/shared/i18n/dicts/zh-TW/index.ts b/src/shared/i18n/dicts/zh-TW/index.ts new file mode 100644 index 0000000..6670442 --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/index.ts @@ -0,0 +1,18 @@ +import common from './common' +import settings from './settings' +import workspace from './workspace' +import terminal from './terminal' +import ssh from './ssh' +import panels from './panels' +import main from './main' + +/** 繁體中文詞條。命名空間檔案依區域分工維護,詳見 src/shared/i18n/index.ts 的歸屬表。 */ +export default { + ...common, + ...settings, + ...workspace, + ...terminal, + ...ssh, + ...panels, + ...main +} diff --git a/src/shared/i18n/dicts/zh-TW/main.ts b/src/shared/i18n/dicts/zh-TW/main.ts new file mode 100644 index 0000000..5589b54 --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/main.ts @@ -0,0 +1,56 @@ +/** 命名空間:main(主程序文案:系統匣、關閉詢問、更新器)。歸屬:主程序維護者。 */ +const main: Record = { + 'main.tray.showWindow': '顯示主視窗', + 'main.tray.closeAction': '關閉按鈕行為', + 'main.tray.askEveryTime': '每次詢問', + 'main.tray.minimizeToTray': '最小化到系統匣', + 'main.tray.exitDirectly': '直接結束', + 'main.tray.exit': '結束', + 'main.tray.balloonTitle': '已最小化到系統匣', + 'main.tray.balloonContent': '終端機連線仍在背景執行,點擊系統匣圖示即可還原視窗。', + 'main.tray.closeTitle': '關閉 OpenTerminal', + 'main.tray.closeMessage': '要最小化到系統匣,還是直接結束?', + 'main.tray.closeDetail': '最小化到系統匣時,終端機連線會繼續執行。', + 'main.tray.rememberChoice': '記住我的選擇,不再詢問', + + 'main.updater.feedFailed': '國內來源: {gitea};GitHub: {github}', + + 'main.ssh.connectTimeout': '連線逾時 ({host}:{port})', + 'main.ssh.saveFingerprintFailed': '儲存主機指紋失敗: {detail}', + 'main.ssh.hostKeyRejected': '使用者拒絕了主機指紋', + 'main.ssh.connectFailed': '連線失敗 {host}:{port}: {detail}', + 'main.ssh.shellOpenFailed': '無法開啟 SSH shell ({host}:{port}): {detail}', + 'main.ssh.initFailed': 'SSH 連線初始化失敗 ({host}:{port}): {detail}', + 'main.ssh.readKeyFailed': '無法讀取私密金鑰檔案 {path}: {detail}', + + 'main.sftp.sessionGone': 'SSH 連線不存在或已中斷', + 'main.sftp.deleteFailed': '刪除失敗: {detail}', + 'main.sftp.invalidMode': '權限值無效: {mode}', + 'main.sftp.invalidUidGid': 'uid/gid 無效', + 'main.sftp.commandExitCode': '指令結束碼 {code}', + 'main.sftp.cancelled': '已取消', + + 'main.zmodem.transferFailed': '傳輸失敗', + 'main.zmodem.transferTimeout': '傳輸逾時', + 'main.zmodem.sendFailed': '傳送失敗', + 'main.zmodem.uploadFailed': '上傳失敗', + 'main.zmodem.receiveFailed': '接收失敗', + 'main.zmodem.abnormal': 'ZMODEM 傳輸異常', + 'main.zmodem.cancelled': '已取消', + 'main.zmodem.offerTimedOut': '未選擇檔案,已取消', + 'main.zmodem.sessionInvalid': '連線已失效', + 'main.zmodem.sessionCreateFailed': '無法建立 ZMODEM 連線', + 'main.zmodem.noSaveDir': '未指定儲存目錄', + 'main.zmodem.noFiles': '未選擇檔案', + + 'main.ipc.connectionMissing': '連線書籤不存在 ({id})', + + 'main.sysinfo.sessionGone': 'SSH 工作階段不存在或已中斷', + 'main.sysinfo.execFailed': 'SSH exec 失敗', + 'main.sysinfo.parseFailed': '解析失敗: {message}', + 'main.pty.serviceNotReady': 'SSH 工作階段服務尚未初始化', + 'main.pty.missingConnectionId': 'SSH 工作階段缺少 connectionId', + 'main.log.tuiOmitted': '全螢幕介面(TUI)輸出已省略' +} + +export default main diff --git a/src/shared/i18n/dicts/zh-TW/panels.ts b/src/shared/i18n/dicts/zh-TW/panels.ts new file mode 100644 index 0000000..7d8a33f --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/panels.ts @@ -0,0 +1,36 @@ +/** 命名空間:panels(命令面板、工作階段日誌面板、傳輸面板)。歸屬:SSH/面板維護者。 */ +const panels: Record = { + // 命令面板 + 'panels.commands.sectionTitle': '命令', + 'panels.commands.target': '傳送目標', + 'panels.commands.targetSsh': 'SSH 伺服器', + 'panels.commands.targetTerminal': '終端機', + 'panels.commands.tabHistory': '歷史', + 'panels.commands.tabLibrary': '命令庫', + 'panels.commands.historyTitle': '歷史命令', + 'panels.commands.clearTitle': '清空全部命令歷史?', + 'panels.commands.clear': '清空', + 'panels.commands.noHistory': '尚無歷史命令', + 'panels.commands.loading': '載入中…', + 'panels.commands.libraryTitle': '命令庫', + 'panels.commands.libraryEmpty': '命令庫為空', + 'panels.commands.add': '新增命令', + 'panels.commands.name': '名稱', + 'panels.commands.optional': '(選填)', + 'panels.commands.command': '命令', + 'panels.commands.commandPlaceholder': '如 ls -la', + 'panels.commands.note': '備註', + 'panels.commands.deleteTitle': '刪除該命令?', + 'panels.commands.deleteAria': '刪除命令', + + // 傳輸面板 + 'panels.transfer.failed': '傳輸失敗', + 'panels.transfer.cancelled': '已取消', + 'panels.transfer.done': '傳輸完成', + 'panels.transfer.uploading': '上傳中', + 'panels.transfer.downloading': '下載中', + 'panels.transfer.zmodemUploading': 'ZMODEM 上傳中', + 'panels.transfer.zmodemDownloading': 'ZMODEM 下載中' +} + +export default panels diff --git a/src/shared/i18n/dicts/zh-TW/settings.ts b/src/shared/i18n/dicts/zh-TW/settings.ts new file mode 100644 index 0000000..d1334bb --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/settings.ts @@ -0,0 +1,147 @@ +/** 命名空間:settings(設定對話框:字型/游標/渲染/高亮/主題/系統/關於)。歸屬:設定頁維護者。 */ +const settings: Record = { + 'settings.title': '設定', + 'settings.tabs.font': '字型', + 'settings.tabs.cursor': '游標與捲動', + 'settings.tabs.render': '渲染與行為', + 'settings.tabs.highlight': '高亮', + 'settings.tabs.theme': '主題', + 'settings.tabs.system': '系統', + 'settings.tabs.about': '關於', + 'settings.resizeHandle': '拖曳調整大小', + 'settings.preview': '預覽', + 'settings.deleteConfirmDesc': '刪除後無法復原,確定要繼續嗎?', + 'settings.color.foreground': '前景', + 'settings.color.background': '背景', + 'settings.color.cursor': '游標', + 'settings.color.selection': '選取區', + 'settings.font.label': '字型', + 'settings.font.desc': '內建預設字型或系統等寬字型', + 'settings.font.placeholder': '選擇字型', + 'settings.font.default': '預設', + 'settings.font.size': '字型大小', + 'settings.font.sizeDesc': '終端文字的像素大小', + 'settings.font.weight': '字重', + 'settings.font.weightDesc': '400 為一般,700 為粗體', + 'settings.font.boldWeight': '粗體字重', + 'settings.font.boldWeightDesc': '粗體文字所使用的字重', + 'settings.font.letterSpacing': '字元間距', + 'settings.font.letterSpacingDesc': '字元之間的水平間距', + 'settings.font.lineHeight': '行高', + 'settings.font.lineHeightDesc': '相鄰兩行之間的垂直距離', + 'settings.cursor.blink': '游標閃爍', + 'settings.cursor.blinkDesc': '游標是否週期性閃爍', + 'settings.cursor.style': '游標樣式', + 'settings.cursor.styleDesc': '游標的顯示形狀', + 'settings.cursor.inactiveStyle': '非作用中游標樣式', + 'settings.cursor.inactiveStyleDesc': '視窗失焦時的游標樣式', + 'settings.cursor.scrollback': '捲動緩衝區', + 'settings.cursor.scrollbackDesc': '畫面上可往回捲動的行數', + 'settings.cursorStyle.bar': '直線 (bar)', + 'settings.cursorStyle.block': '方塊 (block)', + 'settings.cursorStyle.underline': '底線 (underline)', + 'settings.cursorStyle.outline': '外框 (outline)', + 'settings.cursorStyle.none': '無 (none)', + 'settings.rendererMode.auto': '自動', + 'settings.rendererMode.autoHint': '優先使用 WebGL 加速,失敗時自動退回 DOM 渲染', + 'settings.rendererMode.webgl': '高效能 WebGL', + 'settings.rendererMode.webglHint': '強制使用 GPU 加速渲染,適合高更新率與大量行數捲動', + 'settings.rendererMode.dom': '相容 DOM', + 'settings.rendererMode.domHint': '純 CPU 渲染,相容性最佳,適合沒有 GPU 或遠端桌面的環境', + 'settings.render.modeLabel': '渲染模式', + 'settings.render.modeHint': '終端儲存格的渲染加速方式,變更將套用到所有終端', + 'settings.render.autoWrap': '自動換行', + 'settings.render.autoWrapDesc': '超出寬度時自動折行', + 'settings.render.copyOnSelect': '選取即複製', + 'settings.render.copyOnSelectDesc': '以滑鼠選取文字時自動複製', + 'settings.render.pasteRiskConfirm': '貼上風險確認', + 'settings.render.pasteRiskConfirmDesc': '多行文字(或超長單行)貼上前彈出確認;單行直接貼上', + 'settings.render.suggest': '輸入建議', + 'settings.render.suggestDesc': '輸入時顯示指令建議;Tab 接受,Enter 一律直接執行', + 'settings.render.history': '記錄指令歷史', + 'settings.render.historyDesc': '關閉後不再記錄新指令;已儲存的歷史仍會保留', + 'settings.render.historyLimit': '歷史筆數上限', + 'settings.render.historyLimitDesc': '指令歷史最多保留幾筆,重複的指令只記一次', + 'settings.render.recButton': '終端工具列 · 錄製', + 'settings.render.recButtonDesc': '右上角顯示「記錄工作階段日誌」按鈕', + 'settings.render.openLogsButton': '終端工具列 · 開啟日誌目錄', + 'settings.render.openLogsButtonDesc': '右上角顯示「開啟日誌目錄」按鈕', + 'settings.render.openCwdButton': '終端工具列 · 開啟工作區目錄', + 'settings.render.openCwdButtonDesc': + '右上角顯示「開啟目前目錄」按鈕(SSH 工作階段不顯示;目錄會隨 cd 即時追蹤,需開啟 shell 整合或使用 cd 指令)', + 'settings.system.launchAtLogin': '開機時自動啟動', + 'settings.system.launchAtLoginDesc': '開機時自動啟動程式(開發模式下註冊的是開發版程式)', + 'settings.system.preventSleep': '防止系統休眠', + 'settings.system.preventSleepDesc': + '開啟後 OpenTerminal 會防止系統自動休眠;關閉則允許系統依本身的設定休眠', + 'settings.system.restoreSession': '啟動時還原上次工作階段', + 'settings.system.restoreSessionDesc': + '下次啟動時還原分割版面,並讓每個終端回到上次所在的目錄;關閉則每次都以單一終端啟動', + 'settings.system.shellIntegration': 'Shell 整合', + 'settings.system.shellIntegrationDesc': + '讓 shell 主動回報工作目錄(OSC 7),路徑記憶更精確(含指令碼內的 cd);會包裝你的命令提示字元,提示字元主題顯示異常時關閉即可', + 'settings.system.closeAction': '關閉按鈕行為', + 'settings.system.closeActionDesc': + '點擊視窗關閉按鈕時的動作;最小化至系統匣後,可從系統匣圖示的右鍵選單結束', + 'settings.system.closeActionTray': '最小化至系統匣', + 'settings.system.closeActionExit': '直接結束', + 'settings.system.closeActionAsk': '每次詢問', + 'settings.system.globalShortcut': '全域喚出快速鍵', + 'settings.system.globalShortcutDesc': '點擊輸入框後直接按下組合鍵;Esc 取消,Backspace 清除並停用', + 'settings.system.globalShortcutHint': + '搭配全域鍵可在任何畫面喚出/隱藏視窗;若註冊失敗(與其他軟體衝突)則不會生效,應用程式也不會報錯。', + 'settings.system.shortcutRecording': '請按下組合鍵…', + 'settings.system.shortcutPlaceholder': '點擊後按下快速鍵,留空為停用', + 'settings.highlight.alert': + '自訂高亮可強化顯示效果,但會略微增加效能負擔。規則依優先順序套用。PS:高亮是在寫入終端時注入顏色,對既有的回捲內容不生效', + 'settings.highlight.enabled': '啟用', + 'settings.highlight.pattern': '正規表示式', + 'settings.highlight.patternPlaceholder': '例如 \\b(ERROR|FAILED)\\b', + 'settings.highlight.invalidRegex': '正規表示式無效', + 'settings.highlight.priority': '優先順序', + 'settings.highlight.priorityHint': '值越小越先套用', + 'settings.highlight.color': '顏色', + 'settings.highlight.clearBg': '清除', + 'settings.highlight.addBg': '新增', + 'settings.highlight.palette': '色盤', + 'settings.highlight.note': '備註', + 'settings.highlight.notePlaceholder': '規則說明(選填)', + 'settings.highlight.actions': '操作', + 'settings.highlight.addRule': '+ 新增規則', + 'settings.highlight.empty': '尚無高亮規則,點擊「+ 新增規則」建立', + 'settings.highlight.deleteTitle': '刪除高亮規則', + 'settings.highlight.resetTitle': '還原預設高亮規則', + 'settings.highlight.resetDesc': '會將目前所有規則取代為內建預設,無法復原。', + 'settings.highlight.resetOk': '還原', + 'settings.highlight.createTitle': '新增高亮規則', + 'settings.highlight.editTitle': '編輯高亮規則', + 'settings.theme.accent': '標籤強調色', + 'settings.theme.accentDesc': '作用中標籤的邊框、側邊欄指示條與 SSH 徽章的強調顏色', + 'settings.theme.customBadge': '自訂', + 'settings.theme.deleteTitle': '刪除自訂主題', + 'settings.theme.create': '新增主題', + 'settings.theme.editTitle': '編輯主題', + 'settings.themeEditor.name': '名稱', + 'settings.themeEditor.namePlaceholder': '主題名稱', + 'settings.themeEditor.copySuffix': '(副本)', + 'settings.about.currentVersion': '目前版本', + 'settings.about.statusIdle': '點擊檢查更新', + 'settings.about.statusChecking': '正在檢查更新…', + 'settings.about.statusAvailable': '發現新版本', + 'settings.about.statusLatest': '目前已是最新版本', + 'settings.about.statusDownloading': '正在下載…', + 'settings.about.statusDownloaded': '下載完成,可重新啟動安裝', + 'settings.about.statusError': '檢查失敗', + 'settings.about.statusDev': '開發模式不支援檢查更新', + 'settings.about.channelStable': '穩定更新通道', + 'settings.about.autoCheck': '啟動時自動檢查', + 'settings.about.download': '下載更新', + 'settings.about.downloadGithub': '(GitHub 來源)', + 'settings.about.install': '重新啟動安裝', + 'settings.about.check': '檢查更新', + 'settings.about.checkingButton': '檢查中', + 'settings.about.changelog': '更新日誌', + 'settings.about.changelogEmpty': '暫無更新日誌' +} + +export default settings diff --git a/src/shared/i18n/dicts/zh-TW/ssh.ts b/src/shared/i18n/dicts/zh-TW/ssh.ts new file mode 100644 index 0000000..f040d23 --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/ssh.ts @@ -0,0 +1,144 @@ +/** 命名空間:ssh(SSH 連線管理、主機金鑰、檔案面板、zmodem、系統監控)。歸屬:SSH 維護者。 */ +const ssh: Record = { + // 認證方式 / 金鑰種類 + 'ssh.auth.password': '密碼', + 'ssh.auth.privateKey': '私鑰', + 'ssh.auth.agent': 'Agent', + 'ssh.secret.passphrase': '口令', + + // 連線編輯對話框:認證相關欄位 + 'ssh.fields.agentDesc': '使用本機 SSH Agent 中已載入的金鑰進行認證,無須在本應用程式內儲存任何金鑰。', + 'ssh.fields.askPasswordLabel': '連線時詢問密碼', + 'ssh.fields.askPasswordHint': '開啟後每次連線彈出對話框輸入密碼,不再使用已儲存的密碼', + 'ssh.fields.passwordPlaceholder': '編輯時留空表示保留已儲存的密碼', + 'ssh.fields.keyPath': '私鑰路徑(伺服器上的本機私鑰路徑,可留空)', + 'ssh.fields.keyContent': '私鑰內容', + 'ssh.fields.keyContentPlaceholder': + '貼上私鑰內容;留空則使用上面的路徑(編輯時留空表示保留已儲存的私鑰)', + 'ssh.fields.askPassphraseLabel': '連線時詢問口令', + 'ssh.fields.askPassphraseHint': '開啟後每次連線提示輸入私鑰口令', + 'ssh.fields.passphrase': '私鑰口令', + 'ssh.fields.passphrasePlaceholder': '編輯時留空表示保留已儲存的口令', + + // 連線編輯對話框 + 'ssh.dialog.titleEdit': '編輯連線', + 'ssh.dialog.titleNew': '新增連線', + 'ssh.dialog.saveFailed': '儲存失敗,請檢查輸入後重試', + 'ssh.dialog.name': '名稱', + 'ssh.dialog.nameRequired': '請輸入連線名稱', + 'ssh.dialog.namePlaceholder': '例如:正式環境伺服器', + 'ssh.dialog.group': '分組', + 'ssh.dialog.groupPlaceholder': '選擇或輸入新分組,留空歸入「預設」', + 'ssh.dialog.host': '主機', + 'ssh.dialog.hostRequired': '請輸入主機位址', + 'ssh.dialog.hostPlaceholder': '192.168.1.10 或 example.com', + 'ssh.dialog.port': '連接埠', + 'ssh.dialog.portRange': '連接埠需在 1–65535 之間', + 'ssh.dialog.username': '使用者名稱', + 'ssh.dialog.usernameRequired': '請輸入使用者名稱', + 'ssh.dialog.authMethod': '認證方式', + 'ssh.dialog.savedSecret': '已儲存{kind},留空將保留原值', + 'ssh.dialog.keepalive': 'Keepalive 間隔(秒)', + 'ssh.dialog.keepaliveTooltip': '0 表示關閉 keepalive', + 'ssh.dialog.keepaliveMin': '不能為負數', + + // 連線側欄 + 'ssh.sidebar.defaultGroup': '預設', + 'ssh.sidebar.timeJustNow': '剛剛', + 'ssh.sidebar.timeMinutesAgo': '{n} 分鐘前', + 'ssh.sidebar.timeHoursAgo': '{n} 小時前', + 'ssh.sidebar.timeDaysAgo': '{n} 天前', + 'ssh.sidebar.modeTerminal': '終端機工作區', + 'ssh.sidebar.modeSsh': 'SSH 工作區', + 'ssh.sidebar.title': '連線', + 'ssh.sidebar.newConnection': '新增連線', + 'ssh.sidebar.sectionTerminals': '終端機', + 'ssh.sidebar.noLocalTerminals': '沒有開啟的本機終端機', + 'ssh.sidebar.local': '本機', + 'ssh.sidebar.sectionServers': 'SSH 伺服器', + 'ssh.sidebar.empty': '尚無連線,點擊+新增', + 'ssh.sidebar.loading': '載入中…', + 'ssh.sidebar.recent': '最近工作階段', + 'ssh.sidebar.dblClickConnect': '雙擊連線 {host}', + 'ssh.sidebar.deleteTitle': '刪除該連線?', + 'ssh.sidebar.deleteDesc': '連線設定刪除後無法復原。已儲存的金鑰資料會一併刪除。', + + // 主機金鑰確認 + 'ssh.hostKey.changedTitle': '主機指紋已變更!', + 'ssh.hostKey.unknownHost': '未知主機', + 'ssh.hostKey.accept': '接受並連線', + 'ssh.hostKey.reject': '拒絕', + 'ssh.hostKey.mitmTitle': '可能遭受中間人攻擊', + 'ssh.hostKey.mitmDesc': + '遠端主機的金鑰指紋與先前記錄的不一致。如果這不是您本人更換了伺服器金鑰,可能有人正在冒充該主機。', + 'ssh.hostKey.firstConnect': '首次連線該主機,尚未記錄其指紋。', + 'ssh.hostKey.target': '主機:{target}', + 'ssh.hostKey.fingerprint': '指紋:{fingerprint}', + + // SSH 工作區:檔案面板容器、主機徽章 + 'ssh.bottomPanel.resize': '調整檔案面板高度', + 'ssh.hostBadge.aria': 'SSH 主機 {label}', + + // ZMODEM + 'ssh.zmodem.done': 'ZMODEM 傳輸完成', + 'ssh.zmodem.failed': 'ZMODEM 傳輸失敗:{message}', + 'ssh.zmodem.unknownError': '未知錯誤', + 'ssh.zmodem.titleReceive': '下載到本機', + 'ssh.zmodem.titleSend': '上傳到伺服器', + 'ssh.zmodem.pickDir': '選擇儲存目錄', + 'ssh.zmodem.pickFiles': '選擇檔案', + 'ssh.zmodem.receiveDesc': '偵測到 sz 下載請求(遠端正在傳送檔案),請選擇儲存在本機的目錄。', + 'ssh.zmodem.sendDesc': '偵測到 rz 上傳請求(遠端正在接收檔案),請選擇要上傳的檔案。', + + // 檔案面板(SFTP) + 'ssh.file.up': '上一層', + 'ssh.file.refresh': '重新整理', + 'ssh.file.uploadFile': '上傳檔案', + 'ssh.file.downloadSelected': '下載選取項目', + 'ssh.file.permOwner': '權限 / 擁有者', + 'ssh.file.open': '開啟', + 'ssh.file.download': '下載', + 'ssh.file.upload': '上傳…', + 'ssh.file.rename': '重新命名', + 'ssh.file.new': '新增', + 'ssh.file.newFolder': '新增資料夾', + 'ssh.file.copyPath': '複製路徑', + 'ssh.file.copyPathFailed': '複製路徑失敗', + 'ssh.file.pathCopied': '已複製路徑', + 'ssh.file.permission': '檔案權限…', + 'ssh.file.deleteTitle': '確認刪除?', + 'ssh.file.loading': '載入中…', + 'ssh.file.empty': '空目錄', + 'ssh.file.modeUnavailable': '權限不可用', + 'ssh.file.folderName': '資料夾名稱', + 'ssh.file.create': '建立', + 'ssh.file.renameTitle': '重新命名 {name}', + 'ssh.file.permTitle': '權限 · {name}', + 'ssh.file.permApplied': '已套用權限', + 'ssh.file.octal': '八進位', + 'ssh.file.octalPlaceholder': '如 755', + 'ssh.file.keepEmpty': '不修改則留空', + 'ssh.file.ownerHint': 'UID/GID 留空表示不修改擁有者', + 'ssh.file.permRowOwner': '擁有者', + 'ssh.file.permRowGroup': '群組', + 'ssh.file.permRowOther': '其他', + 'ssh.file.permColRead': '讀取', + 'ssh.file.permColWrite': '寫入', + 'ssh.file.permColExec': '執行', + + // 系統監控 + 'ssh.monitor.collecting': '採集中…', + 'ssh.monitor.interrupted': '採集中斷:{error}', + 'ssh.monitor.mem': '記憶體', + 'ssh.monitor.disks': '磁碟', + 'ssh.monitor.net': '網路', + 'ssh.monitor.cores': '{n} 核心', + 'ssh.monitor.loadavg': '負載 {value}', + 'ssh.monitor.uptime': '運作時間', + 'ssh.monitor.uptimeSeconds': '{n} 秒', + 'ssh.monitor.uptimeMinutes': '{n} 分鐘', + 'ssh.monitor.uptimeHours': '{h} 小時 {m} 分', + 'ssh.monitor.uptimeDays': '{n} 天' +} + +export default ssh diff --git a/src/shared/i18n/dicts/zh-TW/terminal.ts b/src/shared/i18n/dicts/zh-TW/terminal.ts new file mode 100644 index 0000000..0797085 --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/terminal.ts @@ -0,0 +1,26 @@ +/** 命名空間:terminal(終端機檢視:右鍵選單、搜尋、貼上確認、工具列、提示)。歸屬:工作區/終端機維護者。 */ +const terminal: Record = { + 'terminal.menu.find': '尋找', + 'terminal.menu.selectAll': '全選', + 'terminal.menu.clear': '清除畫面', + 'terminal.search.placeholder': '搜尋', + 'terminal.search.prev': '上一個', + 'terminal.search.next': '下一個', + 'terminal.search.found': '已找到', + 'terminal.search.notFound': '未找到', + 'terminal.suggest.hint': '↑/↓ 選擇 · Tab 接受 · Esc 關閉', + 'terminal.rec.start': '開始錄製', + 'terminal.rec.stop': '停止錄製', + 'terminal.rec.startLog': '開始錄製工作階段日誌', + 'terminal.rec.stopLog': '停止錄製工作階段日誌', + 'terminal.rec.openLogs': '開啟日誌目錄', + 'terminal.rec.openCwd': '開啟工作區目錄', + 'terminal.paste.title': '確認貼上', + 'terminal.paste.message': '偵測到多行或潛在危險命令,確定要貼到終端機嗎?', + 'terminal.paste.noPrompt': '本次工作階段不再提示', + 'terminal.paste.disableDetection': '關閉後續貼上偵測', + 'terminal.paste.hint': '可在一般終端機設定中重新開啟或切換偵測策略。', + 'terminal.dead.message': '處理程序已結束 (代碼 {code})' +} + +export default terminal diff --git a/src/shared/i18n/dicts/zh-TW/workspace.ts b/src/shared/i18n/dicts/zh-TW/workspace.ts new file mode 100644 index 0000000..899bbb9 --- /dev/null +++ b/src/shared/i18n/dicts/zh-TW/workspace.ts @@ -0,0 +1,60 @@ +/** 命名空間:workspace(工作區外殼:側邊欄、分頁、版面配置選單、分割、空狀態)。歸屬:工作區維護者。 */ +const workspace: Record = { + 'workspace.tab.terminalTitle': '終端機 {n}', + 'workspace.tab.localTerminal': '本機終端機', + 'workspace.tab.closeOthers': '關閉其他', + 'workspace.tab.closeRight': '關閉右側', + 'workspace.tab.broadcastTarget': '廣播目標', + 'workspace.tab.broadcastBadge': '播', + 'workspace.tab.addInSplit': '在此分割中新增終端機', + 'workspace.newTerminal': '新增終端機', + 'workspace.empty.terminalTitle': '尚無終端機', + 'workspace.empty.sshTitle': '尚無 SSH 工作階段', + 'workspace.empty.sshHint': '請從左側伺服器清單連線', + 'workspace.rail.hideSidebar': '收合側邊欄', + 'workspace.rail.showSidebar': '展開側邊欄', + 'workspace.rail.layout': '版面配置', + 'workspace.rail.settings': '設定', + 'workspace.rail.connecting': '連線中…', + 'workspace.rail.terminalWorkspace': '終端機工作區', + 'workspace.rail.sshWorkspace': 'SSH 工作區', + 'workspace.panel.hideSidebar': '收合側邊欄', + 'workspace.panel.showSidebar': '展開側邊欄', + 'workspace.layout.splitRight': '水平分割', + 'workspace.layout.splitBelow': '垂直分割', + 'workspace.layout.saveTemplate': '儲存為範本', + 'workspace.layout.applyTemplate': '套用範本', + 'workspace.template.saveTitle': '儲存版面配置範本', + 'workspace.template.namePlaceholder': '範本名稱', + 'workspace.template.applyTitle': '套用版面配置範本', + 'workspace.template.empty': '尚無已儲存的範本', + 'workspace.template.apply': '套用', + 'workspace.template.deleteConfirm': '刪除這個範本?', + 'workspace.broadcast.toggle': '廣播輸入', + 'workspace.broadcast.activeTitle': '廣播中({count} 個目標)', + 'workspace.broadcast.popoverTerminal': '廣播輸入(終端機)', + 'workspace.broadcast.popoverSsh': '廣播輸入(SSH)', + 'workspace.broadcast.switch': '廣播開關', + 'workspace.broadcast.enabled': '已啟用({count})', + 'workspace.broadcast.needTwo': '需至少選擇 2 個目標', + 'workspace.broadcast.disabled': '未啟用', + 'workspace.broadcast.emptySsh': '尚無 SSH 伺服器工作階段', + 'workspace.broadcast.emptyTerminal': '尚無終端機', + 'workspace.broadcast.hint': '選擇 ≥2 個終端機後會自動開啟廣播;關閉任一個使目標少於 2 個時會自動關閉。僅目前模式群組的工作階段可被勾選。', + 'workspace.hint.sshFromList': 'SSH 工作階段請從伺服器清單連線', + 'workspace.hint.connectSshFirst': '請先連線 SSH 伺服器', + 'workspace.hint.noActiveTerminal': '沒有作用中的終端機:請至少開啟一個本機終端機或 SSH 工作階段', + 'workspace.hint.noSessionInPanel': '目前作用中的面板沒有可用的工作階段', + 'workspace.connect.failed': 'SSH 連線失敗', + 'workspace.connect.passwordTitle': '輸入連線密碼', + 'workspace.connect.passwordPlaceholder': '密碼', + 'workspace.connect.passphraseTitle': '輸入私密金鑰密語', + 'workspace.connect.passphrasePlaceholder': '私密金鑰密語', + 'workspace.connect.connecting': '連線中…', + 'workspace.connect.connectingNamed': '正在連線 {name}', + 'workspace.connect.action': '連線', + 'workspace.error.title': '介面發生異常', + 'workspace.error.reload': '重新載入' +} + +export default workspace diff --git a/src/shared/i18n/index.ts b/src/shared/i18n/index.ts new file mode 100644 index 0000000..30bbb5f --- /dev/null +++ b/src/shared/i18n/index.ts @@ -0,0 +1,83 @@ +/** + * Tiny dependency-free i18n core, shared by the renderer and the main process. + * + * Dictionaries are flat `key -> text` maps split by namespace, one file per + * language per namespace (`dicts//.ts`). Ownership map — each + * namespace is translated by whoever owns that area, so parallel work never + * edits the same file: + * + * common — i18n plumbing: language names, generic buttons/status + * settings — the settings dialog (font/cursor/render/highlight/theme/system/about) + * workspace — workspace shell: sidebar, tabs, layout menu, split, empty state + * terminal — terminal view internals: context menu, search, paste confirm, toolbar + * ssh — ssh connections, host key prompt, file panel, zmodem, monitor + * panels — command panel, session-log panel, transfer panel + * main — main-process strings: tray, close prompt, updater + * + * Missing keys fall back to zh-CN, then to the key itself (so a missing + * translation is visible, never blank). + */ + +import zhCN from './dicts/zh-CN' +import zhTW from './dicts/zh-TW' +import en from './dicts/en' +import ja from './dicts/ja' + +export type Language = 'zh-CN' | 'zh-TW' | 'en' | 'ja' + +export interface LanguageOption { + value: Language + /** Language names are shown in their own script, so this is not translated. */ + label: string +} + +export const LANGUAGES: LanguageOption[] = [ + { value: 'zh-CN', label: '简体中文' }, + { value: 'zh-TW', label: '繁體中文' }, + { value: 'en', label: 'English' }, + { value: 'ja', label: '日本語' } +] + +export const DEFAULT_LANGUAGE: Language = 'zh-CN' + +export function isLanguage(value: unknown): value is Language { + return typeof value === 'string' && LANGUAGES.some((l) => l.value === value) +} + +export type Dict = Record + +const DICTS: Record = { + 'zh-CN': zhCN, + 'zh-TW': zhTW, + en, + ja +} + +let current: Language = DEFAULT_LANGUAGE +const listeners = new Set<(lang: Language) => void>() + +/** Switch the active language and notify subscribers (renderer re-render, + * main-process tray rebuild). Both processes call this on settings load/save. */ +export function setLanguage(lang: Language): void { + if (current === lang) return + current = lang + for (const listener of listeners) listener(lang) +} + +export function getLanguage(): Language { + return current +} + +export function onLanguageChange(cb: (lang: Language) => void): () => void { + listeners.add(cb) + return () => listeners.delete(cb) +} + +/** Translate `key`; `{name}` placeholders are filled from `vars`. */ +export function t(key: string, vars?: Record): string { + const raw = DICTS[current][key] ?? DICTS[DEFAULT_LANGUAGE][key] ?? key + if (!vars) return raw + return raw.replace(/\{(\w+)\}/g, (match, name: string) => + vars[name] === undefined ? match : String(vars[name]) + ) +} diff --git a/src/shared/settings.ts b/src/shared/settings.ts index 0e92cc4..e3a7ec1 100644 --- a/src/shared/settings.ts +++ b/src/shared/settings.ts @@ -1,4 +1,5 @@ import type { TerminalTheme } from './theme' +import type { Language } from './i18n' /** One terminal keyword-highlight rule (regex → ANSI color injection). */ export interface HighlightRule { @@ -72,6 +73,8 @@ export interface SystemSettings { * to parsing typed `cd` commands. */ shellIntegration?: boolean + /** interface language (see @shared/i18n); missing = 简体中文 */ + language?: Language } export interface AppSettings { @@ -133,5 +136,5 @@ export const DEFAULT_SETTINGS: AppSettings = { }, customThemes: [], highlightRules: DEFAULT_HIGHLIGHT_RULES, - system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false } + system: { launchAtLogin: false, preventSleep: false, globalShowHide: '', closeAction: 'tray', autoCheckUpdate: true, restoreSession: true, shellIntegration: false, language: 'zh-CN' } } diff --git a/tests/commands-store.mjs b/tests/commands-store.mjs index a2528c8..332d1dd 100644 --- a/tests/commands-store.mjs +++ b/tests/commands-store.mjs @@ -49,6 +49,20 @@ const store = new commandsMod.CommandsStore(userData, async (p) => { }) // ---- 3. History record / dedupe / cap ------------------------------------------ +// Recording is off by default (the setting ships disabled); every test below that +// exercises recording enables it explicitly through this helper. A call site that +// passes `historyEnabled: false` still wins, because the spread comes last. +const writeSettings = (terminal) => + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ terminal: { historyEnabled: true, ...terminal }, system: {} }), + 'utf8' + ) +const DEFAULT_LIMIT = 100 +// The settings file has to exist before the first recordCommand: with no file, +// the store falls back to the shipped defaults, where recording is off. +writeSettings({}) + store.recordCommand(' echo hello ') // trims to 'echo hello' store.recordCommand('ls -la') store.recordCommand('ps aux') @@ -82,9 +96,6 @@ store.clearHistory() ok(store.listHistory().length === 0, 'clearHistory empties history') // ---- 3b. History limit + the recording switch ----------------------------------- -const writeSettings = (terminal) => - writeFileSync(join(userData, 'settings.json'), JSON.stringify({ terminal, system: {} }), 'utf8') -const DEFAULT_LIMIT = 100 // The bundled default raises past the old hard cap of 500 only when configured; // out of the box the list is trimmed to the default limit. diff --git a/tests/settings-store.mjs b/tests/settings-store.mjs new file mode 100644 index 0000000..4a301dc --- /dev/null +++ b/tests/settings-store.mjs @@ -0,0 +1,115 @@ +/** + * Settings-store self-test (settings-store.mjs). + * + * Guards the sanitizer's repair-instead-of-drop behavior: + * - closeAction 'ask' survives a save/load round trip (it used to be coerced + * to 'tray', which made the "ask every time" option dead in the UI) + * - a rule with a repairable shape (priority "5", enabled 1, missing fg + * colour) is corrected rather than dropped + * - an unusable entry is skipped and written to settings-warnings.log + * + * Build + run: + * npx esbuild src/main/settingsStore.ts --bundle --platform=node --format=cjs \ + * --outfile=tests/.settings-store.cjs --alias:electron=./tests/electron-stub.cjs \ + * --alias:@shared=./src/shared + * node tests/settings-store.mjs + */ +import { mkdtempSync, readFileSync, existsSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const userData = mkdtempSync(join(tmpdir(), 'ot-settings-')) +process.env.OT_STUB_USERDATA = userData + +const store = require('./.settings-store.cjs') + +let failed = 0 +const ok = (cond, msg) => { + console.log(` ${cond ? 'ok' : 'FAIL'}: ${msg}`) + if (!cond) failed++ +} + +const rule = (over = {}) => ({ + id: 'r1', + pattern: '\\bERROR\\b', + enabled: true, + priority: 5, + color: { fg: '#ff0000' }, + note: 'note', + ...over +}) + +const roundTrip = async (patch) => { + const current = store.loadSettings() + await store.saveSettings({ ...current, ...patch }) + return store.loadSettings() +} + +// ---- 1. closeAction enum ---------------------------------------------------- +console.log('[closeAction]') +for (const action of ['tray', 'exit', 'ask']) { + const back = (await roundTrip({ system: { ...store.loadSettings().system, closeAction: action } })).system.closeAction + ok(back === action, `closeAction '${action}' survives the round trip (got '${back}')`) +} +{ + const back = (await roundTrip({ system: { ...store.loadSettings().system, closeAction: 'nonsense' } })).system.closeAction + ok(back === 'tray', `unknown closeAction falls back to 'tray' (got '${back}')`) +} + +// ---- 2. highlight-rule repair ---------------------------------------------- +console.log('[highlight rules]') +{ + const out = (await roundTrip({ highlightRules: [rule()] })).highlightRules + ok(out.length === 1 && out[0].priority === 5 && out[0].color.fg === '#ff0000', 'well-formed rule is untouched') +} +{ + const out = (await roundTrip({ highlightRules: [rule({ priority: '5' })] })).highlightRules + ok(out.length === 1 && out[0].priority === 5, `priority "5" repaired to a number (got ${out.length} rule(s))`) +} +{ + const out = (await roundTrip({ highlightRules: [rule({ enabled: 1 })] })).highlightRules + ok(out.length === 1 && out[0].enabled === true, 'enabled 1 repaired to true') +} +{ + const out = (await roundTrip({ highlightRules: [rule({ color: { bg: '#333333' } })] })).highlightRules + ok(out.length === 1 && typeof out[0].color.fg === 'string' && out[0].color.bg === '#333333', 'missing fg gets a default colour, bg kept') +} +{ + const out = (await roundTrip({ highlightRules: [rule({ priority: 500 })] })).highlightRules + ok(out.length === 1 && out[0].priority === 100, 'out-of-range priority is clamped') +} +{ + const out = (await roundTrip({ highlightRules: [rule({ note: 42, extraField: 'kept' })] })).highlightRules + ok(out.length === 1 && out[0].note === undefined && out[0].extraField === 'kept', 'non-string note dropped, unknown fields preserved') +} +{ + const out = (await roundTrip({ highlightRules: [rule(), rule({ id: undefined })] })).highlightRules + ok(out.length === 1, 'unusable entry skipped, valid one kept') +} +{ + const out = (await roundTrip({ highlightRules: [] })).highlightRules + ok(out.length === 0, 'an explicitly empty rule list stays empty') +} + +// ---- 3. warnings land in a log file ---------------------------------------- +console.log('[warnings log]') +{ + writeFileSync( + join(userData, 'settings.json'), + JSON.stringify({ terminal: { fontSize: '16' }, highlightRules: [rule({ priority: 'x' })] }), + 'utf8' + ) + store.loadSettings() + const file = join(userData, 'settings-warnings.log') + const text = existsSync(file) ? readFileSync(file, 'utf8') : '' + ok(text.includes('terminal.fontSize'), 'terminal key type mismatch is logged') + ok(text.includes('priority repaired'), 'rule repair is logged') + const size = text.length + store.loadSettings() + ok(readFileSync(file, 'utf8').length === size, 'identical warnings are not appended twice') +} + +console.log(failed === 0 ? '\n[settings] ALL CHECKS PASSED' : `\n[settings] ${failed} CHECK(S) FAILED`) +process.exit(failed === 0 ? 0 : 1)