From f6286b776c6025c444c193326aa9cd03b95384e1 Mon Sep 17 00:00:00 2001 From: Xuz Date: Wed, 7 Oct 2026 22:01:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E5=8D=87=E7=BA=A7=E4=B8=8E?= =?UTF-8?q?=E5=A4=96=E9=83=A8=20CLI=20=E7=9A=84=E6=96=87=E6=A1=88=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在线升级:状态行、标题栏按钮、更新对话框、出错原因和「重启会关闭…」改走 t! / tn!,列表用 join_list - 外部 CLI:命令输出(表头、字段、摘要、进度、错误)、应用回给命令的消息和校验、设置页的 Agent 名称都有中英两版 - --help 与 SKILL.md 提到的界面名称写英文并附中文 - 新增 locales/update.yml、locales/cli.yml,补英文输出的单元测试 Co-Authored-By: Claude Opus 5.5 --- locales/cli.yml | 392 +++++++++++++++++++++++++++++ locales/update.yml | 257 +++++++++++++++++++ src/cli/SKILL.md | 4 +- src/cli/backend.rs | 20 +- src/cli/client.rs | 449 ++++++++++++++++++++++++---------- src/cli/install.rs | 29 ++- src/cli/install_windows.rs | 3 +- src/cli/link.rs | 2 +- src/cli/manage.rs | 81 +++--- src/cli/mod.rs | 61 +++-- src/cli/server.rs | 39 ++- src/update/error.rs | 27 +- src/update/feed.rs | 15 +- src/update/install.rs | 35 +-- src/update/install_macos.rs | 35 ++- src/update/install_windows.rs | 5 +- src/update/status.rs | 208 +++++++++++----- src/update/update_dialog.rs | 61 +++-- src/update/updater.rs | 9 +- src/workspace/cli_changes.rs | 46 ++-- 20 files changed, 1398 insertions(+), 380 deletions(-) create mode 100644 locales/cli.yml create mode 100644 locales/update.yml diff --git a/locales/cli.yml b/locales/cli.yml new file mode 100644 index 0000000..0fba612 --- /dev/null +++ b/locales/cli.yml @@ -0,0 +1,392 @@ +_version: 2 + +# 外部 CLI: what the `shellrs` command prints, and the messages the app +# sends back to it. CLI output uses plain ASCII quotes and apostrophes, so +# it copies into a shell unchanged; the texts shown in the app's window +# (the settings page, notifications) use “ ” and ’ like the rest of the UI. + +# The command could not reach the app. +cli.client.not_running: + zh-CN: ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」 + en: ShellRS isn't running. Open ShellRS and turn on Enable external CLI in Settings → External CLI. +cli.client.broken: + zh-CN: 与 ShellRS 的连接意外中断:%{error} + en: "The connection to ShellRS broke off: %{error}" +cli.client.no_permission: + zh-CN: 没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信 + en: No permission to connect to ShellRS. The agent may be running in a sandbox; allow it local interprocess communication. +cli.client.foreign_pipe: + zh-CN: 外部 CLI 的管道不属于当前用户,已拒绝连接 + en: The external CLI's pipe belongs to another user, so the connection was refused. +cli.client.older_app: + zh-CN: 正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它 + en: The running ShellRS doesn't know this command. Update ShellRS to the latest version, then restart it. +# Also the app's notification when the server cannot start. +cli.unsupported: + zh-CN: 此系统暂不支持外部 CLI + en: The external CLI is not supported on this system. + +# Reading the command line and stdin. +cli.exec.empty: + zh-CN: 命令不能为空 + en: The command is empty. +cli.exec.bad_json: + zh-CN: 标准输入不是 %{form} 形式的 JSON:%{error} + en: "The input on stdin isn't JSON of the form %{form}: %{error}" +cli.json.from_terminal: + zh-CN: 请从标准输入传入 JSON,例如 %{example} + en: "Pass the JSON on stdin, for example: %{example}" +cli.json.not_json: + zh-CN: 标准输入不是 JSON:%{error} + en: "The input on stdin isn't JSON: %{error}" +cli.json.invalid: + zh-CN: JSON 有误:%{error} + en: "The JSON is invalid: %{error}" +cli.stdin.unreadable: + zh-CN: 无法读取标准输入:%{error} + en: "Couldn't read stdin: %{error}" +cli.path.unresolvable: + zh-CN: 无法解析路径 %{path}:%{error} + en: "Couldn't resolve the path %{path}: %{error}" + +# `hosts list` and `credentials list`: the column headers, upper case in +# English as command line tables have them. +cli.column.id: + zh-CN: ID + en: ID +cli.column.name: + zh-CN: 名称 + en: NAME +cli.column.group: + zh-CN: 分组 + en: GROUP +cli.column.address: + zh-CN: 地址 + en: ADDRESS +cli.column.os: + zh-CN: 系统 + en: SYSTEM +cli.column.kind: + zh-CN: 类型 + en: TYPE +cli.column.user: + zh-CN: 用户名 + en: USERNAME +cli.column.hosts: + zh-CN: 主机数 + en: HOSTS +cli.hosts.none: + zh-CN: 没有匹配的主机 + en: No matching hosts +cli.credentials.none: + zh-CN: 没有匹配的凭据 + en: No matching credentials +# The group of a host connected to without saving. +cli.group.not_saved: + zh-CN: (未保存) + en: (not saved) + +# `hosts show` and `credentials show`: the labels, then the values. +cli.field.id: + zh-CN: ID + en: ID +cli.field.name: + zh-CN: 名称 + en: Name +cli.field.group: + zh-CN: 分组 + en: Group +cli.field.address: + zh-CN: 地址 + en: Address +cli.field.auth: + zh-CN: 认证 + en: Authentication +cli.field.route: + zh-CN: 连接方式 + en: Connection +cli.field.os: + zh-CN: 系统 + en: System +cli.field.notes: + zh-CN: 备注 + en: Notes +cli.field.kind: + zh-CN: 类型 + en: Type +cli.field.user: + zh-CN: 用户名 + en: Username +cli.field.private_key: + zh-CN: 私钥 + en: Private key +cli.field.passphrase: + zh-CN: 口令 + en: Passphrase +cli.field.hosts: + zh-CN: 主机 + en: Hosts +cli.show.saved: + zh-CN: "%{label}(已保存)" + en: "%{label} (saved)" +cli.show.not_saved: + zh-CN: "%{label}(未保存)" + en: "%{label} (not saved)" +cli.auth.password: + zh-CN: 密码 + en: Password +cli.auth.credential: + zh-CN: 凭据 %{id} + en: Credential %{id} +cli.auth.no_password: + zh-CN: 无密码 + en: No password +cli.route.direct: + zh-CN: 直接连接 + en: Direct +cli.route.jump: + zh-CN: SSH 跳板 %{hosts} + en: Jump hosts %{hosts} +cli.route.deleted_host: + zh-CN: (已删除的主机) + en: (deleted host) +cli.route.http: + zh-CN: HTTP 代理 %{address} + en: HTTP proxy %{address} +cli.route.socks5: + zh-CN: SOCKS5 代理 %{address} + en: SOCKS5 proxy %{address} +cli.route.password_saved: + zh-CN: "%{proxy}(密码已保存)" + en: "%{proxy} (password saved)" +cli.route.password_not_saved: + zh-CN: "%{proxy}(密码未保存)" + en: "%{proxy} (password not saved)" +cli.kind.password: + zh-CN: 密码 + en: Password +cli.kind.key: + zh-CN: 密钥 + en: Key +cli.kind.agent: + zh-CN: SSH Agent + en: SSH Agent +cli.credential.kept_key: + zh-CN: 由 ShellRS 保存 + en: Kept by ShellRS +cli.credential.passphrase_saved: + zh-CN: 已保存 + en: Saved +cli.credential.passphrase_not_saved: + zh-CN: 未保存 + en: Not saved + +# What `create`, `update` and `delete` did. +cli.host.created: + zh-CN: 已创建主机「%{name}」,ID %{id} + en: 'Created host "%{name}", ID %{id}' +cli.host.saved: + zh-CN: 已保存主机「%{name}」(ID %{id}) + en: 'Saved host "%{name}" (ID %{id})' +cli.host.deleted: + zh-CN: 已删除主机「%{name}」 + en: 'Deleted host "%{name}"' +cli.host.deleted_with_forwards.one: + en: 'Deleted host "%{name}" and its port forwarding rule' +cli.host.deleted_with_forwards.other: + zh-CN: 已删除主机「%{name}」,连同它的 %{count} 条端口转发规则 + en: 'Deleted host "%{name}" and its %{count} port forwarding rules' +cli.host.deleted_jump_users.one: + en: "%{deleted}; 1 host jumped through it and now has a deleted jump host" +cli.host.deleted_jump_users.other: + zh-CN: "%{deleted};%{count} 台主机经它跳转,现在那一跳是「已删除的主机」" + en: "%{deleted}; %{count} hosts jumped through it and now have a deleted jump host" +cli.credential.created: + zh-CN: 已创建凭据「%{name}」,ID %{id} + en: 'Created credential "%{name}", ID %{id}' +cli.credential.saved: + zh-CN: 已保存凭据「%{name}」(ID %{id}) + en: 'Saved credential "%{name}" (ID %{id})' +cli.credential.deleted: + zh-CN: 已删除凭据「%{name}」 + en: 'Deleted credential "%{name}"' +cli.credential.deleted_released.one: + en: "%{deleted}; 1 host now logs in on its own: %{hosts}" +cli.credential.deleted_released.other: + zh-CN: "%{deleted};%{count} 台主机改为自己登录:%{hosts}" + en: "%{deleted}; %{count} hosts now log in on their own: %{hosts}" + +# `upload`, `download` and `sync`: the progress line and the summary. +cli.progress: + zh-CN: "%{files}/%{total_files} 个文件,%{bytes} / %{total_bytes}" + en: "%{files}/%{total_files} files, %{bytes} / %{total_bytes}" +cli.summary.transferred.one: + en: Transferred 1 file (%{size}) +cli.summary.transferred.other: + zh-CN: 已传输 %{count} 个文件,共 %{size} + en: Transferred %{count} files (%{size}) +cli.summary.unchanged: + zh-CN: "%{summary},未变 %{count} 个" + en: "%{summary}, %{count} unchanged" +cli.summary.skipped: + zh-CN: "%{summary},跳过 %{count} 个" + en: "%{summary}, %{count} skipped" +cli.summary.deleted: + zh-CN: "%{summary},删除 %{count} 个" + en: "%{summary}, %{count} deleted" +cli.summary.failed: + zh-CN: "%{summary},失败 %{count} 个" + en: "%{summary}, %{count} failed" + +# What the app answers when it will not take the request. +cli.server.version_mismatch: + zh-CN: shellrs 命令与正在运行的 ShellRS 版本不同:请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI + en: The shellrs command and the running ShellRS are different versions. Restart ShellRS, or update the CLI in Settings → External CLI. +cli.server.not_enabled: + zh-CN: ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」 + en: The external CLI is turned off. Turn on Enable external CLI in ShellRS under Settings → External CLI. +cli.server.closed_during_change: + zh-CN: ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成 + en: ShellRS closed while making the change. Open ShellRS again and check with shellrs hosts list whether it was made. +cli.server.change_timed_out: + zh-CN: ShellRS 没有及时处理这项改动,改动没有做:请稍后再试 + en: ShellRS didn't get to the change in time, so it wasn't made. Try again later. +cli.server.not_absolute: + zh-CN: 本地路径必须是绝对路径:%{path} + en: "The local path must be absolute: %{path}" +# The app's notification when a second ShellRS starts. +cli.server.another_app: + zh-CN: 另一个 ShellRS 已在提供外部 CLI + en: Another ShellRS is already serving the external CLI. +cli.error.host_not_found: + zh-CN: 没有 ID 为 %{id} 的主机:请用 shellrs hosts list 查看 + en: No host has the ID %{id}. List the hosts with shellrs hosts list. +cli.error.credential_not_found: + zh-CN: 没有 ID 为 %{id} 的凭据:请用 shellrs credentials list 查看 + en: No credential has the ID %{id}. List the credentials with shellrs credentials list. + +# Checking what `hosts create` / `update` ask for. +cli.host.temporary: + zh-CN: 「%{name}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可 + en: '"%{name}" isn''t saved (it came from Quick connect or an external connection), so it can''t be changed or deleted. Closing its tab is enough.' +cli.host.credential_with_own_auth: + zh-CN: auth 为 password 或 no_password 时不能同时指定 credential + en: With auth password or no_password, credential can't be given. +cli.host.no_credential: + zh-CN: 请选择凭据 + en: Choose a credential. +cli.host.credential_user: + zh-CN: 使用凭据的主机以凭据的用户名 %{user} 登录:去掉 user,或改凭据的用户名 + en: A host using a credential logs in as the credential's user, %{user}. Leave out user, or change the credential's username. +cli.host.password_without_auth: + zh-CN: 只有 auth 为 password 的主机才保存自己的密码 + en: Only a host with auth password keeps a password of its own. +cli.host.proxy_password_without_user: + zh-CN: 填写代理密码时请同时填写用户名 + en: Enter a username along with the proxy password. +cli.host.jump_temporary: + zh-CN: 跳板只能是保存的主机:%{id} 是没有保存的连接 + en: "Jump hosts must be saved hosts: %{id} isn't saved." +cli.host.jump_self: + zh-CN: 跳板主机不能是这台主机自己 + en: A host can't be its own jump host. +cli.host.jump_repeated: + zh-CN: 跳板主机 %{id} 重复了 + en: Jump host %{id} is listed twice. +cli.group.empty_level: + zh-CN: 分组路径「%{path}」里有空的一级 + en: 'The group path "%{path}" has an empty level.' +cli.group.ambiguous: + zh-CN: 有 %{count} 个分组的路径都是「%{path}」:请先在 ShellRS 里给它们改名 + en: '%{count} groups have the path "%{path}". Rename them in ShellRS first.' + +# Checking what `credentials create` / `update` ask for. +cli.credential.key_without_kind: + zh-CN: 只有密钥凭据(kind 为 key)才有私钥 + en: Only a key credential (kind key) has a private key. +cli.credential.password_without_kind: + zh-CN: 只有密码凭据(kind 为 password)才保存密码 + en: Only a password credential (kind password) keeps a password. +cli.credential.passphrase_without_kind: + zh-CN: 只有密钥凭据(kind 为 key)才有口令 + en: Only a key credential (kind key) has a passphrase. +cli.credential.two_keys: + zh-CN: key_path 和 private_key 只能给一个 + en: Give key_path or private_key, not both. +cli.credential.key_not_absolute: + zh-CN: 私钥文件必须是绝对路径:%{path} + en: "The private key file must be an absolute path: %{path}" +cli.credential.key_missing: + zh-CN: 私钥文件不存在:%{path} + en: "The private key file doesn't exist: %{path}" +cli.credential.no_key_dir: + zh-CN: 没有可以保存私钥的目录 + en: There's no folder to keep the private key in. + +# Making the change in the app. +cli.change.host_gone: + zh-CN: 主机保存后不见了 + en: The host was gone after saving. +cli.change.host_missing: + zh-CN: 主机已不存在 + en: The host no longer exists. +cli.change.host_in_use: + zh-CN: 主机「%{name}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭 + en: 'Host "%{name}" has tabs open. Close them first, or add --force to close them along with it.' +cli.change.credential_gone: + zh-CN: 凭据保存后不见了 + en: The credential was gone after saving. +cli.change.key_not_saved: + zh-CN: 私钥未能保存:%{error} + en: "Couldn't save the private key: %{error}" +# One failure after another. +cli.change.failures: + zh-CN: "%{before};%{next}" + en: "%{before}; %{next}" + +# Running commands and moving files. +cli.exec.signal: + zh-CN: 远程命令被信号 %{signal} 终止 + en: The remote command was killed by signal %{signal}. +cli.transfer.sftp_ended: + zh-CN: SFTP 连接意外结束 + en: The SFTP connection ended unexpectedly. +cli.transfer.no_local_folder: + zh-CN: 本地目录不存在:%{path} + en: "The local folder doesn't exist: %{path}" +cli.transfer.host_key_unknown: + zh-CN: 尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机 + en: This host's key isn't trusted yet. Connect to the host once in ShellRS first. +cli.transfer.host_key_changed: + zh-CN: 主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份 + en: The host key doesn't match the saved one, so the connection was refused. Check the server's identity first. +cli.transfer.missing_credential: + zh-CN: 这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码 + en: No usable password or passphrase is saved for this host. Connect to it once in ShellRS and save the password. +cli.transfer.unfinished: + zh-CN: 传输未完成 + en: The transfer didn't finish. +cli.transfer.empty_remote_path: + zh-CN: 远程路径不能为空 + en: The remote path is empty. + +# 设置 › 外部 CLI: the agents the skill installs for, and installing the +# command. +cli.agent.generic: + zh-CN: 通用 Agent + en: Any agent +cli.agent.generic_description: + zh-CN: 安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。 + en: Installs to the standard Agent Skills folder, for agents that read it. +cli.install.unsupported: + zh-CN: 此系统暂不支持安装 shellrs 命令 + en: Installing the shellrs command isn’t supported on this system. +cli.install.occupied: + zh-CN: "%{path} 已被其他程序占用,ShellRS 不会改动它" + en: "%{path} belongs to another program, so ShellRS leaves it alone" +cli.install.cancelled: + zh-CN: 已取消 + en: Cancelled +cli.install.missing: + zh-CN: 找不到 %{path} + en: Can’t find %{path} diff --git a/locales/update.yml b/locales/update.yml new file mode 100644 index 0000000..51ba3c5 --- /dev/null +++ b/locales/update.yml @@ -0,0 +1,257 @@ +_version: 2 + +# 在线升级: the status line on 设置 › 关于, the title bar button, the update +# dialog and what goes wrong. Reasons that follow a colon start in lower +# case in English, like the system's own errors they stand beside. + +# `UpdateError`, always shown after 「检查更新失败:」 and the like. +update.error.network: + zh-CN: 无法连接更新服务器:%{reason} + en: "couldn’t reach the update server: %{reason}" +update.error.http: + zh-CN: 更新服务器返回 HTTP %{status} + en: the update server answered HTTP %{status} +update.error.stalled: + zh-CN: 下载长时间没有进展,已中断 + en: the download stalled and was stopped +update.error.bad_signature: + zh-CN: 更新清单的签名无效 + en: the update manifest’s signature isn’t valid +update.error.bad_manifest: + zh-CN: 无法读取更新清单:%{reason} + en: "couldn’t read the update manifest: %{reason}" +update.error.corrupt: + zh-CN: 下载的文件与更新清单不符 + en: the downloaded file doesn’t match the update manifest +update.error.no_space: + zh-CN: 磁盘空间不足 + en: not enough disk space +update.error.disk: + zh-CN: 无法写入更新文件:%{reason} + en: "couldn’t write the update file: %{reason}" +update.error.install: + zh-CN: 无法安装新版本:%{reason} + en: "couldn’t install the new version: %{reason}" +update.error.cancelled: + zh-CN: 已取消 + en: cancelled + +# The reasons inside those errors. +update.feed.wrong_range: + zh-CN: 服务器返回的续传位置不对 + en: the server resumed at the wrong position +update.feed.cut_off: + zh-CN: 连接在下载完成前断开 + en: the connection closed before the download finished +update.feed.too_large: + zh-CN: 文件过大 + en: the file is too large +update.feed.no_urls: + zh-CN: 没有下载地址 + en: there’s no download address +update.feed.timed_out: + zh-CN: 连接超时 + en: the connection timed out +update.updater.restart_failed: + zh-CN: 无法自动重新启动,请手动重新打开 ShellRS + en: ShellRS couldn’t restart itself, so open it again +update.updater.check_stopped: + zh-CN: 检查意外中止 + en: the check stopped unexpectedly +update.install.not_possible: + zh-CN: 这份 ShellRS 无法自动更新 + en: this copy of ShellRS can’t update itself +update.install.setup_missing: + zh-CN: 找不到下载的安装程序 + en: the downloaded setup program is missing +update.install.tool_failed: + zh-CN: "%{program}:%{error}" + en: "%{program}: %{error}" +update.install.bad_signature: + zh-CN: 新版本的签名无效或不是 ShellRS 的开发者 + en: the new version’s signature isn’t valid or isn’t from ShellRS’s developer +update.install.no_app: + zh-CN: 更新包里没有应用程序 + en: the update package has no app in it +update.install.wrong_version: + zh-CN: 更新包的版本与更新清单不符 + en: the update package’s version doesn’t match the update manifest + +# Why this copy cannot replace itself: on 设置 › 关于 (with a full stop +# added) and in the update dialog. +update.unsupported.development_build: + zh-CN: 开发构建,不检查更新 + en: Development builds don’t check for updates +update.unsupported.translocated: + zh-CN: ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开 + en: ShellRS isn’t in the Applications folder, so it can’t update itself. Move it to Applications, then open it again +update.unsupported.disk_image: + zh-CN: ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹 + en: ShellRS is running from its disk image, so it can’t update itself. Drag it to the Applications folder first +update.unsupported.read_only: + zh-CN: 没有权限写入 %{folder},无法自动更新 + en: ShellRS can’t write to %{folder}, so it can’t update itself +update.unsupported.not_bundled: + zh-CN: ShellRS 不是从应用程序包运行的,无法自动更新 + en: ShellRS isn’t running from an app bundle, so it can’t update itself +update.unsupported.not_installed: + zh-CN: 这份 ShellRS 不是用安装程序安装的,无法自动更新 + en: This copy of ShellRS wasn’t installed by the setup program, so it can’t update itself +update.unsupported.not_appimage: + zh-CN: 这份 ShellRS 不是 AppImage,请用安装它的方式更新 + en: This copy of ShellRS isn’t an AppImage. Update it the way you installed it +update.unsupported.platform: + zh-CN: ShellRS 没有为这个系统提供更新 + en: ShellRS has no updates for this system + +# The line under 当前版本 on 设置 › 关于. +update.status.unsupported: + zh-CN: "%{reason}。" + en: "%{reason}." +update.status.off: + zh-CN: 不检查更新。 + en: Not checking for updates. +update.status.idle: + zh-CN: 尚未检查更新。 + en: Not checked for updates yet. +update.status.checking: + zh-CN: 正在检查更新… + en: Checking for updates… +update.status.up_to_date: + zh-CN: 当前已是最新版本。 + en: ShellRS is up to date. +update.status.available_manual: + zh-CN: 发现新版本 %{version},需要从官网下载安装。 + en: ShellRS %{version} is available. Download it from the website to install it. +update.status.available_unsupported: + zh-CN: 发现新版本 %{version}。%{reason}。 + en: ShellRS %{version} is available. %{reason}. +update.status.available: + zh-CN: 发现新版本 %{version}。 + en: ShellRS %{version} is available. +update.status.downloading: + zh-CN: 正在下载新版本 %{version}… %{percent}% + en: Downloading ShellRS %{version}… %{percent}% +update.status.verifying: + zh-CN: 正在校验新版本 %{version}… + en: Verifying ShellRS %{version}… +update.status.ready: + zh-CN: 新版本 %{version} 已下载,重启 ShellRS 即可完成更新。 + en: ShellRS %{version} is downloaded. Restart ShellRS to finish updating. +update.status.installing: + zh-CN: 正在安装新版本 %{version}… + en: Installing ShellRS %{version}… +update.status.check_failed: + zh-CN: 检查更新失败:%{error} + en: "Couldn’t check for updates: %{error}" +update.status.download_failed: + zh-CN: 下载新版本 %{version} 失败:%{error} + en: "Couldn’t download ShellRS %{version}: %{error}" +update.status.install_failed: + zh-CN: 安装新版本 %{version} 失败:%{error} + en: "Couldn’t install ShellRS %{version}: %{error}" + +# The title bar button's tooltip. +update.badge.ready: + zh-CN: 新版本 %{version} 已就绪 + en: ShellRS %{version} is ready to install +update.badge.available: + zh-CN: 新版本 %{version} 可用 + en: ShellRS %{version} is available +update.badge.download_failed: + zh-CN: 新版本 %{version} 下载失败 + en: Couldn’t download ShellRS %{version} +update.badge.install_failed: + zh-CN: 新版本 %{version} 安装失败 + en: Couldn’t install ShellRS %{version} + +# The update dialog. +update.dialog.title: + zh-CN: ShellRS 更新 + en: ShellRS update +update.dialog.versions_released: + zh-CN: 当前版本 %{current} · 新版本发布于 %{day} + en: Current version %{current} · New version released %{day} +update.dialog.current_version: + zh-CN: 当前版本 %{current} + en: Current version %{current} +update.dialog.ready: + zh-CN: 新版本已下载,重启 ShellRS 即可完成更新。 + en: The new version is downloaded. Restart ShellRS to finish updating. +update.dialog.downloading: + zh-CN: 正在下载 · %{percent}% + en: Downloading · %{percent}% +update.dialog.verifying: + zh-CN: 正在校验… + en: Verifying… +update.dialog.check_failed: + zh-CN: 检查失败:%{error} + en: "Couldn’t check for updates: %{error}" +update.dialog.download_failed: + zh-CN: 下载失败:%{error} + en: "Couldn’t download the update: %{error}" +update.dialog.install_failed: + zh-CN: 安装失败:%{error} + en: "Couldn’t install the update: %{error}" +update.dialog.manual: + zh-CN: 这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。 + en: This version can’t be installed from within ShellRS. Download it from the website. +update.dialog.restart: + zh-CN: 重启并安装 + en: Restart to update +update.dialog.download_page: + zh-CN: 前往下载页 + en: Open download page +update.dialog.retry: + zh-CN: 重试 + en: Retry +update.dialog.download: + zh-CN: 下载 + en: Download +update.dialog.whats_new: + zh-CN: 查看更新内容 + en: What’s new + +# What a restart interrupts, in the update dialog above 重启并安装. +update.restart.remote_terminals.one: + en: 1 remote terminal +update.restart.remote_terminals.other: + zh-CN: "%{count} 个远程终端" + en: "%{count} remote terminals" +update.restart.sftp_tabs.one: + en: 1 SFTP tab +update.restart.sftp_tabs.other: + zh-CN: "%{count} 个 SFTP 标签" + en: "%{count} SFTP tabs" +update.restart.local_terminals.one: + en: 1 local terminal +update.restart.local_terminals.other: + zh-CN: "%{count} 个本地终端" + en: "%{count} local terminals" +update.restart.transfers.one: + en: 1 transfer (its progress is kept) +update.restart.transfers.other: + zh-CN: "%{count} 个传输(保留续传进度)" + en: "%{count} transfers (their progress is kept)" +update.restart.forwards.one: + en: 1 port forward +update.restart.forwards.other: + zh-CN: "%{count} 条端口转发" + en: "%{count} port forwards" +update.restart.closes: + zh-CN: 重启会关闭 %{closes}。 + en: Restarting closes %{closes}. +update.restart.stops: + zh-CN: 重启会停止 %{stops}。 + en: Restarting stops %{stops}. +update.restart.closes_and_stops: + zh-CN: 重启会关闭 %{closes},停止 %{stops}。 + en: Restarting closes %{closes} and stops %{stops}. +update.restart.unsaved_files.one: + en: Unsaved changes in 1 file will be lost. +update.restart.unsaved_files.other: + zh-CN: "%{count} 个文件有未保存的修改,重启后会丢失。" + en: Unsaved changes in %{count} files will be lost. +update.restart.and_unsaved_files: + zh-CN: "%{note}%{unsaved}" + en: "%{note} %{unsaved}" diff --git a/src/cli/SKILL.md b/src/cli/SKILL.md index 27a912c..5d21417 100644 --- a/src/cli/SKILL.md +++ b/src/cli/SKILL.md @@ -21,7 +21,7 @@ shellrs download shellrs sync [--delete] ``` -`` is the 16-character `id` from `shellrs hosts list` (the same ID that ShellRS copies with 复制 ID). +`` is the 16-character `id` from `shellrs hosts list`, the same ID that ShellRS copies with Copy ID (复制 ID). For a simple command, pass one complete remote shell command string as ``. Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone: @@ -119,7 +119,7 @@ To create or update a host or credential from Windows PowerShell 5.1, pipe the J Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. Exit code 255 means `shellrs` could not run the command at all. - `not_running`: ShellRS is not open. Ask the user to open ShellRS. -- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI. +- `not_enabled`: ask the user to turn on Enable external CLI (启用外部 CLI) in ShellRS under Settings → External CLI (设置 → 外部 CLI). - `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`. - `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use. - `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`). diff --git a/src/cli/backend.rs b/src/cli/backend.rs index 6609081..63d2399 100644 --- a/src/cli/backend.rs +++ b/src/cli/backend.rs @@ -10,6 +10,7 @@ use std::{ use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary}; use super::server::{CliBackend, CliTarget}; use crate::connection::{ConnectionPromptKind, ConnectionPromptReply}; +use crate::i18n::t; use crate::secrets::{SecretRef, SharedSecretStore}; use crate::sftp::{ DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider, @@ -72,7 +73,7 @@ impl SshCliBackend { Ok(SftpEvent::Prompt(prompt)) => run.refuse(&commands, &prompt), Ok(SftpEvent::Disconnected(reason)) => return Err(run.failure(reason)), Ok(_) => {} - Err(_) => return Err(run.failure("SFTP 连接意外结束".into())), + Err(_) => return Err(run.failure(t!("cli.transfer.sftp_ended").into())), } } let _ = commands.send_blocking(command); @@ -140,7 +141,7 @@ impl CliBackend for SshCliBackend { Ok(ExecExit::Signal(signal)) => { let _ = output( ExecStream::Stderr, - format!("shellrs: 远程命令被信号 {signal} 终止\n").as_bytes(), + format!("shellrs: {}\n", t!("cli.exec.signal", signal = signal)).as_bytes(), ); Ok(signal_exit_code(&signal)) } @@ -190,7 +191,7 @@ impl CliBackend for SshCliBackend { if !source.is_dir() { return Err(CliError::new( ErrorCode::BadRequest, - format!("本地目录不存在:{}", source.display()), + t!("cli.transfer.no_local_folder", path = source.display()), )); } let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete); @@ -225,15 +226,15 @@ impl Transfer { let refused = match prompt.kind() { ConnectionPromptKind::UnknownHost(_) => CliError::new( ErrorCode::HostKeyUnknown, - "尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机", + t!("cli.transfer.host_key_unknown"), ), ConnectionPromptKind::HostKeyChanged(_) => CliError::new( ErrorCode::HostKeyChanged, - "主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份", + t!("cli.transfer.host_key_changed"), ), ConnectionPromptKind::Authentication(_) => CliError::new( ErrorCode::MissingCredential, - "这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码", + t!("cli.transfer.missing_credential"), ), }; self.refused.get_or_insert(refused); @@ -264,7 +265,7 @@ impl Transfer { ErrorCode::TransferFailed, self.notice .take() - .unwrap_or_else(|| "传输未完成".to_string()), + .unwrap_or_else(|| t!("cli.transfer.unfinished").to_string()), ) })), } @@ -287,7 +288,10 @@ fn remote_path(path: &str) -> Result { let path = path.trim(); let path = match path { "" => { - return Err(CliError::new(ErrorCode::BadRequest, "远程路径不能为空")); + return Err(CliError::new( + ErrorCode::BadRequest, + t!("cli.transfer.empty_remote_path"), + )); } "~" => ".".to_string(), _ if path.starts_with("~/") => format!(".{}", &path[1..]), diff --git a/src/cli/client.rs b/src/cli/client.rs index 2f6695b..d1a0af2 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -8,6 +8,7 @@ use std::{ time::Duration, }; +use gpui_kit::SharedString; use unicode_width::UnicodeWidthStr as _; use super::link::OpenLink; @@ -17,6 +18,7 @@ use super::protocol::{ RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json, }; use crate::host::HostOs; +use crate::i18n::{t, tn}; /// Exit code when the command could not do what was asked at all, as ssh /// uses it. @@ -100,25 +102,19 @@ impl Asked { } } -/// What an app from before a request was added says about it: it cannot -/// read the request, and serde names what it did not know. -const OLDER_APP: &str = - "正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它"; - /// Send `request` to the app listening at `endpoint` and print the answer; /// the exit code. pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 { let result = connect(endpoint).and_then(|stream| talk(&stream, request, console)); match result { Ok(code) => code, - Err(Failure::NotRunning) => console.error( - ErrorCode::NotRunning, - "ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」", - ), - Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message), + Err(Failure::NotRunning) => { + console.error(ErrorCode::NotRunning, &t!("cli.client.not_running")) + } + Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, &message), Err(Failure::Broken(error)) => console.error( ErrorCode::ConnectFailed, - &format!("与 ShellRS 的连接意外中断:{error}"), + &t!("cli.client.broken", error = error), ), } } @@ -172,7 +168,7 @@ where enum Failure { NotRunning, /// Not allowed to talk to the app, or not willing to. - Refused(&'static str), + Refused(SharedString), Broken(io::Error), } @@ -182,19 +178,20 @@ impl From for Failure { } } -/// What a sandbox that forbids local connections looks like. -const NO_PERMISSION: &str = - "没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信"; - #[cfg(unix)] fn connect(socket: &Path) -> Result { std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() { io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning, - io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION), + io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()), _ => error.into(), }) } +/// What a sandbox that forbids local connections looks like. +fn no_permission() -> SharedString { + t!("cli.client.no_permission") +} + /// Open the app's pipe, and make sure it is the app's before saying /// anything: the name is the machine's, so someone else could hold it. #[cfg(windows)] @@ -203,20 +200,18 @@ fn connect(pipe: &Path) -> Result { let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() { io::ErrorKind::NotFound => Failure::NotRunning, - io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION), + io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()), _ => error.into(), })?; if !pipe_windows::owned_by_current_user(&pipe)? { - return Err(Failure::Refused( - "外部 CLI 的管道不属于当前用户,已拒绝连接", - )); + return Err(Failure::Refused(t!("cli.client.foreign_pipe"))); } Ok(pipe) } #[cfg(not(any(unix, windows)))] fn connect(_: &Path) -> Result { - Err(Failure::Refused("此系统暂不支持外部 CLI")) + Err(Failure::Refused(t!("cli.unsupported"))) } /// Send the request and print the replies until the last one. @@ -306,7 +301,11 @@ where code: ErrorCode::BadRequest, message, } if message.contains("unknown variant") => { - return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP)); + // What an app from before the request was added + // says: serde names what it did not know. + return Ok( + console.error(ErrorCode::VersionMismatch, &t!("cli.client.older_app")) + ); } Reply::Error { code, message } => return Ok(console.error(code, &message)), } @@ -353,7 +352,7 @@ fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Resul /// Rows lined up in columns, wide characters counted as two. fn print_table( - header: [&str; N], + header: [SharedString; N], rows: &[[String; N]], console: &mut Console, ) -> io::Result<()> { @@ -379,7 +378,7 @@ fn print_table( } /// Labelled lines, the labels lined up. -fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<()> { +fn print_fields(fields: &[(SharedString, String)], console: &mut Console) -> io::Result<()> { let width = fields .iter() .map(|(label, _)| label.width()) @@ -392,12 +391,14 @@ fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result< Ok(()) } -fn saved_word(saved: Option) -> &'static str { - match saved { - Some(true) => "(已保存)", - Some(false) => "(未保存)", - None => "", - } +/// `label` saying whether its password is saved, when that is known. +fn with_saved(label: SharedString, saved: Option) -> String { + let text = match saved { + Some(true) => t!("cli.show.saved", label = label), + Some(false) => t!("cli.show.not_saved", label = label), + None => label, + }; + text.to_string() } fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> { @@ -408,80 +409,86 @@ fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Re Asked::Create => { return writeln!( console.stdout, - "已创建主机「{}」,ID {}", - host.name, host.id + "{}", + t!("cli.host.created", name = host.name, id = host.id) ); } Asked::Update => { return writeln!( console.stdout, - "已保存主机「{}」(ID {})", - host.name, host.id + "{}", + t!("cli.host.saved", name = host.name, id = host.id) ); } _ => {} } let auth = match host.auth { - AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)), - AuthChoice::Credential => { - format!("凭据 {}", host.credential.as_deref().unwrap_or_default()) - } - AuthChoice::NoPassword => "无密码".to_string(), + AuthChoice::Password => with_saved(t!("cli.auth.password"), host.password_saved), + AuthChoice::Credential => t!( + "cli.auth.credential", + id = host.credential.as_deref().unwrap_or_default() + ) + .to_string(), + AuthChoice::NoPassword => t!("cli.auth.no_password").to_string(), }; let route = match &host.route { - RouteDetails::Direct => "直接连接".to_string(), - RouteDetails::Jump { hosts } => format!( - "SSH 跳板 {}", - hosts + RouteDetails::Direct => t!("cli.route.direct").to_string(), + RouteDetails::Jump { hosts } => { + let deleted = t!("cli.route.deleted_host"); + let hosts = hosts .iter() - .map(|hop| hop.as_deref().unwrap_or("(已删除的主机)")) + .map(|hop| hop.as_deref().unwrap_or(deleted.as_str())) .collect::>() - .join(" → ") - ), + .join(" → "); + t!("cli.route.jump", hosts = hosts).to_string() + } RouteDetails::Proxy { kind, host, port, user, password_saved, - } => format!( - "{} {}{host}:{port}{}", - match kind { - ProxyChoice::Http => "HTTP 代理", - ProxyChoice::Socks5 => "SOCKS5 代理", - }, - user.as_deref() + } => { + let user = user + .as_deref() .map(|user| format!("{user}@")) - .unwrap_or_default(), - password_saved - .map(|saved| if saved { - "(密码已保存)" - } else { - "(密码未保存)" - }) - .unwrap_or_default() - ), + .unwrap_or_default(); + let address = format!("{user}{host}:{port}"); + let proxy = match kind { + ProxyChoice::Http => t!("cli.route.http", address = address), + ProxyChoice::Socks5 => t!("cli.route.socks5", address = address), + }; + let proxy = match password_saved { + Some(true) => t!("cli.route.password_saved", proxy = proxy), + Some(false) => t!("cli.route.password_not_saved", proxy = proxy), + None => proxy, + }; + proxy.to_string() + } }; let mut fields = vec![ - ("ID", host.id.clone()), - ("名称", host.name.clone()), + (t!("cli.field.id"), host.id.clone()), + (t!("cli.field.name"), host.name.clone()), ( - "分组", + t!("cli.field.group"), if host.temporary { - "(未保存)".to_string() + t!("cli.group.not_saved").to_string() } else { host.group.clone().unwrap_or_default() }, ), - ("地址", format!("{}@{}:{}", host.user, host.host, host.port)), - ("认证", auth), - ("连接方式", route), + ( + t!("cli.field.address"), + format!("{}@{}:{}", host.user, host.host, host.port), + ), + (t!("cli.field.auth"), auth), + (t!("cli.field.route"), route), ]; if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) { - fields.push(("系统", os.label().to_string())); + fields.push((t!("cli.field.os"), os.label().to_string())); } if !host.notes.is_empty() { - fields.push(("备注", host.notes.clone())); + fields.push((t!("cli.field.notes"), host.notes.clone())); } print_fields(&fields, console) } @@ -490,24 +497,31 @@ fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Resul if console.json { return print_json(deleted, console); } - let mut line = format!("已删除主机「{}」", deleted.host.name); - if deleted.forwards > 0 { - line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards)); - } + let name = &deleted.host.name; + let mut line = if deleted.forwards > 0 { + tn!( + "cli.host.deleted_with_forwards", + deleted.forwards, + name = name + ) + } else { + t!("cli.host.deleted", name = name) + }; if deleted.jump_users > 0 { - line.push_str(&format!( - ";{} 台主机经它跳转,现在那一跳是「已删除的主机」", - deleted.jump_users - )); + line = tn!( + "cli.host.deleted_jump_users", + deleted.jump_users, + deleted = line + ); } writeln!(console.stdout, "{line}") } -fn kind_label(kind: CredentialKindChoice) -> &'static str { +fn kind_label(kind: CredentialKindChoice) -> SharedString { match kind { - CredentialKindChoice::Password => "密码", - CredentialKindChoice::Key => "密钥", - CredentialKindChoice::Agent => "SSH Agent", + CredentialKindChoice::Password => t!("cli.kind.password"), + CredentialKindChoice::Key => t!("cli.kind.key"), + CredentialKindChoice::Agent => t!("cli.kind.agent"), } } @@ -516,7 +530,7 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) - return print_json(&credentials, console); } if credentials.is_empty() { - return writeln!(console.stderr, "没有匹配的凭据"); + return writeln!(console.stderr, "{}", t!("cli.credentials.none")); } let rows: Vec<[String; 5]> = credentials .iter() @@ -530,7 +544,17 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) - ] }) .collect(); - print_table(["ID", "名称", "类型", "用户名", "主机数"], &rows, console) + print_table( + [ + t!("cli.column.id"), + t!("cli.column.name"), + t!("cli.column.kind"), + t!("cli.column.user"), + t!("cli.column.hosts"), + ], + &rows, + console, + ) } fn print_credential( @@ -545,43 +569,52 @@ fn print_credential( Asked::Create => { return writeln!( console.stdout, - "已创建凭据「{}」,ID {}", - credential.name, credential.id + "{}", + t!( + "cli.credential.created", + name = credential.name, + id = credential.id + ) ); } Asked::Update => { return writeln!( console.stdout, - "已保存凭据「{}」(ID {})", - credential.name, credential.id + "{}", + t!( + "cli.credential.saved", + name = credential.name, + id = credential.id + ) ); } _ => {} } let mut fields = vec![ - ("ID", credential.id.clone()), - ("名称", credential.name.clone()), + (t!("cli.field.id"), credential.id.clone()), + (t!("cli.field.name"), credential.name.clone()), ( - "类型", - format!( - "{}{}", - kind_label(credential.kind), - saved_word(credential.password_saved) - ), + t!("cli.field.kind"), + with_saved(kind_label(credential.kind), credential.password_saved), ), - ("用户名", credential.user.clone()), + (t!("cli.field.user"), credential.user.clone()), ]; if credential.kind == CredentialKindChoice::Key { let key = match (&credential.key_path, credential.kept) { (Some(path), _) => path.clone(), - (None, _) => "由 ShellRS 保存".to_string(), + (None, _) => t!("cli.credential.kept_key").to_string(), }; - fields.push(("私钥", key)); + fields.push((t!("cli.field.private_key"), key)); if let Some(saved) = credential.passphrase_saved { - fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string())); + let saved = if saved { + t!("cli.credential.passphrase_saved") + } else { + t!("cli.credential.passphrase_not_saved") + }; + fields.push((t!("cli.field.passphrase"), saved.to_string())); } } - fields.push(("主机", credential.hosts.join(", "))); + fields.push((t!("cli.field.hosts"), credential.hosts.join(", "))); print_fields(&fields, console) } @@ -589,18 +622,20 @@ fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console) if console.json { return print_json(deleted, console); } - let mut line = format!("已删除凭据「{}」", deleted.credential.name); + let mut line = t!("cli.credential.deleted", name = deleted.credential.name); if !deleted.released.is_empty() { let names: Vec<&str> = deleted .released .iter() .map(|host| host.name.as_str()) .collect(); - line.push_str(&format!( - ";{} 台主机改为自己登录:{}", + let separator = t!("common.list.separator"); + line = tn!( + "cli.credential.deleted_released", names.len(), - names.join("、") - )); + deleted = line, + hosts = names.join(separator.as_str()) + ); } writeln!(console.stdout, "{line}") } @@ -610,7 +645,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { return print_json(&hosts, console); } if hosts.is_empty() { - return writeln!(console.stderr, "没有匹配的主机"); + return writeln!(console.stderr, "{}", t!("cli.hosts.none")); } let rows: Vec<[String; 5]> = hosts .iter() @@ -621,7 +656,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { // 临时连接 and 外部连接 alike: an agent needs no telling // them apart, only that the host is not saved. if host.temporary { - "(未保存)".to_string() + t!("cli.group.not_saved").to_string() } else { host.group.clone().unwrap_or_default() }, @@ -634,7 +669,17 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { ] }) .collect(); - print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console) + print_table( + [ + t!("cli.column.id"), + t!("cli.column.name"), + t!("cli.column.group"), + t!("cli.column.address"), + t!("cli.column.os"), + ], + &rows, + console, + ) } /// A sync's skipped items are the ones that had not changed. @@ -642,20 +687,35 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) - if console.json { print_json(summary, console)?; } else { - let mut line = format!( - "已传输 {} 个文件,共 {}", + let mut line = tn!( + "cli.summary.transferred", summary.files, - format_bytes(summary.bytes) + size = format_bytes(summary.bytes) ); if summary.skipped > 0 { - let word = if sync { "未变" } else { "跳过" }; - line.push_str(&format!(",{word} {} 个", summary.skipped)); + line = if sync { + t!( + "cli.summary.unchanged", + summary = line, + count = summary.skipped + ) + } else { + t!( + "cli.summary.skipped", + summary = line, + count = summary.skipped + ) + }; } if summary.deleted > 0 { - line.push_str(&format!(",删除 {} 个", summary.deleted)); + line = t!( + "cli.summary.deleted", + summary = line, + count = summary.deleted + ); } if summary.failed > 0 { - line.push_str(&format!(",失败 {} 个", summary.failed)); + line = t!("cli.summary.failed", summary = line, count = summary.failed); } writeln!(console.stdout, "{line}")?; for failure in &summary.failures { @@ -666,13 +726,14 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) - } fn progress_line(counters: TransferCounters) -> String { - format!( - "{}/{} 个文件,{} / {}", - counters.files, - counters.total_files, - format_bytes(counters.bytes), - format_bytes(counters.total_bytes) + t!( + "cli.progress", + files = counters.files, + total_files = counters.total_files, + bytes = format_bytes(counters.bytes), + total_bytes = format_bytes(counters.total_bytes) ) + .to_string() } fn format_bytes(bytes: u64) -> String { @@ -688,3 +749,143 @@ fn format_bytes(bytes: u64) -> String { } format!("{value:.1} {}", UNITS[unit]) } + +#[cfg(test)] +mod tests { + use super::*; + + /// What `print` writes to stdout, as on a terminal. + fn printed(print: impl FnOnce(&mut Console) -> io::Result<()>) -> String { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + print(&mut Console { + stdout: &mut stdout, + stderr: &mut stderr, + json: false, + exec_json: false, + stderr_is_terminal: false, + }) + .unwrap(); + String::from_utf8(stdout).unwrap() + } + + fn host(name: &str) -> HostDetails { + HostDetails { + id: "Jwg5rHvXCxw89paM".into(), + name: name.into(), + group: None, + host: "10.0.0.9".into(), + port: 22, + user: "root".into(), + auth: AuthChoice::Password, + credential: None, + password_saved: None, + route: RouteDetails::Direct, + notes: String::new(), + os: None, + temporary: false, + } + } + + fn deleted_host(forwards: u64, jump_users: u64) -> String { + let deleted = HostDeleted { + host: host("web"), + forwards, + jump_users, + }; + printed(|console| print_host_deleted(&deleted, console)) + } + + fn deleted_credential(released: &[&str]) -> String { + let info = |name: &&str| HostInfo { + id: String::new(), + name: name.to_string(), + group: None, + user: "root".into(), + host: "10.0.0.9".into(), + port: 22, + os: None, + temporary: false, + }; + let deleted = CredentialDeleted { + credential: CredentialDetails { + id: String::new(), + name: "deploy".into(), + kind: CredentialKindChoice::Password, + user: "deploy".into(), + key_path: None, + kept: false, + hosts: Vec::new(), + password_saved: None, + passphrase_saved: None, + }, + released: released.iter().map(info).collect(), + }; + printed(|console| print_credential_deleted(&deleted, console)) + } + + fn summary(files: u64, skipped: u64, sync: bool) -> String { + let summary = TransferSummary { + files, + bytes: 2048, + skipped, + failed: 1, + deleted: 0, + failures: Vec::new(), + }; + printed(|console| print_summary(&summary, sync, console).map(drop)) + } + + #[test] + fn what_was_done_reads_as_one_line_in_either_language() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("zh-CN"); + assert_eq!(deleted_host(0, 0), "已删除主机「web」\n"); + assert_eq!( + deleted_host(2, 3), + "已删除主机「web」,连同它的 2 条端口转发规则;\ + 3 台主机经它跳转,现在那一跳是「已删除的主机」\n" + ); + assert_eq!( + deleted_credential(&["web", "db"]), + "已删除凭据「deploy」;2 台主机改为自己登录:web、db\n" + ); + assert_eq!( + summary(2, 40, true), + "已传输 2 个文件,共 2.0 KB,未变 40 个,失败 1 个\n" + ); + + crate::i18n::set_locale("en"); + assert_eq!(deleted_host(0, 0), "Deleted host \"web\"\n"); + assert_eq!( + deleted_host(1, 1), + "Deleted host \"web\" and its port forwarding rule; \ + 1 host jumped through it and now has a deleted jump host\n" + ); + assert_eq!( + deleted_credential(&["web", "db"]), + "Deleted credential \"deploy\"; 2 hosts now log in on their own: web, db\n" + ); + assert_eq!( + summary(1, 4, false), + "Transferred 1 file (2.0 KB), 4 skipped, 1 failed\n" + ); + } + + #[test] + fn an_english_show_lines_up_its_labels() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + let mut shown = host("web"); + shown.password_saved = Some(true); + let shown = printed(|console| print_host(&shown, Asked::Show, console)); + assert!( + shown.contains("\nAuthentication Password (saved)\n"), + "{shown}" + ); + assert!(shown.contains("\nConnection Direct\n"), "{shown}"); + assert!( + shown.starts_with("ID Jwg5rHvXCxw89paM\n"), + "{shown}" + ); + } +} diff --git a/src/cli/install.rs b/src/cli/install.rs index 7054026..9fb79c3 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -7,6 +7,10 @@ use std::path::Path; use std::{fs, io, path::PathBuf}; +use gpui_kit::SharedString; + +use crate::i18n::t; + /// The skill that teaches an agent to use `shellrs`. pub const SKILL: &str = include_str!("SKILL.md"); @@ -36,19 +40,19 @@ impl AgentKind { AgentKind::WorkBuddy, ]; - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - AgentKind::Generic => "通用 Agent", - AgentKind::Codex => "Codex", - AgentKind::ClaudeCode => "Claude Code", - AgentKind::OpenCode => "OpenCode", - AgentKind::WorkBuddy => "WorkBuddy", + AgentKind::Generic => t!("cli.agent.generic"), + AgentKind::Codex => "Codex".into(), + AgentKind::ClaudeCode => "Claude Code".into(), + AgentKind::OpenCode => "OpenCode".into(), + AgentKind::WorkBuddy => "WorkBuddy".into(), } } - pub fn description(self) -> Option<&'static str> { + pub fn description(self) -> Option { match self { - AgentKind::Generic => Some("安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。"), + AgentKind::Generic => Some(t!("cli.agent.generic_description")), _ => None, } } @@ -277,7 +281,7 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> { #[cfg(not(unix))] Err(io::Error::new( io::ErrorKind::Unsupported, - "此系统暂不支持安装 shellrs 命令", + t!("cli.install.unsupported").to_string(), )) } @@ -300,7 +304,7 @@ fn remove_link(paths: &IntegrationPaths) -> io::Result<()> { fn occupied(link: &Path) -> io::Error { io::Error::new( io::ErrorKind::AlreadyExists, - format!("{} 已被其他程序占用,ShellRS 不会改动它", link.display()), + t!("cli.install.occupied", path = link.display()).to_string(), ) } @@ -323,7 +327,10 @@ fn run_as_administrator(command: &str) -> io::Result<()> { let message = String::from_utf8_lossy(&output.stderr); // AppleScript's "User canceled." is error -128. if message.contains("-128") { - return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消")); + return Err(io::Error::new( + io::ErrorKind::Interrupted, + t!("cli.install.cancelled").to_string(), + )); } Err(io::Error::other(message.trim().to_string())) } diff --git a/src/cli/install_windows.rs b/src/cli/install_windows.rs index f017861..d32cce2 100644 --- a/src/cli/install_windows.rs +++ b/src/cli/install_windows.rs @@ -12,6 +12,7 @@ use std::{ }; use super::install::{BinaryStatus, IntegrationPaths, UserPath}; +use crate::i18n::t; pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus { let source = fs::metadata(&paths.exe).ok(); @@ -56,7 +57,7 @@ pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> { if !paths.exe.exists() { return Err(io::Error::new( io::ErrorKind::NotFound, - format!("找不到 {}", paths.exe.display()), + t!("cli.install.missing", path = paths.exe.display()).to_string(), )); } let folder = folder(paths); diff --git a/src/cli/link.rs b/src/cli/link.rs index 037b4f0..f247603 100644 --- a/src/cli/link.rs +++ b/src/cli/link.rs @@ -46,7 +46,7 @@ fn without_password(url: &str) -> String { } parsed.to_string() } - Err(_) => "<无法解析的链接>".into(), + Err(_) => "".into(), } } diff --git a/src/cli/manage.rs b/src/cli/manage.rs index 8e3d5b6..6f5eb74 100644 --- a/src/cli/manage.rs +++ b/src/cli/manage.rs @@ -9,6 +9,7 @@ use crate::host::{ AuthKind, Credential, CredentialDraft, CredentialId, CredentialKind, GroupId, Host, HostDraft, HostId, HostStore, PastedKey, ProxyKind, ProxySettings, Route, }; +use crate::i18n::t; use crate::secrets::SecretRef; use super::protocol::{ @@ -202,9 +203,10 @@ pub fn with_saved_credential_secrets( /// The credential search: its name, user, kind or ID. pub fn credential_matches(details: &CredentialDetails, query: &str) -> bool { let needle = query.trim().to_lowercase(); + // Both languages' words, whichever the agent or its user types. let kind = match details.kind { - CredentialKindChoice::Password => "password 密码", - CredentialKindChoice::Key => "key 密钥", + CredentialKindChoice::Password => "password 密码", // i18n: keep + CredentialKindChoice::Key => "key 密钥", // i18n: keep CredentialKindChoice::Agent => "agent ssh agent", }; needle.is_empty() @@ -218,10 +220,17 @@ fn bad(message: impl Into) -> CliError { CliError::new(ErrorCode::BadRequest, message) } -fn host_not_found(id: &str) -> CliError { +pub(super) fn host_not_found(id: &str) -> CliError { CliError::new( ErrorCode::HostNotFound, - format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"), + t!("cli.error.host_not_found", id = id), + ) +} + +pub(super) fn credential_not_found(id: &str) -> CliError { + CliError::new( + ErrorCode::CredentialNotFound, + t!("cli.error.credential_not_found", id = id), ) } @@ -239,10 +248,7 @@ pub fn find_host<'a>(store: &'a HostStore, id: &str) -> Result<&'a Host, CliErro pub fn find_saved_host(store: &HostStore, id: &str) -> Result { let host = find_host(store, id)?; if store.is_temporary(host.id) { - return Err(bad(format!( - "「{}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可", - host.name - ))); + return Err(bad(t!("cli.host.temporary", name = host.name))); } Ok(host.id) } @@ -252,12 +258,7 @@ pub fn find_credential<'a>(store: &'a HostStore, id: &str) -> Result<&'a Credent .credentials() .iter() .find(|credential| credential.keychain_id.as_str() == id.trim()) - .ok_or_else(|| { - CliError::new( - ErrorCode::CredentialNotFound, - format!("没有 ID 为 {id} 的凭据:请用 shellrs credentials list 查看"), - ) - }) + .ok_or_else(|| credential_not_found(id)) } /// Where a host goes in the tree. @@ -359,9 +360,7 @@ pub fn plan_host( }; let credential = match (fields.auth, named) { (Some(AuthChoice::Password | AuthChoice::NoPassword), Some(_)) => { - return Err(bad( - "auth 为 password 或 no_password 时不能同时指定 credential", - )); + return Err(bad(t!("cli.host.credential_with_own_auth"))); } (Some(AuthChoice::Password | AuthChoice::NoPassword), None) => None, (_, Some(named)) => Some(named), @@ -369,15 +368,12 @@ pub fn plan_host( (_, None) => draft.credential.and_then(|id| store.credential(id)), }; match (fields.auth, credential) { - (Some(AuthChoice::Credential), None) => return Err(bad("请选择凭据")), + (Some(AuthChoice::Credential), None) => return Err(bad(t!("cli.host.no_credential"))), (_, Some(credential)) => { if let Some(user) = &fields.user && user.trim() != credential.user.as_ref() { - return Err(bad(format!( - "使用凭据的主机以凭据的用户名 {} 登录:去掉 user,或改凭据的用户名", - credential.user - ))); + return Err(bad(t!("cli.host.credential_user", user = credential.user))); } draft.credential = Some(credential.id); draft.user = credential.user.clone(); @@ -401,7 +397,7 @@ pub fn plan_host( } let own_password = draft.credential.is_none() && draft.auth == AuthKind::Password; if !own_password && matches!(fields.password, Some(Some(_))) { - return Err(bad("只有 auth 为 password 的主机才保存自己的密码")); + return Err(bad(t!("cli.host.password_without_auth"))); } // The proxy password field, when the route was given at all. @@ -419,7 +415,7 @@ pub fn plan_host( } => { let user = user.unwrap_or_default(); if user.trim().is_empty() && matches!(password, Some(Some(_))) { - return Err(bad("填写代理密码时请同时填写用户名")); + return Err(bad(t!("cli.host.proxy_password_without_user"))); } proxy_field = Some(password); let kind = match kind { @@ -475,13 +471,13 @@ fn jump_hosts( }; let hop = find_host(store, id)?; if store.is_temporary(hop.id) { - return Err(bad(format!("跳板只能是保存的主机:{id} 是没有保存的连接"))); + return Err(bad(t!("cli.host.jump_temporary", id = id))); } if Some(hop.id) == own { - return Err(bad("跳板主机不能是这台主机自己")); + return Err(bad(t!("cli.host.jump_self"))); } if hops.contains(&Some(hop.id)) { - return Err(bad(format!("跳板主机 {id} 重复了"))); + return Err(bad(t!("cli.host.jump_repeated", id = id))); } hops.push(Some(hop.id)); } @@ -498,14 +494,11 @@ fn resolve_group(store: &HostStore, path: &str) -> Result { } let names: Vec<&str> = path.split('/').map(str::trim).collect(); if names.iter().any(|name| name.is_empty()) { - return Err(bad(format!("分组路径「{path}」里有空的一级"))); + return Err(bad(t!("cli.group.empty_level", path = path))); } let wanted = names.join("/"); - let ambiguous = |count: usize, path: &str| { - bad(format!( - "有 {count} 个分组的路径都是「{path}」:请先在 ShellRS 里给它们改名" - )) - }; + let ambiguous = + |count: usize, path: &str| bad(t!("cli.group.ambiguous", count = count, path = path)); let whole: Vec = store .groups() .iter() @@ -587,22 +580,25 @@ pub fn plan_credential( draft.user = user.into(); } if gives_key && kind != CredentialKind::Key { - return Err(bad("只有密钥凭据(kind 为 key)才有私钥")); + return Err(bad(t!("cli.credential.key_without_kind"))); } if kind != CredentialKind::Password && matches!(fields.password, Some(Some(_))) { - return Err(bad("只有密码凭据(kind 为 password)才保存密码")); + return Err(bad(t!("cli.credential.password_without_kind"))); } if kind != CredentialKind::Key && matches!(fields.passphrase, Some(Some(_))) { - return Err(bad("只有密钥凭据(kind 为 key)才有口令")); + return Err(bad(t!("cli.credential.passphrase_without_kind"))); } let private_key = match (fields.key_path, fields.private_key) { - (Some(_), Some(_)) => return Err(bad("key_path 和 private_key 只能给一个")), + (Some(_), Some(_)) => return Err(bad(t!("cli.credential.two_keys"))), (Some(path), None) => { if !path.is_absolute() { - return Err(bad(format!("私钥文件必须是绝对路径:{}", path.display()))); + return Err(bad(t!( + "cli.credential.key_not_absolute", + path = path.display() + ))); } if !Path::new(&path).is_file() { - return Err(bad(format!("私钥文件不存在:{}", path.display()))); + return Err(bad(t!("cli.credential.key_missing", path = path.display()))); } draft = draft.with_key_path(path.display().to_string()); None @@ -612,11 +608,12 @@ pub fn plan_credential( if store.key_dir().is_none() { return Err(CliError::new( ErrorCode::SaveFailed, - "没有可以保存私钥的目录", + t!("cli.credential.no_key_dir"), )); } - // Stands in until the key file is written, so the draft passes. - draft = draft.with_key_path("(新私钥)"); + // Stands in until the key file is written, so the draft passes; + // never saved or shown. + draft = draft.with_key_path("(pasted key)"); Some(pasted) } (None, None) => None, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8f06eb7..64af145 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -26,6 +26,8 @@ use std::{ use clap::{Parser, Subcommand}; +use crate::i18n::t; + pub use backend::SshCliBackend; pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app}; pub use install::{ @@ -48,12 +50,17 @@ pub use protocol::{ }; pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse}; -const AFTER_HELP: &str = "\ -Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID). -ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on. - -Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures; -255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`)."; +// `--help` is English whatever the interface language, so it names the +// settings it points to in both. +const AFTER_HELP: &str = concat!( + "Hosts are named by the 16-character ID that `shellrs hosts list` prints, ", + "the one ShellRS copies with Copy ID (复制 ID).\n", // i18n: keep + "ShellRS must be running, with Settings → External CLI → Enable external CLI ", + "(设置 → 外部 CLI → 启用外部 CLI) turned on.\n", // i18n: keep + "\n", + "Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;\n", + "255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).", +); const HOST_FIELDS: &str = "\ Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given. @@ -61,7 +68,7 @@ Reads one JSON object from stdin. Every field may be left out; `update` changes host The address (IP or host name). Required to create. Also read as `address`. port Default 22. user Default root. A host using a credential logs in as the credential's user. - group A group path such as \"生产/数据库\", made when it is not there; null for the top level. + group A group path such as \"Production/Databases\", made when it is not there; null for the top level. auth \"password\", \"credential\" or \"no_password\". credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied). password The host's own password, saved to the keychain and never printed; null deletes it. @@ -332,7 +339,7 @@ fn request(command: Command, console: &mut Console) -> Result { }; let command = normalize_command(&command); if command.trim().is_empty() { - return Err(console.error(ErrorCode::BadRequest, "命令不能为空")); + return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty"))); } Request::Exec { host, command } } @@ -450,7 +457,7 @@ fn read_json(console: &mut Console, example: &str) -> Result Result Result { let value = read_json(console, example)?; - manage::host_fields(value) - .map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}"))) + manage::host_fields(value).map_err(|error| { + console.error( + ErrorCode::BadRequest, + &t!("cli.json.invalid", error = error), + ) + }) } /// A key file named relative to here, or to the home directory, is sent /// as the absolute path the app can find. fn credential_fields(console: &mut Console, example: &str) -> Result { let value = read_json(console, example)?; - let mut fields = manage::credential_fields(value) - .map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))?; + let mut fields = manage::credential_fields(value).map_err(|error| { + console.error( + ErrorCode::BadRequest, + &t!("cli.json.invalid", error = error), + ) + })?; if let Some(path) = fields.key_path.take() { let path = match path.strip_prefix("~") { Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path), @@ -490,7 +505,10 @@ fn read_stdin(console: &mut Console) -> Result { std::io::stdin() .read_to_string(&mut text) .map_err(|error| { - console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}")) + console.error( + ErrorCode::BadRequest, + &t!("cli.stdin.unreadable", error = error), + ) })?; Ok(text) } @@ -506,7 +524,12 @@ struct ExecJson { fn exec_request(text: &str) -> Result<(String, String), String> { let text = text.strip_prefix('\u{feff}').unwrap_or(text); let request: ExecJson = serde_json::from_str(text).map_err(|error| { - format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}") + t!( + "cli.exec.bad_json", + form = r#"{"host": ..., "command": ...}"#, + error = error + ) + .to_string() })?; Ok((request.host, request.command)) } @@ -526,7 +549,11 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result { std::path::absolute(&path).map_err(|error| { console.error( ErrorCode::BadRequest, - &format!("无法解析路径 {}:{error}", path.display()), + &t!( + "cli.path.unresolvable", + path = path.display(), + error = error + ), ) }) } diff --git a/src/cli/server.rs b/src/cli/server.rs index c02f9b9..6210d88 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -21,8 +21,8 @@ use tokio::sync::watch; use super::link::OpenLink; use super::manage::{ CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches, - credential_secrets, host_details, host_info, host_secrets, with_saved_credential_secrets, - with_saved_passwords, + credential_not_found, credential_secrets, host_details, host_info, host_not_found, + host_secrets, with_saved_credential_secrets, with_saved_passwords, }; use super::protocol::{ CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo, @@ -30,6 +30,7 @@ use super::protocol::{ read_frame, write_frame, write_json, }; use crate::host::{Host, HostLogin, HostStore, matches_query}; +use crate::i18n::t; use crate::secrets::SecretRef; use crate::ssh::ExecStream; @@ -538,12 +539,15 @@ struct Listener; fn listen(_: PathBuf, _: Arc) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, - "此系统暂不支持外部 CLI", + t!("cli.unsupported").to_string(), )) } fn another_app() -> io::Error { - io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI") + io::Error::new( + io::ErrorKind::AddrInUse, + t!("cli.server.another_app").to_string(), + ) } /// Read one request and answer it. @@ -593,15 +597,14 @@ fn respond( if version.version != PROTOCOL_VERSION { return Err(CliError::new( ErrorCode::VersionMismatch, - "shellrs 命令与正在运行的 ShellRS 版本不同:\ - 请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI", + t!("cli.server.version_mismatch"), )); } let envelope = envelope.map_err(bad_request)?; if !shared.enabled.load(Ordering::Acquire) { return Err(CliError::new( ErrorCode::NotEnabled, - "ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」", + t!("cli.server.not_enabled"), )); } if let Some(usage) = CliUse::of(&envelope.request) { @@ -718,14 +721,7 @@ fn respond( .iter() .find(|known| known.details.id == credential.trim()) .cloned() - .ok_or_else(|| { - CliError::new( - ErrorCode::CredentialNotFound, - format!( - "没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看" - ), - ) - })?; + .ok_or_else(|| credential_not_found(&credential))?; let saved = |secret: &SecretRef| shared.backend.is_saved(secret); Ok(Reply::Credential(with_saved_credential_secrets( found.details, @@ -758,7 +754,7 @@ fn change(shared: &Shared, change: CliChange) -> Result { let gone = || { CliError::new( ErrorCode::ConnectFailed, - "ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成", + t!("cli.server.closed_during_change"), ) }; let (sender, outcome) = mpsc::channel(); @@ -786,7 +782,7 @@ fn change(shared: &Shared, change: CliChange) -> Result { changes.remove(index); return Err(CliError::new( ErrorCode::ConnectFailed, - "ShellRS 没有及时处理这项改动,改动没有做:请稍后再试", + t!("cli.server.change_timed_out"), )); } } @@ -802,17 +798,12 @@ fn find(shared: &Shared, id: &str) -> Result { .iter() .find(|target| target.info.id == id.trim()) .cloned() - .ok_or_else(|| { - CliError::new( - ErrorCode::HostNotFound, - format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"), - ) - }) + .ok_or_else(|| host_not_found(id)) } fn not_absolute(path: &Path) -> CliError { CliError::new( ErrorCode::BadRequest, - format!("本地路径必须是绝对路径:{}", path.display()), + t!("cli.server.not_absolute", path = path.display()), ) } diff --git a/src/update/error.rs b/src/update/error.rs index 8c15a44..85d839b 100644 --- a/src/update/error.rs +++ b/src/update/error.rs @@ -3,6 +3,8 @@ use std::fmt; +use crate::i18n::t; + #[derive(Clone, Debug, PartialEq, Eq)] pub enum UpdateError { /// The server could not be reached, or the transfer broke off. @@ -30,18 +32,19 @@ pub enum UpdateError { impl fmt::Display for UpdateError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Network(reason) => write!(f, "无法连接更新服务器:{reason}"), - Self::Http(status) => write!(f, "更新服务器返回 HTTP {status}"), - Self::Stalled => f.write_str("下载长时间没有进展,已中断"), - Self::BadSignature => f.write_str("更新清单的签名无效"), - Self::BadManifest(reason) => write!(f, "无法读取更新清单:{reason}"), - Self::Corrupt => f.write_str("下载的文件与更新清单不符"), - Self::NoSpace => f.write_str("磁盘空间不足"), - Self::Disk(reason) => write!(f, "无法写入更新文件:{reason}"), - Self::Install(reason) => write!(f, "无法安装新版本:{reason}"), - Self::Cancelled => f.write_str("已取消"), - } + let text = match self { + Self::Network(reason) => t!("update.error.network", reason = reason), + Self::Http(status) => t!("update.error.http", status = status), + Self::Stalled => t!("update.error.stalled"), + Self::BadSignature => t!("update.error.bad_signature"), + Self::BadManifest(reason) => t!("update.error.bad_manifest", reason = reason), + Self::Corrupt => t!("update.error.corrupt"), + Self::NoSpace => t!("update.error.no_space"), + Self::Disk(reason) => t!("update.error.disk", reason = reason), + Self::Install(reason) => t!("update.error.install", reason = reason), + Self::Cancelled => t!("update.error.cancelled"), + }; + f.write_str(&text) } } diff --git a/src/update/feed.rs b/src/update/feed.rs index 2b3fc8e..5c8746e 100644 --- a/src/update/feed.rs +++ b/src/update/feed.rs @@ -17,6 +17,7 @@ use reqwest::header::{CONTENT_RANGE, RANGE}; use super::build_info::{self, Channel}; use super::error::UpdateError; use super::platform; +use crate::i18n::t; /// Where manifests and packages come from. pub trait UpdateFeed: Send + Sync { @@ -127,7 +128,9 @@ impl HttpFeed { if range_start(&response) != Some(have) { // Not the bytes that were asked for: start over next time. fs::remove_file(part)?; - return Err(UpdateError::Network("服务器返回的续传位置不对".into())); + return Err(UpdateError::Network( + t!("update.feed.wrong_range").to_string(), + )); } OpenOptions::new().append(true).open(part)? } @@ -159,7 +162,7 @@ impl HttpFeed { } file.sync_all().map_err(write_error)?; if have < size { - return Err(UpdateError::Network("连接在下载完成前断开".into())); + return Err(UpdateError::Network(t!("update.feed.cut_off").to_string())); } Ok(()) } @@ -182,7 +185,9 @@ impl UpdateFeed for HttpFeed { .read_to_end(&mut body) .map_err(read_error)?; if body.len() as u64 > MANIFEST_LIMIT { - return Err(UpdateError::BadManifest("文件过大".into())); + return Err(UpdateError::BadManifest( + t!("update.feed.too_large").to_string(), + )); } Ok(body) } @@ -197,7 +202,7 @@ impl UpdateFeed for HttpFeed { ) -> Result<(), UpdateError> { let client = self.client()?; let part = part_path(dest); - let mut last = UpdateError::Network("没有下载地址".into()); + let mut last = UpdateError::Network(t!("update.feed.no_urls").to_string()); for url in urls { match self.download_from(&client, url, size, &part, progress, cancel) { Ok(()) => { @@ -257,7 +262,7 @@ fn write_error(error: io::Error) -> UpdateError { /// URL, the reason is at the end of its chain. fn describe(error: &reqwest::Error) -> String { if error.is_timeout() { - return "连接超时".into(); + return t!("update.feed.timed_out").to_string(); } let mut source: &dyn std::error::Error = error; while let Some(next) = source.source() { diff --git a/src/update/install.rs b/src/update/install.rs index e65cc4a..7792fd6 100644 --- a/src/update/install.rs +++ b/src/update/install.rs @@ -18,6 +18,7 @@ use semver::Version; use super::error::UpdateError; use super::manifest::Release; +use crate::i18n::t; /// Where this ShellRS runs from, as far as updating goes. #[derive(Clone, Debug, PartialEq, Eq)] @@ -62,23 +63,19 @@ pub enum Unsupported { impl Unsupported { /// Why, for 设置 › 关于 and the update dialog. pub fn reason(&self) -> String { - match self { - Self::DevelopmentBuild => "开发构建,不检查更新".into(), - Self::Translocated => { - "ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开" - .into() - } - Self::DiskImage => { - "ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹".into() - } + let reason = match self { + Self::DevelopmentBuild => t!("update.unsupported.development_build"), + Self::Translocated => t!("update.unsupported.translocated"), + Self::DiskImage => t!("update.unsupported.disk_image"), Self::ReadOnly(folder) => { - format!("没有权限写入 {},无法自动更新", folder.display()) + t!("update.unsupported.read_only", folder = folder.display()) } - Self::NotBundled => "ShellRS 不是从应用程序包运行的,无法自动更新".into(), - Self::NotInstalled => "这份 ShellRS 不是用安装程序安装的,无法自动更新".into(), - Self::NotAppImage => "这份 ShellRS 不是 AppImage,请用安装它的方式更新".into(), - Self::Platform => "ShellRS 没有为这个系统提供更新".into(), - } + Self::NotBundled => t!("update.unsupported.not_bundled"), + Self::NotInstalled => t!("update.unsupported.not_installed"), + Self::NotAppImage => t!("update.unsupported.not_appimage"), + Self::Platform => t!("update.unsupported.platform"), + }; + reason.to_string() } } @@ -229,11 +226,15 @@ impl Installer for NoInstaller { } fn stage(&self, _: &Path, _: &Release) -> Result { - Err(UpdateError::Install("这份 ShellRS 无法自动更新".into())) + Err(UpdateError::Install( + t!("update.install.not_possible").to_string(), + )) } fn apply(&self, _: &Staged, _: bool) -> Result { - Err(UpdateError::Install("这份 ShellRS 无法自动更新".into())) + Err(UpdateError::Install( + t!("update.install.not_possible").to_string(), + )) } fn clean_up(&self) {} diff --git a/src/update/install_macos.rs b/src/update/install_macos.rs index 9cd3e8d..097e6ca 100644 --- a/src/update/install_macos.rs +++ b/src/update/install_macos.rs @@ -18,6 +18,7 @@ use super::build_info::APPLE_TEAM_ID; use super::error::UpdateError; use super::install::{InstallKind, Installer, Relaunch, Staged, sibling}; use super::manifest::Release; +use crate::i18n::t; /// The macOS tools staging relies on, behind a seam for the tests. pub trait MacTools: Send + Sync { @@ -37,18 +38,28 @@ pub struct SystemTools; impl SystemTools { fn run(program: &str, args: &[&OsStr]) -> Result { - let output = Command::new(program) - .args(args) - .output() - .map_err(|error| UpdateError::Install(format!("{program}:{error}")))?; + let output = Command::new(program).args(args).output().map_err(|error| { + UpdateError::Install( + t!( + "update.install.tool_failed", + program = program, + error = error + ) + .to_string(), + ) + })?; if output.status.success() { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } else { let stderr = String::from_utf8_lossy(&output.stderr); - Err(UpdateError::Install(format!( - "{program}:{}", - stderr.trim() - ))) + Err(UpdateError::Install( + t!( + "update.install.tool_failed", + program = program, + error = stderr.trim() + ) + .to_string(), + )) } } } @@ -94,7 +105,7 @@ impl MacTools for SystemTools { args.push(bundle.as_os_str()); Self::run("/usr/bin/codesign", &args) .map(drop) - .map_err(|_| UpdateError::Install("新版本的签名无效或不是 ShellRS 的开发者".into())) + .map_err(|_| UpdateError::Install(t!("update.install.bad_signature").to_string())) } } @@ -151,9 +162,11 @@ impl MacInstaller { .flatten() .map(|entry| entry.path()) .find(|path| path.extension().is_some_and(|extension| extension == "app")) - .ok_or_else(|| UpdateError::Install("更新包里没有应用程序".into()))?; + .ok_or_else(|| UpdateError::Install(t!("update.install.no_app").to_string()))?; if self.tools.bundle_version(&app)? != release.version.to_string() { - return Err(UpdateError::Install("更新包的版本与更新清单不符".into())); + return Err(UpdateError::Install( + t!("update.install.wrong_version").to_string(), + )); } self.tools.check_signature(&app, self.team.as_deref())?; Ok(app) diff --git a/src/update/install_windows.rs b/src/update/install_windows.rs index f168d4e..9322535 100644 --- a/src/update/install_windows.rs +++ b/src/update/install_windows.rs @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf}; use super::error::UpdateError; use super::install::{InstallKind, Installer, Launch, Relaunch, Staged}; use super::manifest::Release; +use crate::i18n::t; /// Waits for ShellRS to exit, runs the setup, then starts ShellRS again if /// asked. Values come in through the environment, not the script's text, @@ -85,7 +86,9 @@ impl Installer for SetupInstaller { fn apply(&self, staged: &Staged, relaunch: bool) -> Result { if !staged.path.is_file() { - return Err(UpdateError::Install("找不到下载的安装程序".into())); + return Err(UpdateError::Install( + t!("update.install.setup_missing").to_string(), + )); } Ok(Relaunch::Hand(waiter( std::process::id(), diff --git a/src/update/status.rs b/src/update/status.rs index c655ac7..0e82384 100644 --- a/src/update/status.rs +++ b/src/update/status.rs @@ -2,7 +2,10 @@ //! button, which buttons apply, and what a restart interrupts. Pure, so //! every wording is tested without a window. +use gpui_kit::SharedString; + use super::updater::{Phase, Stage, UpdateSnapshot}; +use crate::i18n::{join_list, t, tn}; /// The title bar's update button. #[derive(Clone, Debug, PartialEq, Eq)] @@ -52,51 +55,76 @@ impl UpdateSnapshot { /// The line under 当前版本 on 设置 › 关于. pub fn status_line(&self) -> (String, Tone) { - let offered = self.offered_version().unwrap_or_default(); - match &self.phase { + let version = self.offered_version().unwrap_or_default(); + let version = version.as_str(); + let (text, tone) = match &self.phase { Phase::Off => ( - format!( - "{}。", - self.unsupported - .as_ref() - .map(|reason| reason.reason()) - .unwrap_or_else(|| "不检查更新".into()) - ), + match &self.unsupported { + Some(reason) => t!("update.status.unsupported", reason = reason.reason()), + None => t!("update.status.off"), + }, Tone::Plain, ), - Phase::Idle => ("尚未检查更新。".into(), Tone::Plain), - Phase::Checking => ("正在检查更新…".into(), Tone::Plain), - Phase::UpToDate => ("当前已是最新版本。".into(), Tone::Plain), + Phase::Idle => (t!("update.status.idle"), Tone::Plain), + Phase::Checking => (t!("update.status.checking"), Tone::Plain), + Phase::UpToDate => (t!("update.status.up_to_date"), Tone::Plain), Phase::Available if self.manual.is_some() => ( - format!("发现新版本 {offered},需要从官网下载安装。"), + t!("update.status.available_manual", version = version), Tone::Warning, ), Phase::Available => match &self.unsupported { Some(reason) => ( - format!("发现新版本 {offered}。{}。", reason.reason()), + t!( + "update.status.available_unsupported", + version = version, + reason = reason.reason() + ), Tone::Warning, ), - None => (format!("发现新版本 {offered}。"), Tone::Plain), + None => ( + t!("update.status.available", version = version), + Tone::Plain, + ), }, Phase::Downloading { done, total } => ( - format!("正在下载新版本 {offered}… {}%", percent(*done, *total)), + t!( + "update.status.downloading", + version = version, + percent = percent(*done, *total) + ), Tone::Plain, ), - Phase::Verifying => (format!("正在校验新版本 {offered}…"), Tone::Plain), - Phase::Ready => ( - format!("新版本 {offered} 已下载,重启 ShellRS 即可完成更新。"), + Phase::Verifying => ( + t!("update.status.verifying", version = version), + Tone::Plain, + ), + Phase::Ready => (t!("update.status.ready", version = version), Tone::Plain), + Phase::Installing => ( + t!("update.status.installing", version = version), Tone::Plain, ), - Phase::Installing => (format!("正在安装新版本 {offered}…"), Tone::Plain), Phase::Failed { stage, error } => ( match stage { - Stage::Check => format!("检查更新失败:{error}"), - Stage::Download => format!("下载新版本 {offered} 失败:{error}"), - Stage::Install => format!("安装新版本 {offered} 失败:{error}"), + Stage::Check => t!("update.status.check_failed", error = error), + Stage::Download => { + t!( + "update.status.download_failed", + version = version, + error = error + ) + } + Stage::Install => { + t!( + "update.status.install_failed", + version = version, + error = error + ) + } }, Tone::Danger, ), - } + }; + (text.to_string(), tone) } /// What the version on offer needs next, for the button next to the @@ -119,24 +147,26 @@ impl UpdateSnapshot { /// version ready to install, one that has to be downloaded by hand, or /// automatic downloads that keep failing. pub fn badge(&self) -> Option { - let offered = self.offered_version()?; + let version = self.offered_version()?; + let version = version.as_str(); let (label, trouble) = match &self.phase { - Phase::Ready => (format!("新版本 {offered} 已就绪"), false), + Phase::Ready => (t!("update.badge.ready", version = version), false), Phase::Available if self.needs_download_page() => { - (format!("新版本 {offered} 可用"), false) + (t!("update.badge.available", version = version), false) } Phase::Failed { stage: Stage::Download, .. } if self.download_failures >= super::updater::FAILURES_BEFORE_BADGE => { - (format!("新版本 {offered} 下载失败"), true) + (t!("update.badge.download_failed", version = version), true) } Phase::Failed { stage: Stage::Install, .. - } => (format!("新版本 {offered} 安装失败"), true), + } => (t!("update.badge.install_failed", version = version), true), _ => return None, }; + let label = label.to_string(); Some(UpdateBadge { label, trouble }) } @@ -185,45 +215,44 @@ pub struct RestartImpact { /// 「重启会关闭 2 个远程终端和 1 个 SFTP 标签,停止 1 个传输(保留续传进度)。」, /// or nothing when a restart interrupts nothing. pub fn restart_note(impact: RestartImpact) -> Option { - let closes = join(&[ - (impact.remote_terminals, "个远程终端"), - (impact.sftp_tabs, "个 SFTP 标签"), - (impact.local_terminals, "个本地终端"), + let closes = phrases(&[ + (impact.remote_terminals > 0) + .then(|| tn!("update.restart.remote_terminals", impact.remote_terminals)), + (impact.sftp_tabs > 0).then(|| tn!("update.restart.sftp_tabs", impact.sftp_tabs)), + (impact.local_terminals > 0) + .then(|| tn!("update.restart.local_terminals", impact.local_terminals)), ]); - let stops = join(&[ - (impact.transfers, "个传输(保留续传进度)"), - (impact.forwards, "条端口转发"), + let stops = phrases(&[ + (impact.transfers > 0).then(|| tn!("update.restart.transfers", impact.transfers)), + (impact.forwards > 0).then(|| tn!("update.restart.forwards", impact.forwards)), ]); let note = match (closes, stops) { (None, None) => None, - (Some(closes), None) => Some(format!("重启会关闭 {closes}。")), - (None, Some(stops)) => Some(format!("重启会停止 {stops}。")), - (Some(closes), Some(stops)) => Some(format!("重启会关闭 {closes},停止 {stops}。")), + (Some(closes), None) => Some(t!("update.restart.closes", closes = closes)), + (None, Some(stops)) => Some(t!("update.restart.stops", stops = stops)), + (Some(closes), Some(stops)) => Some(t!( + "update.restart.closes_and_stops", + closes = closes, + stops = stops + )), }; - let unsaved = (impact.unsaved_files > 0).then(|| { - format!( - "{} 个文件有未保存的修改,重启后会丢失。", - impact.unsaved_files - ) - }); - match (note, unsaved) { - (Some(note), Some(unsaved)) => Some(format!("{note}{unsaved}")), + let unsaved = (impact.unsaved_files > 0) + .then(|| tn!("update.restart.unsaved_files", impact.unsaved_files)); + let note = match (note, unsaved) { + (Some(note), Some(unsaved)) => Some(t!( + "update.restart.and_unsaved_files", + note = note, + unsaved = unsaved + )), (note, unsaved) => note.or(unsaved), - } + }; + note.map(|note| note.to_string()) } /// `2 个远程终端、1 个 SFTP 标签和 1 个本地终端`, leaving out what is none. -fn join(parts: &[(usize, &str)]) -> Option { - let parts: Vec = parts - .iter() - .filter(|(count, _)| *count > 0) - .map(|(count, noun)| format!("{count} {noun}")) - .collect(); - match parts.as_slice() { - [] => None, - [one] => Some(one.clone()), - [rest @ .., last] => Some(format!("{}和 {}", rest.join("、"), last)), - } +fn phrases(parts: &[Option]) -> Option { + let parts: Vec<&SharedString> = parts.iter().flatten().collect(); + (!parts.is_empty()).then(|| join_list(&parts)) } #[cfg(test)] @@ -296,6 +325,67 @@ mod tests { ); } + #[test] + fn restarting_says_what_it_interrupts_in_english() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + assert_eq!( + restart_note(RestartImpact { + remote_terminals: 2, + sftp_tabs: 1, + local_terminals: 1, + transfers: 1, + forwards: 3, + unsaved_files: 0, + }) + .as_deref(), + Some( + "Restarting closes 2 remote terminals, 1 SFTP tab and 1 local terminal \ + and stops 1 transfer (its progress is kept) and 3 port forwards." + ) + ); + assert_eq!( + restart_note(RestartImpact { + sftp_tabs: 2, + unsaved_files: 1, + ..RestartImpact::default() + }) + .as_deref(), + Some("Restarting closes 2 SFTP tabs. Unsaved changes in 1 file will be lost.") + ); + assert_eq!( + restart_note(RestartImpact { + unsaved_files: 3, + ..RestartImpact::default() + }) + .as_deref(), + Some("Unsaved changes in 3 files will be lost.") + ); + assert_eq!( + snapshot(Phase::Downloading { + done: 45, + total: 100 + }) + .status_line() + .0, + "Downloading ShellRS 0.2.0… 45%" + ); + let failed = snapshot(Phase::Failed { + stage: Stage::Download, + error: UpdateError::Http(503), + }); + assert_eq!( + failed.status_line().0, + "Couldn’t download ShellRS 0.2.0: the update server answered HTTP 503" + ); + let mut off = snapshot(Phase::Off); + off.unsupported = Some(Unsupported::DevelopmentBuild); + assert_eq!( + off.status_line().0, + "Development builds don’t check for updates." + ); + } + #[test] fn each_phase_has_its_line() { assert_eq!(snapshot(Phase::Checking).status_line().0, "正在检查更新…"); diff --git a/src/update/update_dialog.rs b/src/update/update_dialog.rs index 46cc2ef..e207db5 100644 --- a/src/update/update_dialog.rs +++ b/src/update/update_dialog.rs @@ -19,6 +19,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{CatalogIcon, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate}; +use crate::i18n::t; use super::status::{RestartImpact, UpdateStep, percent, restart_note}; use super::updater::{Phase, Stage, UpdateSnapshot, Updater}; @@ -57,8 +58,12 @@ impl Render for UpdateDialog { .as_ref() .and_then(|release| release.published_on()) { - Some(day) => format!("当前版本 {} · 新版本发布于 {day}", snapshot.current), - None => format!("当前版本 {}", snapshot.current), + Some(day) => t!( + "update.dialog.versions_released", + current = snapshot.current, + day = day + ), + None => t!("update.dialog.current_version", current = snapshot.current), }; v_flex() .id("update-dialog") @@ -73,7 +78,7 @@ impl Render for UpdateDialog { div() .id("update-ready") .test_support() - .child("新版本已下载,重启 ShellRS 即可完成更新。"), + .child(t!("update.dialog.ready")), ) }) .child(div().text_color(muted).child(versions)), @@ -122,29 +127,33 @@ impl Render for UpdateDialog { } /// The progress line and bar while downloading or checking the package. -fn progress(snapshot: &UpdateSnapshot) -> Option<(String, f32)> { +fn progress(snapshot: &UpdateSnapshot) -> Option<(SharedString, f32)> { match snapshot.phase { Phase::Downloading { done, total } => { let percent = percent(done, total); - Some((format!("正在下载 · {percent}%"), percent as f32)) + Some(( + t!("update.dialog.downloading", percent = percent), + percent as f32, + )) } - Phase::Verifying => Some(("正在校验…".into(), 100.)), + Phase::Verifying => Some((t!("update.dialog.verifying"), 100.)), _ => None, } } /// Why the last step failed, or why this copy cannot install the update. -fn problem(snapshot: &UpdateSnapshot) -> Option { +fn problem(snapshot: &UpdateSnapshot) -> Option { match &snapshot.phase { Phase::Failed { stage, error } => Some(match stage { - Stage::Check => format!("检查失败:{error}"), - Stage::Download => format!("下载失败:{error}"), - Stage::Install => format!("安装失败:{error}"), + Stage::Check => t!("update.dialog.check_failed", error = error), + Stage::Download => t!("update.dialog.download_failed", error = error), + Stage::Install => t!("update.dialog.install_failed", error = error), }), - _ if snapshot.manual.is_some() => { - Some("这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。".into()) - } - _ => snapshot.unsupported.as_ref().map(|reason| reason.reason()), + _ if snapshot.manual.is_some() => Some(t!("update.dialog.manual")), + _ => snapshot + .unsupported + .as_ref() + .map(|reason| reason.reason().into()), } } @@ -161,8 +170,8 @@ pub fn open_update_dialog( window.open_dialog(cx, move |dialog, window, cx| { let snapshot = updater.read(cx).snapshot(); let title = match snapshot.offered_version() { - Some(version) => format!("ShellRS {version}"), - None => "ShellRS 更新".into(), + Some(version) => SharedString::from(format!("ShellRS {version}")), + None => t!("update.dialog.title"), }; dialog .title(title) @@ -179,20 +188,26 @@ pub fn open_update_dialog( /// Where to read what changed, and the one step that applies now. The /// dialog closes by its close button or Escape. fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter { - let action: Option<(&'static str, &'static str, Box)> = + let action: Option<(&'static str, SharedString, Box)> = snapshot.step().map(|step| -> (_, _, Box) { match step { - UpdateStep::Restart => { - ("restart-to-update", "重启并安装", Box::new(RestartToUpdate)) - } + UpdateStep::Restart => ( + "restart-to-update", + t!("update.dialog.restart"), + Box::new(RestartToUpdate), + ), UpdateStep::DownloadPage => ( "open-download-page", - "前往下载页", + t!("update.dialog.download_page"), Box::new(OpenDownloadPage), ), UpdateStep::Download { retry } => ( "download-update", - if retry { "重试" } else { "下载" }, + if retry { + t!("update.dialog.retry") + } else { + t!("update.dialog.download") + }, Box::new(DownloadUpdate), ), } @@ -202,7 +217,7 @@ fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter { .child( Button::new("open-changelog") .icon(CatalogIcon::ExternalLink) - .label("查看更新内容") + .label(t!("update.dialog.whats_new")) .on_click(move |_, window, cx| { changelog.dispatch_action(&OpenChangelog, window, cx); }), diff --git a/src/update/updater.rs b/src/update/updater.rs index 1589d6b..d68f8b4 100644 --- a/src/update/updater.rs +++ b/src/update/updater.rs @@ -24,6 +24,7 @@ use super::feed::{HttpFeed, UpdateFeed}; use super::install::{Installer, Launch, Relaunch, Staged, Unsupported, system_installer}; use super::manifest::{Offer, Release}; use super::verify::{Opened, TrustedKeys, open_envelope, verify_file}; +use crate::i18n::t; /// The first look, a while after start so it does not compete with the /// window coming up. @@ -408,7 +409,7 @@ impl Updater { this.phase = Phase::Failed { stage: Stage::Install, error: UpdateError::Install( - "无法自动重新启动,请手动重新打开 ShellRS".into(), + t!("update.updater.restart_failed").to_string(), ), }; this.changed(cx); @@ -576,7 +577,9 @@ impl Updater { ) { self.phase = Phase::Failed { stage: Stage::Check, - error: UpdateError::Network("检查意外中止".into()), + error: UpdateError::Network( + t!("update.updater.check_stopped").to_string(), + ), }; self.changed(cx); } @@ -794,7 +797,7 @@ pub fn start_handed_over() { if let Some(launch) = launch && let Err(error) = launch.spawn() { - eprintln!("shellrs: 无法启动更新安装程序:{error}"); + eprintln!("shellrs: could not start the update installer: {error}"); } } diff --git a/src/workspace/cli_changes.rs b/src/workspace/cli_changes.rs index a0fb562..e08ea2c 100644 --- a/src/workspace/cli_changes.rs +++ b/src/workspace/cli_changes.rs @@ -18,6 +18,7 @@ use crate::cli::{ plan_host, with_saved_credential_secrets, with_saved_passwords, }; use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore}; +use crate::i18n::t; use crate::secrets::{SecretRef, SharedSecretStore}; use super::workspace_view::Workspace; @@ -157,7 +158,9 @@ impl Workspace { .host(id) .map(|host| (host_details(host, store), host_secrets(host, store))) }) - .ok_or_else(|| CliError::new(ErrorCode::HostNotFound, "主机保存后不见了"))?; + .ok_or_else(|| { + CliError::new(ErrorCode::HostNotFound, t!("cli.change.host_gone")) + })?; let details = cx .background_executor() .spawn(async move { @@ -180,7 +183,10 @@ impl Workspace { let store = self.store.read(cx); let id = find_saved_host(store, host)?; let Some(host) = store.host(id) else { - return Err(CliError::new(ErrorCode::HostNotFound, "主机已不存在")); + return Err(CliError::new( + ErrorCode::HostNotFound, + t!("cli.change.host_missing"), + )); }; let deleted = HostDeleted { host: host_details(host, store), @@ -190,10 +196,7 @@ impl Workspace { if !force && self.has_tabs(id, cx) { return Err(CliError::new( ErrorCode::HostInUse, - format!( - "主机「{}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭", - deleted.host.name - ), + t!("cli.change.host_in_use", name = deleted.host.name), )); } self.close_host_tabs(id, window, cx); @@ -241,7 +244,7 @@ impl Workspace { ) }) }) - .ok_or_else(|| CliError::new(ErrorCode::CredentialNotFound, "凭据保存后不见了"))?; + .ok_or_else(credential_gone)?; let details = cx .background_executor() .spawn(async move { @@ -310,7 +313,10 @@ fn save_credential( let path = store .save_private_key(existing, pasted.text()) .map_err(|error| { - CliError::new(ErrorCode::SaveFailed, format!("私钥未能保存:{error}")) + CliError::new( + ErrorCode::SaveFailed, + t!("cli.change.key_not_saved", error = error), + ) })?; draft = draft.with_key_path(path); } @@ -322,10 +328,7 @@ fn save_credential( None => store.insert_credential(draft, cx), }); let Some(credential) = store.credential(id).cloned() else { - return Err(CliError::new( - ErrorCode::CredentialNotFound, - "凭据保存后不见了", - )); + return Err(credential_gone()); }; let mut writes = Vec::new(); if let Some(value) = plan.password.value() { @@ -369,13 +372,18 @@ fn is_saved(secrets: &SharedSecretStore, secret: &SecretRef) -> bool { secrets.get(secret).is_ok_and(|value| value.is_some()) } -fn save_failed(failures: Vec) -> CliError { +fn credential_gone() -> CliError { CliError::new( - ErrorCode::SaveFailed, - failures - .iter() - .map(SharedString::as_ref) - .collect::>() - .join(";"), + ErrorCode::CredentialNotFound, + t!("cli.change.credential_gone"), ) } + +/// Every failure, one after the other. +fn save_failed(failures: Vec) -> CliError { + let message = failures + .into_iter() + .reduce(|before, next| t!("cli.change.failures", before = before, next = next)) + .unwrap_or_default(); + CliError::new(ErrorCode::SaveFailed, message) +}