diff --git a/locales/connection.yml b/locales/connection.yml new file mode 100644 index 0000000..c271084 --- /dev/null +++ b/locales/connection.yml @@ -0,0 +1,21 @@ +_version: 2 + +# 连接: the questions a connection asks before it can go on. + +connection.unknown_host.description: + zh-CN: "主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。" + en: "Host: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust." +connection.unknown_host.jump_host_description: + zh-CN: "跳板主机:%{jump_host}\n主机:%{host}:%{port}\n算法:%{algorithm}\nSHA-256 指纹:%{fingerprint}\n\n请先确认该指纹来自可信渠道。" + en: "Jump host: %{jump_host}\nHost: %{host}:%{port}\nAlgorithm: %{algorithm}\nSHA-256 fingerprint: %{fingerprint}\n\nMake sure the fingerprint matches one from a source you trust." + +connection.jump_host.instructions: + zh-CN: 跳板主机「%{name}」 + en: Jump host “%{name}” +connection.jump_host.with_instructions: + zh-CN: 跳板主机「%{name}」:%{instructions} + en: "Jump host “%{name}”: %{instructions}" + +connection.latency.timed_out: + zh-CN: 超时 + en: Timed out diff --git a/locales/credential.yml b/locales/credential.yml new file mode 100644 index 0000000..e475df8 --- /dev/null +++ b/locales/credential.yml @@ -0,0 +1,156 @@ +_version: 2 + +# 凭据: the credential list and the dialog that edits a credential. + +credential.key_source.file: + zh-CN: 本机文件 + en: Local file +credential.key_source.paste: + zh-CN: 粘贴 + en: Paste +credential.key_source.generate: + zh-CN: 生成新密钥 + en: Generate + +credential.dialog.title_new: + zh-CN: 新建凭据 + en: New credential +credential.dialog.title_edit: + zh-CN: 编辑凭据 + en: Edit credential +credential.dialog.create: + zh-CN: 创建 + en: Create +credential.dialog.kind: + zh-CN: 类型 + en: Type +credential.dialog.name: + zh-CN: 名称 + en: Name +credential.dialog.name_placeholder: + zh-CN: 例如 生产环境 root + en: e.g. Production root +credential.dialog.user: + zh-CN: 用户名 + en: Username +credential.dialog.password: + zh-CN: 密码 + en: Password +credential.dialog.private_key: + zh-CN: 私钥 + en: Private key +credential.dialog.key_text_placeholder: + zh-CN: 粘贴私钥,以 -----BEGIN 开头 + en: Paste the private key, starting with -----BEGIN +credential.dialog.no_key_dir: + zh-CN: 本地数据库没有打开,这次运行无法保存私钥 + en: The local database isn’t open, so ShellRS can’t keep private keys until it restarts +credential.dialog.kept_key: + zh-CN: 这把私钥由 ShellRS 保存,删除凭据时会一起删除。 + en: ShellRS keeps this private key and deletes it along with the credential. +credential.dialog.algorithm: + zh-CN: 算法 + en: Algorithm +credential.dialog.rsa_note: + zh-CN: 只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。 + en: Choose it only for servers without Ed25519 support (OpenSSH before 6.5). +credential.dialog.public_key: + zh-CN: 公钥 + en: Public key +credential.dialog.public_key_note: + zh-CN: 把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。 + en: Add the public key to ~/.ssh/authorized_keys on a server to log in to it with this key. +credential.dialog.copy_public_key: + zh-CN: 复制公钥 + en: Copy public key +credential.dialog.generating: + zh-CN: 正在生成密钥… + en: Generating the key… +credential.dialog.passphrase: + zh-CN: 私钥口令 + en: Passphrase +credential.dialog.ask_for_passphrase: + zh-CN: 留空则每次连接都询问 + en: Leave empty to be asked at each connection +credential.dialog.no_passphrase: + zh-CN: 留空则不加密 + en: Leave empty to leave the key unencrypted +credential.dialog.agent_note: + zh-CN: 登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。 + en: Logs in with the keys in the SSH agent that SSH_AUTH_SOCK points to. +credential.dialog.agent_note_windows: + zh-CN: 登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。 + en: Logs in with the keys in the Windows OpenSSH Authentication Agent service. +credential.dialog.keychain_note: + zh-CN: 密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。 + en: Passwords and passphrases are kept in the system keychain, never in ShellRS’s database. +credential.dialog.no_keychain_note: + zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。 + en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection. +credential.dialog.drops_kept_key: + zh-CN: 保存后,ShellRS 保存的原私钥会被删除。 + en: Saving deletes the private key ShellRS kept for this credential. +credential.dialog.usage.one: + en: "%{count} host uses this credential." +credential.dialog.usage.other: + zh-CN: 有 %{count} 台主机使用此凭据。 + en: "%{count} hosts use this credential." +credential.dialog.usage_new_key: + zh-CN: 换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。 + en: After switching to a new private key, add its public key to these hosts so they can log in again. +credential.dialog.usage_reconnects: + zh-CN: 修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。 + en: Changing the username, type or private key file reconnects the ones that are connected. +credential.dialog.encrypting: + zh-CN: 正在用口令加密私钥… + en: Encrypting the private key with the passphrase… + +credential.dialog.generate_failed: + zh-CN: 无法生成密钥:%{error} + en: "Couldn’t generate the key: %{error}" +credential.dialog.still_generating: + zh-CN: 密钥还在生成,请稍候 + en: The key is still being generated. Try again in a moment. +credential.dialog.encode_failed: + zh-CN: 无法保存私钥:%{error} + en: "Couldn’t save the private key: %{error}" +credential.dialog.encrypt_failed: + zh-CN: 无法加密私钥:%{error} + en: "Couldn’t encrypt the private key: %{error}" +credential.dialog.key_not_saved: + zh-CN: 私钥未能保存:%{error} + en: "The private key wasn’t saved: %{error}" + +credential.panel.title: + zh-CN: 凭据 + en: Credentials +credential.panel.search: + zh-CN: 搜索凭据 + en: Search credentials +credential.panel.detail.one: + en: "%{summary} · %{count} host" +credential.panel.detail.other: + zh-CN: "%{summary} · %{count} 台主机" + en: "%{summary} · %{count} hosts" +credential.panel.no_matches: + zh-CN: 没有匹配的凭据 + en: No matching credentials +credential.panel.empty: + zh-CN: 还没有凭据 + en: No credentials yet +credential.panel.empty_hint: + zh-CN: 保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。 + en: Save a username with a password, a private key or the SSH agent for several hosts to share. + +credential.menu.new: + zh-CN: 新建凭据… + en: New credential… +credential.menu.generate: + zh-CN: 生成密钥… + en: Generate key… +credential.menu.edit: + zh-CN: 编辑凭据… + en: Edit credential… +credential.menu.copy_public_key: + zh-CN: 复制公钥 + en: Copy public key diff --git a/locales/forward.yml b/locales/forward.yml new file mode 100644 index 0000000..80d8b2c --- /dev/null +++ b/locales/forward.yml @@ -0,0 +1,286 @@ +_version: 2 + +# 端口转发: the forward list, its dialog and the forwards running. + +forward.diagram.local: + zh-CN: 本机 + en: This computer +forward.diagram.server: + zh-CN: SSH 服务器 + en: SSH server +forward.diagram.target_service: + zh-CN: 目标服务 + en: Service +forward.diagram.anywhere: + zh-CN: 任意地址 + en: Any address +forward.diagram.entrance: + zh-CN: 隧道入口 + en: Tunnel entrance +forward.diagram.exit: + zh-CN: 隧道出口 + en: Tunnel exit +forward.diagram.target: + zh-CN: 目标 + en: Destination +forward.diagram.listens: + zh-CN: 监听 %{bind} + en: Listens on %{bind} +forward.diagram.connects_onward: + zh-CN: 代为连接目标 + en: Connects onward +forward.diagram.app_chooses: + zh-CN: 由应用指定 + en: Chosen by the app +forward.diagram.tunnel: + zh-CN: SSH 隧道 + en: SSH tunnel +forward.diagram.plain: + zh-CN: 普通连接 + en: Plain TCP + +forward.explain.local: + zh-CN: 在本机连接 %{bind},就等于从 SSH 服务器连接 %{target}。 + en: Connecting to %{bind} on this computer is the same as connecting to %{target} from the SSH server. +forward.explain.remote: + zh-CN: 在 SSH 服务器上连接 %{bind},就等于从本机连接 %{target}。 + en: Connecting to %{bind} on the SSH server is the same as connecting to %{target} from this computer. +forward.explain.dynamic: + zh-CN: 把应用的 SOCKS5 代理设为 %{bind},它的连接都从 SSH 服务器发出。 + en: Set an app’s SOCKS5 proxy to %{bind}, and its connections go out from the SSH server. + +forward.purpose.local: + zh-CN: 在本机访问服务器那边的服务 + en: Reach a service on the server’s side +forward.purpose.remote: + zh-CN: 让服务器那边访问本机的服务 + en: Let the server’s side reach a service here +forward.purpose.dynamic: + zh-CN: 把服务器当作 SOCKS 代理 + en: Use the server as a SOCKS proxy + +forward.typical_use.local: + zh-CN: 常见用途:用本机的客户端连接只对服务器开放的数据库或内网后台。 + en: "Typical use: connect a client here to a database or internal admin site that only the server can reach." +forward.typical_use.remote: + zh-CN: 常见用途:把本机正在开发的服务给服务器那边访问,或接收 Webhook。 + en: "Typical use: let the server’s side reach a service you’re developing here, or receive webhooks." +forward.typical_use.dynamic: + zh-CN: 常见用途:让浏览器经由服务器上网,不必为每个地址单独建转发。 + en: "Typical use: browse through the server without a forward for each address." + +forward.dialog.title_new: + zh-CN: 新建端口转发 + en: New port forward +forward.dialog.title_edit: + zh-CN: 编辑端口转发 + en: Edit port forward +forward.dialog.create: + zh-CN: 创建 + en: Create +forward.dialog.kind_card: + zh-CN: "%{kind}(%{flag})" + en: "%{kind} (%{flag})" +forward.dialog.name: + zh-CN: 名称 + en: Name +forward.dialog.name_placeholder: + zh-CN: 可选,留空显示转发摘要 + en: Optional; shows the forward’s summary if left empty +forward.dialog.host: + zh-CN: 经由主机 + en: Via host +forward.dialog.choose_host: + zh-CN: 请选择主机 + en: Choose a host +forward.dialog.host_option: + zh-CN: "%{name}(%{endpoint})" + en: "%{name} (%{endpoint})" +forward.dialog.bind_local: + zh-CN: 本机监听地址 + en: Listen on this computer +forward.dialog.bind_server: + zh-CN: 服务器监听地址 + en: Listen on the server +forward.dialog.bind_socks: + zh-CN: 本机监听地址(SOCKS 代理) + en: Listen on this computer (SOCKS proxy) +forward.dialog.target_from_server: + zh-CN: 目标地址(从服务器访问) + en: Destination (as the server reaches it) +forward.dialog.target_from_local: + zh-CN: 目标地址(从本机访问) + en: Destination (as this computer reaches it) +forward.dialog.port: + zh-CN: 端口 + en: Port +forward.dialog.target_placeholder: + zh-CN: 例如 db.internal 或 127.0.0.1 + en: e.g. db.internal or 127.0.0.1 +forward.dialog.target_placeholder_remote: + zh-CN: 例如 localhost + en: e.g. localhost +forward.dialog.auto_start: + zh-CN: 启动 ShellRS 时自动开启 + en: Start when ShellRS opens +forward.dialog.gateway_ports_note: + zh-CN: 服务器的 sshd_config 需设置 GatewayPorts clientspecified 或 yes,其他机器才连得上;默认是 no,这时转发照常运行,但只监听服务器自己的 127.0.0.1。 + en: Other machines can connect only if the server’s sshd_config sets GatewayPorts to clientspecified or yes. With the default, no, the forward still runs but listens only on the server’s own 127.0.0.1. +forward.dialog.not_loopback_note: + zh-CN: 监听地址不是本机回环地址,同一网络中的其他设备也能使用这个转发。 + en: The listening address isn’t a loopback address, so other devices on the same network can use this forward too. +forward.dialog.privileged_port_remote: + zh-CN: 服务器上 1024 以下的端口需要以 root 登录才能监听。 + en: Listening on a port below 1024 on the server takes logging in as root. +forward.dialog.privileged_port: + zh-CN: 1024 以下的端口通常需要管理员权限才能监听。 + en: Listening on a port below 1024 usually takes administrator rights. +forward.dialog.restart_note: + zh-CN: 这条转发正在运行,改动监听或目标后会按新设置重新启动。 + en: This forward is running. Changing where it listens or connects restarts it with the new settings. +forward.dialog.choose_host_error: + zh-CN: 请选择端口转发经由的主机 + en: Choose the host to forward through +forward.dialog.host_gone: + zh-CN: 所选主机已不存在,请重新选择 + en: The chosen host no longer exists. Choose another. + +forward.panel.title: + zh-CN: 端口转发 + en: Port forwarding +forward.panel.search: + zh-CN: 搜索端口转发 + en: Search port forwards +forward.panel.no_matches: + zh-CN: 没有匹配的端口转发 + en: No matching port forwards +forward.panel.no_hosts: + zh-CN: 还没有主机 + en: No hosts yet +forward.panel.no_hosts_hint: + zh-CN: 端口转发经由一台主机的 SSH 连接,先新建主机。 + en: A port forward goes through a host’s SSH connection. Create a host first. +forward.panel.new_host: + zh-CN: 新建主机… + en: New host… +forward.panel.empty: + zh-CN: 还没有端口转发 + en: No port forwards yet +forward.panel.empty_hint: + zh-CN: 把本机的端口和服务器那一侧的网络接起来。 + en: Connect a port on this computer with the network on the server’s side. + +forward.menu.new: + zh-CN: 新建端口转发… + en: New port forward… +forward.menu.edit: + zh-CN: 编辑端口转发… + en: Edit port forward… +forward.menu.start: + zh-CN: 启动 + en: Start +forward.menu.stop: + zh-CN: 停止 + en: Stop + +forward.status.stopped: + zh-CN: 已停止 + en: Stopped +forward.status.connecting: + zh-CN: 正在连接 + en: Connecting +forward.status.running: + zh-CN: 运行中 + en: Running +forward.status.running_with.one: + en: Running, %{count} connection +forward.status.running_with.other: + zh-CN: 运行中,%{count} 个连接 + en: Running, %{count} connections +forward.status.reconnecting: + zh-CN: 连接中断,正在重连(%{attempt}/%{of}) + en: Connection lost, reconnecting (%{attempt}/%{of}) +forward.status.failed: + zh-CN: 已停止:%{reason} + en: "Stopped: %{reason}" + +forward.error.start_failed: + zh-CN: 无法启动端口转发:%{error} + en: "Couldn’t start the port forward: %{error}" +forward.error.ended: + zh-CN: 端口转发意外中止 + en: The port forward stopped unexpectedly +forward.error.reconnect_failed: + zh-CN: 连接中断,自动重连失败:%{reason} + en: "The connection was lost and couldn’t be restored: %{reason}" +forward.error.connection_lost: + zh-CN: 与服务器的连接已中断 + en: The connection to the server was lost +forward.error.reconnects_used.one: + en: The connection was lost, and %{count} attempt to reconnect failed +forward.error.reconnects_used.other: + zh-CN: 连接中断,重连 %{count} 次均未成功 + en: The connection was lost, and all %{count} attempts to reconnect failed +forward.error.remote_listen: + zh-CN: 服务器拒绝在 %{bind} 上监听:端口可能已被占用、1024 以下的端口需要 root,或服务器禁用了端口转发(AllowTcpForwarding) + en: "The server refused to listen on %{bind}: the port may be in use, a port below 1024 needs root, or the server has port forwarding turned off (AllowTcpForwarding)" +forward.error.key_changed: + zh-CN: 主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份 + en: The host key doesn’t match the saved one, so the connection was blocked. Verify the server’s identity first. +forward.error.unknown_key: + zh-CN: 尚未信任该主机的密钥,请手动启动以确认 + en: The host’s key isn’t trusted yet. Start the forward yourself to confirm it. +forward.error.needs_password: + zh-CN: 需要输入密码,请手动启动 + en: A password is needed. Start the forward yourself to enter it. +forward.error.needs_passphrase: + zh-CN: 需要输入私钥口令,请手动启动 + en: A passphrase is needed. Start the forward yourself to enter it. +forward.error.needs_interactive: + zh-CN: 服务器要求交互式认证,请手动启动 + en: The server asks for interactive authentication. Start the forward yourself to answer it. +forward.error.unresolved_bind: + zh-CN: 无法解析监听地址 %{host} + en: Couldn’t resolve the listening address %{host} +forward.error.port_in_use: + zh-CN: 本机端口 %{port} 已被占用 + en: Port %{port} on this computer is already in use +forward.error.port_denied: + zh-CN: 没有权限监听端口 %{port}(1024 以下的端口需要管理员权限) + en: No permission to listen on port %{port} (ports below 1024 need administrator rights) +forward.error.no_such_address: + zh-CN: 本机没有地址 %{host},无法监听 + en: This computer has no address %{host} to listen on +forward.error.bind_failed: + zh-CN: 无法监听 %{bind}:%{error} + en: "Couldn’t listen on %{bind}: %{error}" +forward.error.not_socks: + zh-CN: 收到的不是 SOCKS 请求,请把应用的代理类型设为 SOCKS5 + en: This isn’t a SOCKS request. Set the app’s proxy type to SOCKS5. +forward.error.local_refused: + zh-CN: 连接被拒绝,该端口上没有服务在监听 + en: connection refused, as nothing is listening on that port +forward.error.local_timed_out: + zh-CN: 连接超时 + en: the connection timed out +forward.error.local_target_failed: + zh-CN: 无法连接本机目标 %{target}:%{reason} + en: "Couldn’t connect to %{target} on this computer: %{reason}" +forward.error.server_timed_out: + zh-CN: 服务器连接 %{target} 超时 + en: The server timed out connecting to %{target} +forward.error.prohibited: + zh-CN: 服务器禁止转发到 %{target}(AllowTcpForwarding 或 PermitOpen 限制) + en: The server doesn’t allow forwarding to %{target} (AllowTcpForwarding or PermitOpen) +forward.error.server_connect_failed: + zh-CN: 服务器无法连接 %{target} + en: The server couldn’t connect to %{target} +forward.error.unsupported: + zh-CN: 服务器不支持端口转发 + en: The server doesn’t support port forwarding +forward.error.resource_shortage: + zh-CN: 服务器资源不足,无法建立转发连接 + en: The server is short of resources and couldn’t open the connection +forward.error.refused: + zh-CN: 服务器拒绝了到 %{target} 的连接 + en: The server refused the connection to %{target} diff --git a/locales/host.yml b/locales/host.yml new file mode 100644 index 0000000..4d94f36 --- /dev/null +++ b/locales/host.yml @@ -0,0 +1,613 @@ +_version: 2 + +# 主机: the saved hosts, their groups and the store that keeps them. + +host.state.disconnected: + zh-CN: 未连接 + en: Not connected +host.state.connecting: + zh-CN: 连接中 + en: Connecting +host.state.connected: + zh-CN: 已连接 + en: Connected + +host.proxy_kind.http: + zh-CN: HTTP 代理 + en: HTTP proxy +host.proxy_kind.socks5: + zh-CN: SOCKS5 代理 + en: SOCKS5 proxy + +host.draft.name: + zh-CN: 请输入名称 + en: Enter a name +host.draft.address: + zh-CN: 请输入地址 + en: Enter an address +host.draft.port: + zh-CN: 端口必须是 1 到 65535 之间的数字 + en: The port must be a number from 1 to 65535 +host.draft.no_jump_hosts: + zh-CN: 请添加跳板主机 + en: Add a jump host +host.draft.deleted_jump_host: + zh-CN: 请移除已删除的跳板主机 + en: Remove the deleted jump host +host.draft.proxy_address: + zh-CN: 请输入代理地址 + en: Enter the proxy address +host.draft.proxy_port: + zh-CN: 代理端口必须是 1 到 65535 之间的数字 + en: The proxy port must be a number from 1 to 65535 + +host.credential_kind.password: + zh-CN: 密码 + en: Password +host.credential_kind.key: + zh-CN: 密钥 + en: Key + +host.credential_draft.name: + zh-CN: 请输入名称 + en: Enter a name +host.credential_draft.key_path: + zh-CN: 密钥凭据需要选择私钥文件 + en: Choose a private key file for a key credential + +host.database.newer: + zh-CN: 数据库来自更新版本的 ShellRS(结构版本 %{version},本版本支持到 %{supported}),请安装最新版本 + en: The database is from a newer version of ShellRS (schema version %{version}; this version supports up to %{supported}). Install the latest version. +host.database.too_old: + zh-CN: 数据库来自 ShellRS 的早期开发版本(结构版本 %{version}),无法升级 + en: The database is from an early development version of ShellRS (schema version %{version}) and can’t be upgraded. + +host.forward_kind.local: + zh-CN: 本地转发 + en: Local +host.forward_kind.remote: + zh-CN: 远程转发 + en: Remote +host.forward_kind.dynamic: + zh-CN: 动态转发 + en: Dynamic + +host.forward_draft.bind_host: + zh-CN: 请输入监听地址,地址中不能有空格 + en: Enter a listening address without spaces +host.forward_draft.bind_port: + zh-CN: 监听端口应为 1–65535 的数字 + en: The listening port must be a number from 1 to 65535 +host.forward_draft.target_host: + zh-CN: 请输入目标地址,地址中不能有空格 + en: Enter a destination address without spaces +host.forward_draft.target_port: + zh-CN: 目标端口应为 1–65535 的数字 + en: The destination port must be a number from 1 to 65535 + +host.auth.password: + zh-CN: 密码 + en: Password +host.auth.credential: + zh-CN: 使用凭据 + en: Credential +host.auth.no_password: + zh-CN: 无密码 + en: No password + +host.route.direct: + zh-CN: 直接连接 + en: Direct +host.route.jump: + zh-CN: SSH 跳板 + en: Jump hosts +host.route.proxy: + zh-CN: 代理连接 + en: Proxy + +host.dialog.title_new: + zh-CN: 新建主机 + en: New host +host.dialog.title_edit: + zh-CN: 编辑主机 + en: Edit host +host.dialog.title_temporary: + zh-CN: 临时连接 + en: Quick connect +host.dialog.create: + zh-CN: 创建 + en: Create +host.dialog.connect: + zh-CN: 连接 + en: Connect +host.dialog.test_connection: + zh-CN: 测试连接 + en: Test connection +host.dialog.name: + zh-CN: 名称 + en: Name +host.dialog.name_placeholder: + zh-CN: 例如 web-01 + en: e.g. web-01 +host.dialog.temporary_name_placeholder: + zh-CN: 可选,默认用地址 + en: Optional; the address if left empty +host.dialog.address: + zh-CN: 地址 + en: Address +host.dialog.address_placeholder: + zh-CN: 主机名或 IP 地址 + en: Host name or IP address +host.dialog.port: + zh-CN: 端口 + en: Port +host.dialog.auth: + zh-CN: 认证方式 + en: Authentication +host.dialog.no_password_note: + zh-CN: 依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。 + en: Tries logging in without authentication, then the SSH agent, then the default private keys in ~/.ssh. +host.dialog.user: + zh-CN: 用户名 + en: Username +host.dialog.password: + zh-CN: 密码 + en: Password +host.dialog.credential: + zh-CN: 凭据 + en: Credential +host.dialog.choose_credential: + zh-CN: 请选择凭据 + en: Choose a credential +host.dialog.search_credentials: + zh-CN: 搜索凭据 + en: Search credentials +host.dialog.no_credentials: + zh-CN: 还没有凭据 + en: No credentials yet +host.dialog.no_credentials_hint: + zh-CN: 还没有凭据,可在侧栏的「凭据」中新建 + en: No credentials yet. Create one in the sidebar’s Credentials list. +host.dialog.credential_option: + zh-CN: "%{name}(%{summary})" + en: "%{name} (%{summary})" +host.dialog.logs_in_with.password: + zh-CN: 以 %{user} 登录,使用凭据保存的密码 + en: Logs in as %{user} with the password saved in the credential +host.dialog.logs_in_with.kept_key: + zh-CN: 以 %{user} 登录,使用 ShellRS 保存的私钥 + en: Logs in as %{user} with the private key ShellRS keeps +host.dialog.logs_in_with.key: + zh-CN: 以 %{user} 登录,使用私钥 %{path} + en: Logs in as %{user} with the private key %{path} +host.dialog.logs_in_with.agent: + zh-CN: 以 %{user} 登录,使用 SSH Agent 中的密钥 + en: Logs in as %{user} with the keys in the SSH agent +host.dialog.enter_user: + zh-CN: 请输入用户名 + en: Enter a username +host.dialog.group: + zh-CN: 分组 + en: Group +host.dialog.no_group: + zh-CN: (无分组) + en: (No group) +host.dialog.notes: + zh-CN: 备注 + en: Notes +host.dialog.optional: + zh-CN: 可选 + en: Optional +host.dialog.keychain_note: + zh-CN: 密码保存在系统钥匙串,不会写入 ShellRS 的数据库。 + en: Passwords are kept in the system keychain, never in ShellRS’s database. +host.dialog.no_keychain_note: + zh-CN: 系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。 + en: The system keychain isn’t available, so passwords can’t be saved on this computer and are asked for at each connection. +host.dialog.reconnect_note: + zh-CN: 保存后连接设置将立即生效并重连。 + en: Saving applies the connection settings at once and reconnects. +host.dialog.temporary_note: + zh-CN: 仅当前使用的临时会话,不会保存到主机列表。 + en: For this connection only. It won’t be added to your hosts. + +host.dialog.route: + zh-CN: 连接方式 + en: Connection +host.dialog.jump_note: + zh-CN: 依次经过跳板主机连接到当前主机,可添加多台。 + en: Connects to this host through each jump host in turn. You can add several. +host.dialog.this_computer: + zh-CN: 本机 + en: This computer +host.dialog.this_host: + zh-CN: 当前主机 + en: This host +host.dialog.deleted_host: + zh-CN: 已删除的主机 + en: Deleted host +host.dialog.jump_host_title: + zh-CN: "%{name}(%{address})" + en: "%{name} (%{address})" +host.dialog.remove_hop: + zh-CN: 移除 + en: Remove +host.dialog.add_jump_host: + zh-CN: 添加跳板主机 + en: Add jump host +host.dialog.search_hosts: + zh-CN: 搜索主机 + en: Search hosts +host.dialog.no_matching_hosts: + zh-CN: 没有匹配的主机 + en: No matching hosts +host.dialog.no_other_hosts: + zh-CN: 还没有其他主机可做跳板 + en: No other hosts to jump through +host.dialog.all_hosts_added: + zh-CN: 其他主机都已添加 + en: All other hosts are added +host.dialog.proxy_kind: + zh-CN: 代理类型 + en: Proxy type +host.dialog.proxy_address: + zh-CN: 代理地址 + en: Proxy address +host.dialog.proxy_host_placeholder: + zh-CN: 例如 127.0.0.1 + en: e.g. 127.0.0.1 +host.dialog.proxy_note: + zh-CN: 目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。 + en: The proxy server resolves the host’s address. Leave the username and password empty if the proxy doesn’t need them. +host.dialog.proxy_password_needs_user: + zh-CN: 填写代理密码时请同时填写用户名 + en: Enter a username to go with the proxy password + +host.dialog.test_start_failed: + zh-CN: 无法启动连接测试:%{error} + en: "Couldn’t start the connection test: %{error}" +host.dialog.test_aborted: + zh-CN: 连接测试意外中止 + en: The connection test stopped unexpectedly +host.test.connected: + zh-CN: 连接成功 + en: Connection succeeded +host.test.failed: + zh-CN: 连接失败 + en: Couldn’t connect + +host.trust.title: + zh-CN: 首次连接此主机 + en: First connection to this host +host.trust.trust: + zh-CN: 信任并继续 + en: Trust and continue + +host.delete.closes_tabs: + zh-CN: 会一并关闭该主机已打开的终端和 SFTP 标签。 + en: Its open terminal and SFTP tabs will close. +host.delete.transfers.one: + en: "%{count} transfer batch will stop, with its progress kept for resuming." +host.delete.transfers.other: + zh-CN: 将停止 %{count} 个传输批次并保留续传进度。 + en: "%{count} transfer batches will stop, with their progress kept for resuming." +host.delete.forwards.one: + en: "%{count} port forward through this host will also be deleted." +host.delete.forwards.other: + zh-CN: 将同时删除经由该主机的 %{count} 条端口转发。 + en: "%{count} port forwards through this host will also be deleted." +host.delete.jump_users.one: + en: "%{count} host uses it as a jump host and will need another one to connect." +host.delete.jump_users.other: + zh-CN: 有 %{count} 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。 + en: "%{count} hosts use it as a jump host and will need another one to connect." + +host.count.hosts.one: + en: "%{count} host" +host.count.hosts.other: + zh-CN: "%{count} 台主机" + en: "%{count} hosts" +host.count.subgroups.one: + en: "%{count} subgroup" +host.count.subgroups.other: + zh-CN: "%{count} 个子分组" + en: "%{count} subgroups" + +host.group_dialog.title_new: + zh-CN: 新建分组 + en: New group +host.group_dialog.title_rename: + zh-CN: 重命名分组 + en: Rename group +host.group_dialog.create: + zh-CN: 创建 + en: Create +host.group_dialog.name: + zh-CN: 名称 + en: Name +host.group_dialog.name_placeholder: + zh-CN: 例如 生产 + en: e.g. Production +host.group_dialog.parent: + zh-CN: 上级分组 + en: Parent group +host.group_dialog.top_level: + zh-CN: (顶层) + en: (Top level) +host.group_dialog.enter_name: + zh-CN: 请输入分组名称 + en: Enter a group name +host.group_dialog.duplicate: + zh-CN: 同一层级下已有同名分组 + en: A group with this name is already at this level +host.group_dialog.into_itself: + zh-CN: 无法把分组移动到它自己的下级 + en: A group can’t move into one of its own subgroups + +host.delete_group.contents: + zh-CN: 将同时删除其中的 %{items}。 + en: This also deletes the group’s %{items}. +host.delete_group.closes_tabs: + zh-CN: 已打开的终端和 SFTP 标签会一并关闭。 + en: Open terminal and SFTP tabs will close. +host.delete_group.forwards.one: + en: "%{count} port forward through these hosts will also be deleted." +host.delete_group.forwards.other: + zh-CN: 这些主机的 %{count} 条端口转发会一并删除。 + en: "%{count} port forwards through these hosts will also be deleted." +host.delete_group.jump_users.one: + en: "%{count} host outside the group jumps through these hosts and will need another jump host to connect." +host.delete_group.jump_users.other: + zh-CN: 分组外有 %{count} 台主机经由这些主机跳转,删除后要重新选择跳板主机才能连接。 + en: "%{count} hosts outside the group jump through these hosts and will need another jump host to connect." + +host.panel.title: + zh-CN: 主机 + en: Hosts +host.panel.search: + zh-CN: 搜索主机或分组 + en: Search hosts or groups + +host.menu.connect: + zh-CN: 连接 + en: Connect +host.menu.open_sftp: + zh-CN: 打开 SFTP + en: Open SFTP +host.menu.edit_host: + zh-CN: 编辑主机… + en: Edit host… +host.menu.duplicate: + zh-CN: 复制 + en: Duplicate +host.menu.copy_ip: + zh-CN: 复制 IP 地址 + en: Copy IP address +host.menu.copy_host_name: + zh-CN: 复制主机名 + en: Copy host name +host.menu.copy_id: + zh-CN: 复制 ID + en: Copy ID +host.menu.new_host: + zh-CN: 新建主机… + en: New host… +host.menu.new_group: + zh-CN: 新建分组… + en: New group… +host.menu.connect_group: + zh-CN: 连接组内主机 + en: Connect all in group +host.menu.new_subgroup: + zh-CN: 新建子分组… + en: New subgroup… +host.menu.expand_all: + zh-CN: 展开全部分组 + en: Expand all groups +host.menu.collapse_all: + zh-CN: 折叠全部分组 + en: Collapse all groups +host.menu.rename_group: + zh-CN: 重命名分组… + en: Rename group… +host.menu.delete_group: + zh-CN: 删除分组 + en: Delete group + +host.pasted_key.empty: + zh-CN: 请粘贴私钥 + en: Paste a private key +host.pasted_key.public_key: + zh-CN: 这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段) + en: This is a public key. Paste the private key, the part that starts with -----BEGIN. +host.pasted_key.unreadable: + zh-CN: 无法识别这段私钥,支持 OpenSSH、PEM 和 PuTTY 格式 + en: This private key can’t be read. Use the OpenSSH, PEM or PuTTY format. + +host.quick_connect.title: + zh-CN: 快速连接 + en: Find a host +host.quick_connect.search: + zh-CN: 搜索主机名称、地址、用户名、备注或分组 + en: Search by name, address, username, notes or group +host.quick_connect.no_hosts: + zh-CN: 还没有保存的主机 + en: No saved hosts yet +host.quick_connect.no_matches: + zh-CN: 没有匹配的主机 + en: No matching hosts +host.quick_connect.choose: + zh-CN: 选中 + en: Select +host.quick_connect.choose_all: + zh-CN: 全选 + en: Select all +host.quick_connect.connect_chosen: + zh-CN: 批量连接 + en: Connect selected + +host.secret_fields.ask_each_time: + zh-CN: 留空则每次连接都询问 + en: Leave empty to be asked at each connection +host.secret_fields.key_path_placeholder: + zh-CN: 选择 OpenSSH 私钥文件 + en: Choose an OpenSSH private key file +host.secret_fields.choose_key_prompt: + zh-CN: 选择 SSH 私钥 + en: Choose an SSH private key +host.secret_fields.choose: + zh-CN: 选择… + en: Choose… + +host.snippet_draft.name: + zh-CN: 请输入名称 + en: Enter a name +host.snippet_draft.command: + zh-CN: 请输入命令 + en: Enter a command + +host.link.missing: + zh-CN: 启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。 + en: The launch arguments have no address to connect to. Use ssh://user@address[:port], or sftp:// to open SFTP. +host.link.malformed: + zh-CN: 链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。 + en: The link isn’t in the right format. Use ssh://user[:password]@address[:port], or sftp:// to open SFTP. +host.link.invalid_port: + zh-CN: 链接里的端口无效。 + en: The port in the link isn’t valid. +host.link.no_address: + zh-CN: 链接里没有地址。 + en: The link has no address. +host.link.unsupported_scheme: + zh-CN: 只支持 ssh:// 和 sftp:// 链接,不支持 %{scheme}://。 + en: Only ssh:// and sftp:// links are supported, not %{scheme}://. +host.link.no_user: + zh-CN: 链接里没有用户名,应为 %{scheme}://用户@地址[:端口]。 + en: The link has no username. Use %{scheme}://user@address[:port]. +host.link.unreadable: + zh-CN: 链接里有无法识别的字符。 + en: The link has characters that can’t be read. + +# 本地数据库: a change that stands in memory but is not on disk. +host.store.not_saved.new_host: + zh-CN: 新建主机未能保存到本地数据库:%{error} + en: "Couldn’t save the new host to the local database: %{error}" +host.store.not_saved.save_host: + zh-CN: 保存主机未能保存到本地数据库:%{error} + en: "Couldn’t save the host’s changes to the local database: %{error}" +host.store.not_saved.delete_host: + zh-CN: 删除主机未能保存到本地数据库:%{error} + en: "Couldn’t delete the host from the local database: %{error}" +host.store.not_saved.duplicate_host: + zh-CN: 复制主机未能保存到本地数据库:%{error} + en: "Couldn’t save the host’s copy to the local database: %{error}" +host.store.not_saved.host_order: + zh-CN: 保存主机顺序未能保存到本地数据库:%{error} + en: "Couldn’t save the order of the hosts to the local database: %{error}" +host.store.not_saved.new_group: + zh-CN: 新建分组未能保存到本地数据库:%{error} + en: "Couldn’t save the new group to the local database: %{error}" +host.store.not_saved.save_group: + zh-CN: 保存分组未能保存到本地数据库:%{error} + en: "Couldn’t save the group’s changes to the local database: %{error}" +host.store.not_saved.add_bookmark: + zh-CN: 添加书签未能保存到本地数据库:%{error} + en: "Couldn’t save the new bookmark to the local database: %{error}" +host.store.not_saved.delete_bookmark: + zh-CN: 删除书签未能保存到本地数据库:%{error} + en: "Couldn’t delete the bookmark from the local database: %{error}" +host.store.not_saved.bookmark_order: + zh-CN: 调整书签顺序未能保存到本地数据库:%{error} + en: "Couldn’t save the order of the bookmarks to the local database: %{error}" +host.store.not_saved.new_forward: + zh-CN: 新建端口转发未能保存到本地数据库:%{error} + en: "Couldn’t save the new port forward to the local database: %{error}" +host.store.not_saved.save_forward: + zh-CN: 保存端口转发未能保存到本地数据库:%{error} + en: "Couldn’t save the port forward’s changes to the local database: %{error}" +host.store.not_saved.delete_forward: + zh-CN: 删除端口转发未能保存到本地数据库:%{error} + en: "Couldn’t delete the port forward from the local database: %{error}" +host.store.not_saved.new_credential: + zh-CN: 新建凭据未能保存到本地数据库:%{error} + en: "Couldn’t save the new credential to the local database: %{error}" +host.store.not_saved.save_credential: + zh-CN: 保存凭据未能保存到本地数据库:%{error} + en: "Couldn’t save the credential’s changes to the local database: %{error}" +host.store.not_saved.delete_credential: + zh-CN: 删除凭据未能保存到本地数据库:%{error} + en: "Couldn’t delete the credential from the local database: %{error}" +host.store.not_saved.group_expanded: + zh-CN: 保存分组展开状态未能保存到本地数据库:%{error} + en: "Couldn’t save whether the group is expanded to the local database: %{error}" +host.store.not_saved.all_groups_expanded: + zh-CN: 保存所有分组展开状态未能保存到本地数据库:%{error} + en: "Couldn’t save whether the groups are expanded to the local database: %{error}" +host.store.not_saved.move_node: + zh-CN: 调整主机顺序未能保存到本地数据库:%{error} + en: "Couldn’t save the rearranged host list to the local database: %{error}" +host.store.not_saved.delete_group: + zh-CN: 删除分组未能保存到本地数据库:%{error} + en: "Couldn’t delete the group from the local database: %{error}" +host.store.not_saved.recent: + zh-CN: 记录最近连接未能保存到本地数据库:%{error} + en: "Couldn’t save the recent connections to the local database: %{error}" +host.store.not_saved.host_os: + zh-CN: 记录主机系统未能保存到本地数据库:%{error} + en: "Couldn’t save the host’s operating system to the local database: %{error}" +host.store.not_saved.new_snippet: + zh-CN: 新建命令片段未能保存到本地数据库:%{error} + en: "Couldn’t save the new snippet to the local database: %{error}" +host.store.not_saved.save_snippet: + zh-CN: 保存命令片段未能保存到本地数据库:%{error} + en: "Couldn’t save the snippet’s changes to the local database: %{error}" +host.store.not_saved.delete_snippet: + zh-CN: 删除命令片段未能保存到本地数据库:%{error} + en: "Couldn’t delete the snippet from the local database: %{error}" +host.store.not_saved.new_snippet_category: + zh-CN: 新建片段分类未能保存到本地数据库:%{error} + en: "Couldn’t save the new snippet category to the local database: %{error}" +host.store.not_saved.rename_snippet_category: + zh-CN: 重命名片段分类未能保存到本地数据库:%{error} + en: "Couldn’t save the renamed snippet category to the local database: %{error}" +host.store.not_saved.delete_snippet_category: + zh-CN: 删除片段分类未能保存到本地数据库:%{error} + en: "Couldn’t delete the snippet category from the local database: %{error}" + +host.store.copy_name: + zh-CN: "%{name} 副本" + en: "%{name} copy" +host.store.no_key_dir: + zh-CN: 没有可以保存私钥的目录 + en: There’s no folder to keep private keys in +host.store.key_not_deleted: + zh-CN: ShellRS 保存的私钥未能删除:%{error} + en: "Couldn’t delete a private key ShellRS keeps: %{error}" + +host.store.secret.password_write: + zh-CN: 密码未能写入系统钥匙串:%{error} + en: "Couldn’t save the password to the system keychain: %{error}" +host.store.secret.password_delete: + zh-CN: 密码未能从系统钥匙串删除:%{error} + en: "Couldn’t delete the password from the system keychain: %{error}" +host.store.secret.passphrase_write: + zh-CN: 私钥口令未能写入系统钥匙串:%{error} + en: "Couldn’t save the private key’s passphrase to the system keychain: %{error}" +host.store.secret.passphrase_delete: + zh-CN: 私钥口令未能从系统钥匙串删除:%{error} + en: "Couldn’t delete the private key’s passphrase from the system keychain: %{error}" +host.store.secret.credential_write: + zh-CN: 凭据的密码未能写入系统钥匙串:%{error} + en: "Couldn’t save the credential’s password to the system keychain: %{error}" +host.store.secret.credential_delete: + zh-CN: 凭据的密码未能从系统钥匙串删除:%{error} + en: "Couldn’t delete the credential’s password from the system keychain: %{error}" +host.store.secret.proxy_write: + zh-CN: 代理的密码未能写入系统钥匙串:%{error} + en: "Couldn’t save the proxy’s password to the system keychain: %{error}" +host.store.secret.proxy_delete: + zh-CN: 代理的密码未能从系统钥匙串删除:%{error} + en: "Couldn’t delete the proxy’s password from the system keychain: %{error}" +host.store.secret.temporary: + zh-CN: 这次连接的密码未能记下:%{error} + en: "Couldn’t keep the password for this connection: %{error}" diff --git a/locales/secrets.yml b/locales/secrets.yml new file mode 100644 index 0000000..73960fb --- /dev/null +++ b/locales/secrets.yml @@ -0,0 +1,38 @@ +_version: 2 + +# 钥匙串: what the system keychain says when it fails. + +secrets.unavailable: + zh-CN: 系统钥匙串不可用,密码无法保存 + en: The system keychain isn’t available, so the password can’t be saved + +secrets.keychain.open_failed: + zh-CN: 打开系统钥匙串条目失败:%{reason} + en: "Couldn’t open the system keychain entry: %{reason}" +secrets.keychain.read_failed: + zh-CN: 读取系统钥匙串条目失败:%{reason} + en: "Couldn’t read the system keychain entry: %{reason}" +secrets.keychain.write_failed: + zh-CN: 写入系统钥匙串条目失败:%{reason} + en: "Couldn’t write the system keychain entry: %{reason}" +secrets.keychain.delete_failed: + zh-CN: 删除系统钥匙串条目失败:%{reason} + en: "Couldn’t delete the system keychain entry: %{reason}" + +secrets.keychain.no_store: + zh-CN: 系统钥匙串不可用 + en: the system keychain isn’t available +secrets.keychain.no_access: + zh-CN: 系统钥匙串被锁定或拒绝访问 + en: the system keychain is locked or denied access +secrets.keychain.bad_encoding: + zh-CN: 已保存的内容不是有效的 UTF-8 文本 + en: what’s saved isn’t valid UTF-8 text +secrets.keychain.ambiguous.one: + en: the keychain has %{count} entry with this name +secrets.keychain.ambiguous.other: + zh-CN: 钥匙串里有 %{count} 条同名条目 + en: the keychain has %{count} entries with this name +secrets.keychain.too_long: + zh-CN: 超过系统钥匙串 %{limit} 个字符的长度上限 + en: it’s longer than the system keychain’s limit of %{limit} characters diff --git a/locales/workspace.yml b/locales/workspace.yml index 4337bed..8635667 100644 --- a/locales/workspace.yml +++ b/locales/workspace.yml @@ -55,9 +55,6 @@ workspace.status.connecting_to: workspace.status.disconnected_from: zh-CN: 未连接 %{name} en: Not connected to %{name} -workspace.status.not_connected: - zh-CN: 未连接 - en: Not connected # How the connection stands, then what went wrong. workspace.status.problem: zh-CN: "%{state}:%{problem}" diff --git a/src/connection.rs b/src/connection.rs index 92e083c..87bfa7d 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -4,6 +4,8 @@ use std::time::Duration; use zeroize::Zeroizing; +use crate::i18n::t; + /// One field requested by an SSH keyboard-interactive challenge. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ConnectionPromptField { @@ -72,24 +74,30 @@ impl UnknownHostPrompt { /// What the trust dialog tells the user about the key. pub fn description(&self) -> String { - format!( - "{}主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。", - jump_host_line(self.jump_host.as_deref()), - self.host, - self.port, - self.algorithm, - self.fingerprint, - ) + let (host, port, algorithm, fingerprint) = + (&self.host, self.port, &self.algorithm, &self.fingerprint); + match &self.jump_host { + // A line above the rest names the jump host. + Some(jump_host) => t!( + "connection.unknown_host.jump_host_description", + jump_host = jump_host, + host = host, + port = port, + algorithm = algorithm, + fingerprint = fingerprint + ), + None => t!( + "connection.unknown_host.description", + host = host, + port = port, + algorithm = algorithm, + fingerprint = fingerprint + ), + } + .to_string() } } -/// The line that opens a question about a jump host, naming it. -fn jump_host_line(jump_host: Option<&str>) -> String { - jump_host - .map(|name| format!("跳板主机:{name}\n")) - .unwrap_or_default() -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct HostKeyChangedPrompt { host: String, @@ -203,10 +211,15 @@ impl ConnectionPromptKind { Self::HostKeyChanged(prompt) => prompt.jump_host = Some(name.to_string()), Self::Authentication(prompt) => { prompt.instructions = if prompt.instructions.trim().is_empty() { - format!("跳板主机「{name}」") + t!("connection.jump_host.instructions", name = name) } else { - format!("跳板主机「{name}」:{}", prompt.instructions) - }; + t!( + "connection.jump_host.with_instructions", + name = name, + instructions = prompt.instructions + ) + } + .to_string(); } } self @@ -230,7 +243,7 @@ impl ConnectionSecret { impl std::fmt::Debug for ConnectionSecret { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("ConnectionSecret([已隐藏])") + formatter.write_str("ConnectionSecret()") } } @@ -248,7 +261,7 @@ impl std::fmt::Debug for ConnectionPromptReply { Self::TrustAndSave => formatter.write_str("TrustAndSave"), Self::Answers(answers) => formatter .debug_tuple("Answers") - .field(&format_args!("{} 个已隐藏答案", answers.len())) + .field(&format_args!("{} redacted", answers.len())) .finish(), Self::Cancel => formatter.write_str("Cancel"), } @@ -418,7 +431,7 @@ impl Latency { pub fn label(self) -> String { match self { Latency::Measured(rtt) => format!("{} ms", rtt.as_millis()), - Latency::TimedOut => "超时".into(), + Latency::TimedOut => t!("connection.latency.timed_out").to_string(), } } } @@ -451,3 +464,33 @@ mod latency_tests { assert_eq!(Latency::TimedOut.label(), "超时"); } } + +#[cfg(test)] +mod prompt_tests { + use super::ConnectionPromptKind; + + fn description(jump_host: Option<&str>) -> String { + match ConnectionPromptKind::unknown_host("10.0.0.5", 22, "ssh-ed25519", "SHA256:abc") + .at_jump_host(jump_host) + { + ConnectionPromptKind::UnknownHost(prompt) => prompt.description(), + _ => unreachable!("an unknown host stays one"), + } + } + + #[test] + fn a_new_host_key_is_described_line_by_line() { + assert_eq!( + description(Some("堡垒机")), + "跳板主机:堡垒机\n主机:10.0.0.5:22\n算法:ssh-ed25519\n\ + SHA-256 指纹:SHA256:abc\n\n请先确认该指纹来自可信渠道。" + ); + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + assert_eq!( + description(None), + "Host: 10.0.0.5:22\nAlgorithm: ssh-ed25519\nSHA-256 fingerprint: SHA256:abc\n\n\ + Make sure the fingerprint matches one from a source you trust." + ); + } +} diff --git a/src/credential/credential_dialog.rs b/src/credential/credential_dialog.rs index 1055b41..4bbfadd 100644 --- a/src/credential/credential_dialog.rs +++ b/src/credential/credential_dialog.rs @@ -15,23 +15,31 @@ use zeroize::Zeroizing; use crate::host::{ CredentialDraft, CredentialId, CredentialKind, DEFAULT_USER, GeneratedKey, HostStore, - KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, read_public_key, + KeyAlgorithm, PastedKey, PastedKeyError, SecretFields, join_sentences, read_public_key, }; +use crate::i18n::{t, tn}; use crate::secrets::SecretRef; use crate::shared::{ Segment, SegmentedControl, commit_footer, dismiss_form_error, form_error_notification, }; /// Where the SSH agent is found, as the form explains it. -#[cfg(windows)] -const AGENT_NOTE: &str = "登录时使用 Windows 的 OpenSSH Authentication Agent 服务中的密钥。"; -#[cfg(not(windows))] -const AGENT_NOTE: &str = "登录时使用 SSH Agent(SSH_AUTH_SOCK 所指)中的密钥。"; +fn agent_note() -> SharedString { + if cfg!(windows) { + t!("credential.dialog.agent_note_windows") + } else { + t!("credential.dialog.agent_note") + } +} /// What an empty passphrase field means for a key that already exists, and /// for one about to be generated. -const ASK_FOR_PASSPHRASE: &str = "留空则每次连接都询问"; -const NO_PASSPHRASE: &str = "留空则不加密"; +fn passphrase_placeholder(source: KeySource) -> SharedString { + match source { + KeySource::Generate => t!("credential.dialog.no_passphrase"), + KeySource::File | KeySource::Paste => t!("credential.dialog.ask_for_passphrase"), + } +} /// Which credential dialog to open. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -57,11 +65,11 @@ enum KeySource { impl KeySource { const ALL: [KeySource; 3] = [KeySource::File, KeySource::Paste, KeySource::Generate]; - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - KeySource::File => "本机文件", - KeySource::Paste => "粘贴", - KeySource::Generate => "生成新密钥", + KeySource::File => t!("credential.key_source.file"), + KeySource::Paste => t!("credential.key_source.paste"), + KeySource::Generate => t!("credential.key_source.generate"), } } } @@ -171,7 +179,7 @@ impl CredentialForm { let name = cx.new(|cx| { InputState::new(window, cx) - .placeholder("例如 生产环境 root") + .placeholder(t!("credential.dialog.name_placeholder")) .default_value(draft.name.clone()) }); let user = cx.new(|cx| { @@ -192,13 +200,13 @@ impl CredentialForm { } if source == KeySource::Generate { fields.update(cx, |fields, cx| { - fields.set_passphrase_placeholder(NO_PASSPHRASE, window, cx) + fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx) }); } let key_text = cx.new(|cx| { TextareaState::new(window, cx) .auto_grow(4, 8) - .placeholder("粘贴私钥,以 -----BEGIN 开头") + .placeholder(t!("credential.dialog.key_text_placeholder")) }); let subscriptions = vec![ cx.observe(&fields, |this, _, cx| { @@ -266,13 +274,9 @@ impl CredentialForm { self.source = source; // A passphrase belongs to one key; whatever the field held was for // the key the form showed before. - let placeholder = match source { - KeySource::Generate => NO_PASSPHRASE, - KeySource::File | KeySource::Paste => ASK_FOR_PASSPHRASE, - }; self.fields.update(cx, |fields, cx| { fields.clear_passphrase(window, cx); - fields.set_passphrase_placeholder(placeholder, window, cx); + fields.set_passphrase_placeholder(passphrase_placeholder(source), window, cx); }); if source == KeySource::Generate { self.generate(cx); @@ -315,7 +319,9 @@ impl CredentialForm { } let generated = match made { Ok(key) => Generated::Ready(Arc::new(key)), - Err(error) => Generated::Failed(format!("无法生成密钥:{error}").into()), + Err(error) => { + Generated::Failed(t!("credential.dialog.generate_failed", error = error)) + } }; this.generated = Some((algorithm, generated)); cx.notify(); @@ -379,7 +385,8 @@ impl CredentialForm { // A pasted or generated key's file is only written once everything // else checks out; until then the draft stands in for it. let key_path = if key_from_text { - "(新私钥)".to_string() + // A stand-in, never shown or kept. + "(新私钥)".to_string() // i18n: keep } else { key_file }; @@ -417,13 +424,17 @@ impl CredentialForm { let key = match &self.generated { Some((algorithm, Generated::Ready(key))) if *algorithm == self.algorithm => key.clone(), Some((_, Generated::Failed(error))) => return self.fail(error.clone(), window, cx), - _ => return self.fail("密钥还在生成,请稍候", window, cx), + _ => return self.fail(t!("credential.dialog.still_generating"), window, cx), }; let comment = draft.name.clone(); if passphrase.is_empty() { return match key.encode(&comment, "") { Ok(text) => self.save(draft, Some(text), None, Some(None), window, cx), - Err(error) => self.fail(format!("无法保存私钥:{error}"), window, cx), + Err(error) => self.fail( + t!("credential.dialog.encode_failed", error = error), + window, + cx, + ), }; } // Deriving the encryption key from a passphrase is slow on purpose, @@ -443,7 +454,11 @@ impl CredentialForm { let passphrase = Some(Some(passphrase.to_string())); this.save(draft, Some(text), None, passphrase, window, cx) } - Err(error) => this.fail(format!("无法加密私钥:{error}"), window, cx), + Err(error) => this.fail( + t!("credential.dialog.encrypt_failed", error = error), + window, + cx, + ), }; if saved { // Closing it from here skips the dialog's `on_close`. @@ -479,7 +494,13 @@ impl CredentialForm { if let Some(text) = key_text { match self.store.read(cx).save_private_key(editing, &text) { Ok(path) => draft.key_path = Some(path), - Err(error) => return self.fail(format!("私钥未能保存:{error}"), window, cx), + Err(error) => { + return self.fail( + t!("credential.dialog.key_not_saved", error = error), + window, + cx, + ); + } } } let passphrase_change = passphrase_change @@ -513,19 +534,19 @@ impl CredentialForm { } /// What editing the credential does to the hosts using it. - fn usage_note(&self) -> Option { + fn usage_note(&self) -> Option { if self.hosts == 0 { return None; } - let mut note = format!("有 {} 台主机使用此凭据。", self.hosts); + let mut note = vec![tn!("credential.dialog.usage", self.hosts)]; // A new key is no use to them until their servers know it. if self.kind == CredentialKind::Key && self.source != KeySource::File { - note.push_str("换成新私钥后,要把它的公钥加到这些主机上,它们才能再登录。"); + note.push(t!("credential.dialog.usage_new_key")); } if self.connected > 0 { - note.push_str("修改用户名、类型或私钥文件后,其中已连接的主机会重新连接。"); + note.push(t!("credential.dialog.usage_reconnects")); } - Some(note) + join_sentences(note) } /// Whether saving now would delete the key ShellRS keeps for the @@ -600,7 +621,7 @@ impl CredentialForm { Segment::new(each.label()).when(each != KeySource::File && !can_keep, |segment| { segment .disabled(true) - .tooltip("本地数据库没有打开,这次运行无法保存私钥") + .tooltip(t!("credential.dialog.no_key_dir")) }) })); // A generated key has nothing to fill in under the choice. @@ -630,17 +651,17 @@ impl CredentialForm { }; let mut form = form.child( Field::new() - .label("私钥") + .label(t!("credential.dialog.private_key")) .required(true) .child(v_flex().w_full().gap_2().child(sources).children(key)) .when(kept_here, |field| { - field.description("这把私钥由 ShellRS 保存,删除凭据时会一起删除。") + field.description(t!("credential.dialog.kept_key")) }), ); if source == KeySource::Generate { form = form.child( Field::new() - .label("算法") + .label(t!("credential.dialog.algorithm")) .child( SegmentedControl::new("credential-key-algorithm") .selected_index( @@ -656,41 +677,43 @@ impl CredentialForm { .segments(KeyAlgorithm::ALL.map(|each| Segment::new(each.label()))), ) .when(self.algorithm == KeyAlgorithm::Rsa, |field| { - field.description("只在服务器不支持 Ed25519 时选它(OpenSSH 6.5 以前)。") + field.description(t!("credential.dialog.rsa_note")) }), ); } form = match self.public_key(cx) { PublicKeyView::Line(line) => form.child( Field::new() - .label("公钥") + .label(t!("credential.dialog.public_key")) .child(public_key_box(line, cx)) .when(source == KeySource::Generate, |field| { - field.description( - "把公钥加到服务器的 ~/.ssh/authorized_keys,就能用这把密钥登录。", - ) + field.description(t!("credential.dialog.public_key_note")) }), ), PublicKeyView::Generating => form.child( - Field::new().label("公钥").child( - div() - .id("credential-public-key-pending") - .test_support() - .text_sm() - .text_color(muted) - .child("正在生成密钥…"), - ), + Field::new() + .label(t!("credential.dialog.public_key")) + .child( + div() + .id("credential-public-key-pending") + .test_support() + .text_sm() + .text_color(muted) + .child(t!("credential.dialog.generating")), + ), ), PublicKeyView::Failed(error) => form.child( - Field::new().label("公钥").child( - div() - .id("credential-generate-error") - .test_support() - .aria_label(error.clone()) - .text_sm() - .text_color(theme.danger) - .child(error), - ), + Field::new() + .label(t!("credential.dialog.public_key")) + .child( + div() + .id("credential-generate-error") + .test_support() + .aria_label(error.clone()) + .text_sm() + .text_color(theme.danger) + .child(error), + ), ), PublicKeyView::Unknown => form, }; @@ -699,7 +722,7 @@ impl CredentialForm { if source != KeySource::Paste || encrypted_paste { form = form.child( Field::new() - .label("私钥口令") + .label(t!("credential.dialog.passphrase")) .child(fields.passphrase_input("credential-passphrase")), ); } @@ -736,7 +759,7 @@ fn public_key_box(line: SharedString, cx: &App) -> impl IntoElement + use<> { .child( Clipboard::new("copy-public-key") .value(line) - .tooltip("复制公钥"), + .tooltip(t!("credential.dialog.copy_public_key")), ) } @@ -747,13 +770,13 @@ impl Render for CredentialForm { let keychain = self.fields.read(cx).keychain_available(); let secret_note = match kind { CredentialKind::Agent => None, - _ if keychain => Some("密码和口令保存在系统钥匙串,不会写入 ShellRS 的数据库。"), - _ => Some("系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。"), + _ if keychain => Some(t!("credential.dialog.keychain_note")), + _ => Some(t!("credential.dialog.no_keychain_note")), }; let drops_kept_key = self.drops_kept_key(cx); let form = Form::new() .child( - Field::new().label("类型").child( + Field::new().label(t!("credential.dialog.kind")).child( SegmentedControl::new("credential-kind") .selected_index(CredentialKind::ALL.iter().position(|each| *each == kind)) .on_change(cx.listener(|this, ix: &usize, _, cx| { @@ -766,19 +789,19 @@ impl Render for CredentialForm { ) .child( Field::new() - .label("名称") + .label(t!("credential.dialog.name")) .required(true) .child(Input::new(&self.name).id("credential-name").small()), ) .child( Field::new() - .label("用户名") + .label(t!("credential.dialog.user")) .child(Input::new(&self.user).id("credential-user").small()), ); let form = match kind { CredentialKind::Password => form.child( Field::new() - .label("密码") + .label(t!("credential.dialog.password")) .child(self.fields.read(cx).password_input("credential-password")), ), CredentialKind::Key => self.key_fields(form, cx), @@ -793,22 +816,22 @@ impl Render for CredentialForm { div() .id("credential-agent-note") .test_support() - .aria_label(AGENT_NOTE) + .aria_label(agent_note()) .text_sm() .text_color(muted) - .child(AGENT_NOTE), + .child(agent_note()), ) }) .when_some(secret_note, |view, note| { view.child(div().text_sm().text_color(muted).child(note)) }) .when(drops_kept_key, |view| { - let note = "保存后,ShellRS 保存的原私钥会被删除。"; + let note = t!("credential.dialog.drops_kept_key"); view.child( div() .id("credential-kept-key-note") .test_support() - .aria_label(note) + .aria_label(note.clone()) .text_sm() .text_color(cx.theme().warning) .child(note), @@ -832,7 +855,7 @@ impl Render for CredentialForm { .test_support() .text_sm() .text_color(muted) - .child("正在用口令加密私钥…"), + .child(t!("credential.dialog.encrypting")), ) }) } @@ -848,13 +871,16 @@ pub fn open_credential_dialog( ) { let form = cx.new(|cx| CredentialForm::new(dialog, store, window, cx)); let editing = matches!(dialog, CredentialDialog::Edit(_)); - let title: SharedString = if editing { - "编辑凭据" + let title = if editing { + t!("credential.dialog.title_edit") } else { - "新建凭据" - } - .into(); - let commit_label: SharedString = if editing { "保存" } else { "创建" }.into(); + t!("credential.dialog.title_new") + }; + let commit_label = if editing { + t!("common.save") + } else { + t!("credential.dialog.create") + }; window.open_dialog(cx, { let form = form.clone(); move |dialog, _, _| { diff --git a/src/credential/credential_panel.rs b/src/credential/credential_panel.rs index 01dec17..0530299 100644 --- a/src/credential/credential_panel.rs +++ b/src/credential/credential_panel.rs @@ -19,6 +19,7 @@ use crate::app::{ SelectNextCredential, SelectPreviousCredential, }; use crate::host::{CredentialId, CredentialKind, HostStore, matches_credential_query}; +use crate::i18n::{UiLocale, t, tn}; use crate::shared::{RowTooltip, RowTooltips}; /// The credential list the left dock shows in place of the hosts: every @@ -69,7 +70,7 @@ impl CredentialPanel { ) -> Self { let search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("搜索凭据") + .placeholder(t!("credential.panel.search")) .clean_on_escape() }); let known = store @@ -80,6 +81,11 @@ impl CredentialPanel { .collect(); let subscriptions = vec![ cx.observe(&store, |this, _, cx| this.on_store_changed(cx)), + cx.observe_global_in::(window, |this, window, cx| { + this.search.update(cx, |search, cx| { + search.set_placeholder(t!("credential.panel.search"), window, cx) + }); + }), cx.subscribe(&search, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.query = state.read(cx).value().to_string(); @@ -136,7 +142,7 @@ impl CredentialPanel { h_flex() .gap_1() .child(Icon::new(CatalogIcon::KeyRound).small()) - .child("凭据") + .child(t!("credential.panel.title")) } /// The dock's toolbar while this list is up. @@ -145,8 +151,8 @@ impl CredentialPanel { vec![ Button::new("new-credential") .icon(IconName::Plus) - .tooltip("新建凭据…") - .accessibility_label("新建凭据…") + .tooltip(t!("credential.menu.new")) + .accessibility_label(t!("credential.menu.new")) .on_click(move |_, window, cx| target.dispatch_action(&NewCredential, window, cx)), ] } @@ -162,14 +168,14 @@ impl CredentialPanel { let hosts = store.hosts_using(credential.id).count(); let summary = credential.summary(store.key_dir()); let detail = match hosts { - 0 => summary, - hosts => format!("{summary} · {hosts} 台主机"), + 0 => summary.into(), + hosts => tn!("credential.panel.detail", hosts, summary = summary), }; CredentialRow { id: credential.id, kind: credential.kind, name: credential.name.clone(), - detail: detail.into(), + detail, key_path: credential.key_path.clone(), } }) @@ -226,12 +232,12 @@ impl CredentialPanel { let muted = cx.theme().muted_foreground; let target = self.target.clone(); let (title, hint, buttons) = if !self.store.read(cx).credentials().is_empty() { - ("没有匹配的凭据", None, None) + (t!("credential.panel.no_matches"), None, None) } else { let generate = target.clone(); ( - "还没有凭据", - Some("保存一套用户名和密码、私钥或 SSH Agent,让多台主机共用。"), + t!("credential.panel.empty"), + Some(t!("credential.panel.empty_hint")), Some( h_flex() .gap_2() @@ -241,7 +247,7 @@ impl CredentialPanel { Button::new("credential-empty-new") .small() .icon(IconName::Plus) - .label("新建凭据…") + .label(t!("credential.menu.new")) .on_click(move |_, window, cx| { target.dispatch_action(&NewCredential, window, cx) }), @@ -250,7 +256,7 @@ impl CredentialPanel { Button::new("credential-empty-generate") .small() .icon(CatalogIcon::KeyRound) - .label("生成密钥…") + .label(t!("credential.menu.generate")) .on_click(move |_, window, cx| { generate.dispatch_action(&GenerateCredentialKey, window, cx) }), @@ -261,7 +267,7 @@ impl CredentialPanel { v_flex() .id("credential-empty") .test_support() - .aria_label(title) + .aria_label(title.clone()) .items_center() .gap_2() .px_4() @@ -374,31 +380,31 @@ fn build_context_menu(hit: Option<(CredentialId, CredentialKind)>, menu: PopupMe let Some((id, kind)) = hit else { return menu .menu_with_icon( - "新建凭据…", + t!("credential.menu.new"), Icon::new(IconName::Plus), Box::new(NewCredential), ) .menu_with_icon( - "生成密钥…", + t!("credential.menu.generate"), Icon::new(CatalogIcon::KeyRound), Box::new(GenerateCredentialKey), ); }; menu.menu_with_icon( - "编辑凭据…", + t!("credential.menu.edit"), Icon::new(CatalogIcon::Pencil), Box::new(EditCredential(id)), ) .when(kind == CredentialKind::Key, |menu| { menu.menu_with_icon( - "复制公钥", + t!("credential.menu.copy_public_key"), Icon::new(CatalogIcon::ClipboardCopy), Box::new(CopyCredentialPublicKey(id)), ) }) .separator() .menu_with_icon( - "删除", + t!("common.delete"), Icon::new(CatalogIcon::Trash), Box::new(DeleteCredential(id)), ) diff --git a/src/forward/diagram.rs b/src/forward/diagram.rs index 5ea2ffc..37ee33b 100644 --- a/src/forward/diagram.rs +++ b/src/forward/diagram.rs @@ -16,6 +16,7 @@ use gpui_kit::*; use crate::app::CatalogIcon; use crate::host::ForwardKind; +use crate::i18n::t; /// One round of the dot: across the tunnel, on to the target, then a rest /// before it sets out again. The rounds go on for as long as the picture is @@ -47,20 +48,27 @@ pub(super) enum Machine { /// One stop: the tunnel's entrance, its exit, or what it reaches. #[derive(Clone, Debug, PartialEq, Eq)] pub(super) struct Stop { - pub role: &'static str, + pub role: SharedString, pub machine: Machine, pub name: SharedString, /// The address at this stop, or a word on what it does. pub detail: SharedString, } -/// What the picture calls this computer and the server the rule goes -/// through; [`explain`] says the same words. They name the part each plays, +/// What the picture calls each machine, this computer and the server the +/// rule goes through among them; [`explain`] says the same words. They name +/// the part each plays, /// not the saved host: its name is whatever the user typed and need not read /// as a machine, and the field under the picture already says which host it /// is. -const LOCAL: &str = "本机"; -const SERVER: &str = "SSH 服务器"; +fn machine_name(machine: Machine) -> SharedString { + match machine { + Machine::Local => t!("forward.diagram.local"), + Machine::Server => t!("forward.diagram.server"), + Machine::Target => t!("forward.diagram.target_service"), + Machine::Anywhere => t!("forward.diagram.anywhere"), + } +} /// The three stops of a forward, in the order a connection passes them. /// @@ -69,66 +77,61 @@ const SERVER: &str = "SSH 服务器"; /// and a remote forward differ in which machine is the entrance and which /// the exit; a dynamic forward differs in the target. pub(super) fn stops(kind: ForwardKind, bind: &SharedString, target: &SharedString) -> [Stop; 3] { - let (local, server) = ((Machine::Local, LOCAL), (Machine::Server, SERVER)); let (entrance, exit) = match kind { - ForwardKind::Local | ForwardKind::Dynamic => (local, server), - ForwardKind::Remote => (server, local), + ForwardKind::Local | ForwardKind::Dynamic => (Machine::Local, Machine::Server), + ForwardKind::Remote => (Machine::Server, Machine::Local), }; let reached = match kind { ForwardKind::Dynamic => Stop { - role: "目标", + role: t!("forward.diagram.target"), machine: Machine::Anywhere, - name: "任意地址".into(), - detail: "由应用指定".into(), + name: machine_name(Machine::Anywhere), + detail: t!("forward.diagram.app_chooses"), }, ForwardKind::Local | ForwardKind::Remote => Stop { - role: "目标", + role: t!("forward.diagram.target"), machine: Machine::Target, - name: "目标服务".into(), + name: machine_name(Machine::Target), detail: target.clone(), }, }; [ Stop { - role: "隧道入口", - machine: entrance.0, - name: entrance.1.into(), + role: t!("forward.diagram.entrance"), + machine: entrance, + name: machine_name(entrance), detail: match kind { ForwardKind::Dynamic => format!("SOCKS {bind}").into(), - ForwardKind::Local | ForwardKind::Remote => format!("监听 {bind}").into(), + ForwardKind::Local | ForwardKind::Remote => { + t!("forward.diagram.listens", bind = bind) + } }, }, Stop { - role: "隧道出口", - machine: exit.0, - name: exit.1.into(), - detail: "代为连接目标".into(), + role: t!("forward.diagram.exit"), + machine: exit, + name: machine_name(exit), + detail: t!("forward.diagram.connects_onward"), }, reached, ] } /// What the rule does, as one sentence with the form's own addresses. -pub(super) fn explain(kind: ForwardKind, bind: &str, target: &str) -> String { +pub(super) fn explain(kind: ForwardKind, bind: &str, target: &str) -> SharedString { match kind { - ForwardKind::Local => { - format!("在本机连接 {bind},就等于从 SSH 服务器连接 {target}。") - } - ForwardKind::Remote => { - format!("在 SSH 服务器上连接 {bind},就等于从本机连接 {target}。") - } - ForwardKind::Dynamic => { - format!("把应用的 SOCKS5 代理设为 {bind},它的连接都从 SSH 服务器发出。") - } + ForwardKind::Local => t!("forward.explain.local", bind = bind, target = target), + ForwardKind::Remote => t!("forward.explain.remote", bind = bind, target = target), + ForwardKind::Dynamic => t!("forward.explain.dynamic", bind = bind), } } /// What the kind is for, on its card: the line people choose by. -pub(super) fn purpose(kind: ForwardKind) -> &'static str { +pub(super) fn purpose(kind: ForwardKind) -> SharedString { match kind { - ForwardKind::Local => "在本机访问服务器那边的服务", - ForwardKind::Remote => "让服务器那边访问本机的服务", - ForwardKind::Dynamic => "把服务器当作 SOCKS 代理", + ForwardKind::Local => t!("forward.purpose.local"), + ForwardKind::Remote => t!("forward.purpose.remote"), + ForwardKind::Dynamic => t!("forward.purpose.dynamic"), } } @@ -142,11 +145,11 @@ pub(super) fn ssh_flag(kind: ForwardKind) -> &'static str { } /// A situation each kind is typically the answer to. -pub(super) fn typical_use(kind: ForwardKind) -> &'static str { +pub(super) fn typical_use(kind: ForwardKind) -> SharedString { match kind { - ForwardKind::Local => "常见用途:用本机的客户端连接只对服务器开放的数据库或内网后台。", - ForwardKind::Remote => "常见用途:把本机正在开发的服务给服务器那边访问,或接收 Webhook。", - ForwardKind::Dynamic => "常见用途:让浏览器经由服务器上网,不必为每个地址单独建转发。", + ForwardKind::Local => t!("forward.typical_use.local"), + ForwardKind::Remote => t!("forward.typical_use.remote"), + ForwardKind::Dynamic => t!("forward.typical_use.dynamic"), } } @@ -237,7 +240,11 @@ impl RenderOnce for ForwardDiagram { .items_stretch() .gap_2() .child(render_stop(entrance, cx)) - .child(render_hop(Some("SSH 隧道"), tunnel_dot, cx)) + .child(render_hop( + Some(t!("forward.diagram.tunnel")), + tunnel_dot, + cx, + )) .child(render_stop(exit, cx)) .child(render_hop(None, plain_dot, cx)) .child(render_stop(reached, cx)) @@ -304,7 +311,7 @@ fn render_stop(stop: Stop, cx: &App) -> impl IntoElement { /// The link between two stops: a line with an arrow at its end, and the dot /// on it while it crosses this hop. The SSH hop is labelled and drawn /// heavier; the hop from the exit to the target is an ordinary connection. -fn render_hop(label: Option<&'static str>, dot: Option, cx: &App) -> impl IntoElement { +fn render_hop(label: Option, dot: Option, cx: &App) -> impl IntoElement { let theme = cx.theme(); let tunnel = label.is_some(); let color = if tunnel { @@ -328,7 +335,7 @@ fn render_hop(label: Option<&'static str>, dot: Option, cx: &App) -> impl I row.child(Icon::new(CatalogIcon::Lock).xsmall()) .child(label) }) - .when(!tunnel, |row| row.child("普通连接")), + .when(!tunnel, |row| row.child(t!("forward.diagram.plain"))), ) .child( h_flex() diff --git a/src/forward/forward_dialog.rs b/src/forward/forward_dialog.rs index 67678b2..4829134 100644 --- a/src/forward/forward_dialog.rs +++ b/src/forward/forward_dialog.rs @@ -17,7 +17,9 @@ use super::{ }; use crate::host::{ DEFAULT_BIND_HOST, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, HostId, HostStore, + join_sentences, }; +use crate::i18n::t; use crate::shared::{commit_footer, dismiss_form_error, form_error_notification, parse_port}; /// The dialog's width in rems: room for the three stops of the diagram side @@ -81,7 +83,13 @@ impl ForwardForm { hosts.iter().map(|host| host.id).collect::>(), hosts .iter() - .map(|host| SharedString::from(format!("{}({})", host.name, host.endpoint()))) + .map(|host| { + t!( + "forward.dialog.host_option", + name = host.name, + endpoint = host.endpoint() + ) + }) .collect::>(), ) }; @@ -97,7 +105,7 @@ impl ForwardForm { let name = cx.new(|cx| { InputState::new(window, cx) - .placeholder("可选,留空显示转发摘要") + .placeholder(t!("forward.dialog.name_placeholder")) .default_value(draft.as_ref().map(|d| d.name.clone()).unwrap_or_default()) }); let host = cx.new(|cx| { @@ -226,8 +234,10 @@ impl ForwardForm { /// Validate and write to the store. Returns whether the dialog may close. pub fn commit(&mut self, window: &mut Window, cx: &mut Context) -> bool { let draft = match self.draft(cx) { - Some(draft) => draft.validated().map_err(|error| error.to_string()), - None => Err("请选择端口转发经由的主机".to_string()), + Some(draft) => draft + .validated() + .map_err(|error| SharedString::from(error.to_string())), + None => Err(t!("forward.dialog.choose_host_error")), }; let draft = match draft { Ok(draft) => draft, @@ -243,7 +253,7 @@ impl ForwardForm { }); if !saved { // The host, or the rule itself, was deleted meanwhile. - window.push_notification(form_error_notification("所选主机已不存在,请重新选择"), cx); + window.push_notification(form_error_notification(t!("forward.dialog.host_gone")), cx); return false; } true @@ -280,7 +290,11 @@ impl ForwardForm { .map(|(kind_ix, kind)| { let checked = kind == self.kind; Radio::new(kind.as_str()) - .label(format!("{}({})", kind.label(), ssh_flag(kind))) + .label(t!( + "forward.dialog.kind_card", + kind = kind.label(), + flag = ssh_flag(kind) + )) .small() // Three cards sharing one row, equally wide and equally // tall; the chosen one keeps its outline, so the choice @@ -327,7 +341,7 @@ impl ForwardForm { let theme = cx.theme(); let bind = Self::endpoint_text(&self.bind_host, &self.bind_port, cx); let target = Self::endpoint_text(&self.target_host, &self.target_port, cx); - let sentence: SharedString = explain(self.kind, &bind, &target).into(); + let sentence = explain(self.kind, &bind, &target); v_flex() .gap_3() .p_3() @@ -356,27 +370,33 @@ impl ForwardForm { fn render_fields(&self) -> impl IntoElement { let (bind_label, target_label) = match self.kind { - ForwardKind::Local => ("本机监听地址", "目标地址(从服务器访问)"), - ForwardKind::Remote => ("服务器监听地址", "目标地址(从本机访问)"), - ForwardKind::Dynamic => ("本机监听地址(SOCKS 代理)", ""), + ForwardKind::Local => ( + t!("forward.dialog.bind_local"), + t!("forward.dialog.target_from_server"), + ), + ForwardKind::Remote => ( + t!("forward.dialog.bind_server"), + t!("forward.dialog.target_from_local"), + ), + ForwardKind::Dynamic => (t!("forward.dialog.bind_socks"), SharedString::default()), }; Form::new() .columns(4) .child( Field::new() - .label("名称") + .label(t!("forward.dialog.name")) .col_span(4) .child(Input::new(&self.name).id("forward-name").small()), ) .child( Field::new() - .label("经由主机") + .label(t!("forward.dialog.host")) .required(true) .col_span(4) .child( Select::new(&self.host) .id("forward-host") - .placeholder("请选择主机") + .placeholder(t!("forward.dialog.choose_host")) .small(), ), ) @@ -389,7 +409,7 @@ impl ForwardForm { ) .child( Field::new() - .label("端口") + .label(t!("forward.dialog.port")) .required(true) .child(Input::new(&self.bind_port).id("forward-bind-port").small()), ) @@ -406,11 +426,14 @@ impl ForwardForm { ), ) .child( - Field::new().label("端口").required(true).child( - Input::new(&self.target_port) - .id("forward-target-port") - .small(), - ), + Field::new() + .label(t!("forward.dialog.port")) + .required(true) + .child( + Input::new(&self.target_port) + .id("forward-target-port") + .small(), + ), ) }) } @@ -424,10 +447,10 @@ fn bind_port_placeholder(kind: ForwardKind) -> &'static str { } } -fn target_host_placeholder(kind: ForwardKind) -> &'static str { +fn target_host_placeholder(kind: ForwardKind) -> SharedString { match kind { - ForwardKind::Local | ForwardKind::Dynamic => "例如 db.internal 或 127.0.0.1", - ForwardKind::Remote => "例如 localhost", + ForwardKind::Local | ForwardKind::Dynamic => t!("forward.dialog.target_placeholder"), + ForwardKind::Remote => t!("forward.dialog.target_placeholder_remote"), } } @@ -440,26 +463,19 @@ fn target_port_placeholder(kind: ForwardKind) -> &'static str { /// What the user should know about the listening end before saving: who /// else can reach it, and what it takes to listen there. -fn bind_notes(kind: ForwardKind, host: &str, port: Option) -> Vec<&'static str> { +fn bind_notes(kind: ForwardKind, host: &str, port: Option) -> Vec { let mut notes = Vec::new(); let bind = ForwardEndpoint::new(host.trim().to_string(), port.unwrap_or(0)); if !host.trim().is_empty() && !bind.is_loopback() { notes.push(match kind { - ForwardKind::Remote => { - "服务器的 sshd_config 需设置 GatewayPorts clientspecified 或 yes,其他机器才连得上;\ - 默认是 no,这时转发照常运行,但只监听服务器自己的 127.0.0.1。" - } - ForwardKind::Local | ForwardKind::Dynamic => { - "监听地址不是本机回环地址,同一网络中的其他设备也能使用这个转发。" - } + ForwardKind::Remote => t!("forward.dialog.gateway_ports_note"), + ForwardKind::Local | ForwardKind::Dynamic => t!("forward.dialog.not_loopback_note"), }); } if port.is_some_and(|port| port < 1024) { notes.push(match kind { - ForwardKind::Remote => "服务器上 1024 以下的端口需要以 root 登录才能监听。", - ForwardKind::Local | ForwardKind::Dynamic => { - "1024 以下的端口通常需要管理员权限才能监听。" - } + ForwardKind::Remote => t!("forward.dialog.privileged_port_remote"), + ForwardKind::Local | ForwardKind::Dynamic => t!("forward.dialog.privileged_port"), }); } notes @@ -473,7 +489,7 @@ impl Render for ForwardForm { parse_port(&self.bind_port.read(cx).value()), ); if self.editing_active { - notes.push("这条转发正在运行,改动监听或目标后会按新设置重新启动。"); + notes.push(t!("forward.dialog.restart_note")); } let muted = cx.theme().muted_foreground; v_flex() @@ -484,7 +500,7 @@ impl Render for ForwardForm { .child(self.render_fields()) .child( Checkbox::new("forward-auto-start") - .label("启动 ShellRS 时自动开启") + .label(t!("forward.dialog.auto_start")) .checked(self.auto_start) .small() .on_click(cx.listener(|this, checked: &bool, _, cx| { @@ -497,11 +513,11 @@ impl Render for ForwardForm { v_flex() .id("forward-notes") .test_support() - .aria_label(notes.join("")) + .aria_label(join_sentences(notes.clone()).unwrap_or_default()) .gap_1() .text_sm() .text_color(muted) - .children(notes.iter().map(|note| div().child(*note))), + .children(notes.into_iter().map(|note| div().child(note))), ) }) } @@ -517,18 +533,16 @@ pub fn open_forward_dialog( cx: &mut App, ) { let form = cx.new(|cx| ForwardForm::new(editing, editing_active, store, window, cx)); - let title: SharedString = if editing.is_some() { - "编辑端口转发" + let title = if editing.is_some() { + t!("forward.dialog.title_edit") } else { - "新建端口转发" - } - .into(); - let commit_label: SharedString = if editing.is_some() { - "保存" + t!("forward.dialog.title_new") + }; + let commit_label = if editing.is_some() { + t!("common.save") } else { - "创建" - } - .into(); + t!("forward.dialog.create") + }; window.open_dialog(cx, { let form = form.clone(); diff --git a/src/forward/forward_panel.rs b/src/forward/forward_panel.rs index 7547a11..2114075 100644 --- a/src/forward/forward_panel.rs +++ b/src/forward/forward_panel.rs @@ -20,6 +20,7 @@ use crate::app::{ SelectNextForward, SelectPreviousForward, StartForward, StopForward, ToggleSelectedForward, }; use crate::host::{ForwardId, ForwardKind, HostStore, matches_forward_query}; +use crate::i18n::{UiLocale, t}; use crate::shared::{RowTooltip, RowTooltips}; /// The port-forwarding list the left dock shows in place of the hosts: @@ -74,7 +75,7 @@ impl ForwardPanel { ) -> Self { let search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("搜索端口转发") + .placeholder(t!("forward.panel.search")) .clean_on_escape() }); let known = store @@ -86,6 +87,11 @@ impl ForwardPanel { let subscriptions = vec![ cx.observe(&store, |this, _, cx| this.on_store_changed(cx)), cx.observe(&manager, |_, _, cx| cx.notify()), + cx.observe_global_in::(window, |this, window, cx| { + this.search.update(cx, |search, cx| { + search.set_placeholder(t!("forward.panel.search"), window, cx) + }); + }), cx.subscribe(&search, |this, state, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { this.query = state.read(cx).value().to_string(); @@ -143,7 +149,7 @@ impl ForwardPanel { h_flex() .gap_1() .child(Icon::new(CatalogIcon::ArrowLeftRight).small()) - .child("端口转发") + .child(t!("forward.panel.title")) } /// The dock's toolbar while this list is up. @@ -152,8 +158,8 @@ impl ForwardPanel { vec![ Button::new("new-forward") .icon(IconName::Plus) - .tooltip("新建端口转发…") - .accessibility_label("新建端口转发…") + .tooltip(t!("forward.menu.new")) + .accessibility_label(t!("forward.menu.new")) .on_click(move |_, window, cx| target.dispatch_action(&NewForward, window, cx)), ] } @@ -239,16 +245,16 @@ impl ForwardPanel { let store = self.store.read(cx); let target = self.target.clone(); let (title, hint, button) = if !store.forwards().is_empty() { - ("没有匹配的端口转发", None, None) + (t!("forward.panel.no_matches"), None, None) } else if store.hosts().is_empty() { ( - "还没有主机", - Some("端口转发经由一台主机的 SSH 连接,先新建主机。"), + t!("forward.panel.no_hosts"), + Some(t!("forward.panel.no_hosts_hint")), Some( Button::new("forward-empty-new-host") .small() .icon(IconName::Plus) - .label("新建主机…") + .label(t!("forward.panel.new_host")) .on_click(move |_, window, cx| { target.dispatch_action(&NewHost, window, cx) }), @@ -256,13 +262,13 @@ impl ForwardPanel { ) } else { ( - "还没有端口转发", - Some("把本机的端口和服务器那一侧的网络接起来。"), + t!("forward.panel.empty"), + Some(t!("forward.panel.empty_hint")), Some( Button::new("forward-empty-new") .small() .icon(IconName::Plus) - .label("新建端口转发…") + .label(t!("forward.menu.new")) .on_click(move |_, window, cx| { target.dispatch_action(&NewForward, window, cx) }), @@ -272,7 +278,7 @@ impl ForwardPanel { v_flex() .id("forward-empty") .test_support() - .aria_label(title) + .aria_label(title.clone()) .items_center() .gap_2() .px_4() @@ -311,6 +317,11 @@ impl ForwardPanel { let toggle_tooltip = tooltip.clone(); let status = row.status.clone(); let target = self.target.clone(); + let toggle_label = if active { + t!("forward.menu.stop") + } else { + t!("forward.menu.start") + }; let item = ListItem::new(("forward-row", id.0)) .w_full() @@ -374,8 +385,8 @@ impl ForwardPanel { } else { CatalogIcon::Play }) - .tooltip(if active { "停止" } else { "启动" }) - .accessibility_label(if active { "停止" } else { "启动" }) + .tooltip(toggle_label.clone()) + .accessibility_label(toggle_label.clone()) .on_click(move |_, window, cx| { // The row's own click must not see this one: // a double click here is two toggles, not a @@ -486,33 +497,33 @@ fn build_context_menu( ) -> PopupMenu { let Some(id) = hit else { return menu.menu_with_icon( - "新建端口转发…", + t!("forward.menu.new"), Icon::new(IconName::Plus), Box::new(NewForward), ); }; let menu = if manager.is_active(id) { menu.menu_with_icon( - "停止", + t!("forward.menu.stop"), Icon::new(CatalogIcon::Square), Box::new(StopForward(id)), ) } else { menu.menu_with_icon( - "启动", + t!("forward.menu.start"), Icon::new(CatalogIcon::Play), Box::new(StartForward(id)), ) }; menu.separator() .menu_with_icon( - "编辑端口转发…", + t!("forward.menu.edit"), Icon::new(CatalogIcon::Pencil), Box::new(EditForward(id)), ) .separator() .menu_with_icon( - "删除", + t!("common.delete"), Icon::new(CatalogIcon::Trash), Box::new(DeleteForward(id)), ) diff --git a/src/forward/manager.rs b/src/forward/manager.rs index cf8123d..9e0c1b8 100644 --- a/src/forward/manager.rs +++ b/src/forward/manager.rs @@ -9,6 +9,7 @@ use super::{ForwardCommand, ForwardEvent, SharedForwardTransportProvider}; use crate::{ connection::{ConnectionPrompt, ConnectionPromptReply}, host::{ForwardId, HostId, HostStore, HostStoreEvent}, + i18n::{t, tn}, }; /// How often the workers' events are collected. Worker threads never wake a @@ -45,16 +46,16 @@ impl ForwardStatus { /// The status in words, as the list shows and announces it. pub fn label(&self) -> SharedString { match self { - ForwardStatus::Stopped => "已停止".into(), - ForwardStatus::Connecting => "正在连接".into(), - ForwardStatus::Running { connections: 0 } => "运行中".into(), + ForwardStatus::Stopped => t!("forward.status.stopped"), + ForwardStatus::Connecting => t!("forward.status.connecting"), + ForwardStatus::Running { connections: 0 } => t!("forward.status.running"), ForwardStatus::Running { connections } => { - format!("运行中,{connections} 个连接").into() + tn!("forward.status.running_with", *connections) } ForwardStatus::Reconnecting { attempt, of } => { - format!("连接中断,正在重连({attempt}/{of})").into() + t!("forward.status.reconnecting", attempt = attempt, of = of) } - ForwardStatus::Failed(reason) => format!("已停止:{reason}").into(), + ForwardStatus::Failed(reason) => t!("forward.status.failed", reason = reason), } } } @@ -220,7 +221,7 @@ impl ForwardManager { .name("shellrs-forward".into()) .spawn(move || transport.run(command_receiver, event_sender)) { - self.fail(id, format!("无法启动端口转发:{error}").into(), cx); + self.fail(id, t!("forward.error.start_failed", error = error), cx); return; } let generation = self.next_generation; @@ -355,7 +356,7 @@ impl ForwardManager { Err(TryRecvError::Empty) => break, // The worker is gone without a last word. Err(TryRecvError::Closed) => { - self.finish(id, Some("端口转发意外中止".into()), cx); + self.finish(id, Some(t!("forward.error.ended")), cx); break; } } diff --git a/src/forward/worker.rs b/src/forward/worker.rs index 3bfbb7f..40ab528 100644 --- a/src/forward/worker.rs +++ b/src/forward/worker.rs @@ -27,6 +27,7 @@ use super::{ use crate::{ connection::{ConnectionPrompt, ConnectionPromptKind}, host::{ForwardEndpoint, ForwardKind, ForwardRule, HostLogin}, + i18n::{t, tn}, ssh::{ ForwardedTcpip, MissingCredential, SshConnectionConfig, SshConnector, SshHandle, SshPrompts, describe_login_error, is_network_error, @@ -107,8 +108,9 @@ impl ForwardTransport for SshForwardTransport { { Ok(runtime) => runtime, Err(error) => { - let _ = events - .send_blocking(ForwardEvent::Failed(format!("无法启动端口转发:{error}"))); + let _ = events.send_blocking(ForwardEvent::Failed( + t!("forward.error.start_failed", error = error).to_string(), + )); return; } }; @@ -287,16 +289,18 @@ impl SshForwardTransport { StartError::RemoteListen => true, }; if !retry { - return Err(format!("连接中断,自动重连失败:{reason}")); + return Err( + t!("forward.error.reconnect_failed", reason = reason).to_string() + ); } } } let Some(delay) = next_reconnect(attempt, &self.reconnect_delays) else { return Err(if self.reconnect_delays.is_empty() { - "与服务器的连接已中断".to_string() + t!("forward.error.connection_lost").to_string() } else { - format!("连接中断,重连 {} 次均未成功", self.reconnect_delays.len()) + tn!("forward.error.reconnects_used", self.reconnect_delays.len()).to_string() }); }; attempt += 1; @@ -440,11 +444,9 @@ impl SshForwardTransport { fn describe_start_error(&self, error: &StartError, trust: HostTrust) -> String { match error { - StartError::RemoteListen => format!( - "服务器拒绝在 {} 上监听:端口可能已被占用、1024 以下的端口需要 root,\ - 或服务器禁用了端口转发(AllowTcpForwarding)", - self.bind - ), + StartError::RemoteListen => { + t!("forward.error.remote_listen", bind = self.bind).to_string() + } StartError::Login(error) => describe_login_failure(error, trust), } } @@ -453,10 +455,10 @@ impl SshForwardTransport { /// Why logging in failed, in the words the list shows. fn describe_login_failure(error: &anyhow::Error, trust: HostTrust) -> String { if trust.key_changed { - return "主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份".into(); + return t!("forward.error.key_changed").to_string(); } if trust.unknown { - return "尚未信任该主机的密钥,请手动启动以确认".into(); + return t!("forward.error.unknown_key").to_string(); } // Only an unattended login fails this way; an attended one asks. if let Some(need) = error @@ -464,11 +466,11 @@ fn describe_login_failure(error: &anyhow::Error, trust: HostTrust) -> String { .find_map(|cause| cause.downcast_ref::()) { return match need { - MissingCredential::Password { .. } => "需要输入密码,请手动启动", - MissingCredential::Passphrase { .. } => "需要输入私钥口令,请手动启动", - MissingCredential::KeyboardInteractive => "服务器要求交互式认证,请手动启动", + MissingCredential::Password { .. } => t!("forward.error.needs_password"), + MissingCredential::Passphrase { .. } => t!("forward.error.needs_passphrase"), + MissingCredential::KeyboardInteractive => t!("forward.error.needs_interactive"), } - .into(); + .to_string(); } describe_login_error(error) } @@ -488,7 +490,7 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { async fn bind_listeners(bind: &ForwardEndpoint) -> Result, String> { let addresses = tokio::net::lookup_host((bind.bare_host(), bind.port)) .await - .map_err(|_| format!("无法解析监听地址 {}", bind.host))?; + .map_err(|_| t!("forward.error.unresolved_bind", host = bind.host).to_string())?; let mut listeners = Vec::new(); let mut failure = None; for address in addresses { @@ -500,21 +502,19 @@ async fn bind_listeners(bind: &ForwardEndpoint) -> Result, Stri match (listeners.is_empty(), failure) { (false, _) => Ok(listeners), (true, Some(error)) => Err(describe_bind_error(&error, bind)), - (true, None) => Err(format!("无法解析监听地址 {}", bind.host)), + (true, None) => Err(t!("forward.error.unresolved_bind", host = bind.host).to_string()), } } fn describe_bind_error(error: &std::io::Error, bind: &ForwardEndpoint) -> String { use std::io::ErrorKind; match error.kind() { - ErrorKind::AddrInUse => format!("本机端口 {} 已被占用", bind.port), - ErrorKind::PermissionDenied => format!( - "没有权限监听端口 {}(1024 以下的端口需要管理员权限)", - bind.port - ), - ErrorKind::AddrNotAvailable => format!("本机没有地址 {},无法监听", bind.host), - _ => format!("无法监听 {bind}:{error}"), + ErrorKind::AddrInUse => t!("forward.error.port_in_use", port = bind.port), + ErrorKind::PermissionDenied => t!("forward.error.port_denied", port = bind.port), + ErrorKind::AddrNotAvailable => t!("forward.error.no_such_address", host = bind.host), + _ => t!("forward.error.bind_failed", bind = bind, error = error), } + .to_string() } /// Hand every accepted connection to the run, for as long as it lasts. @@ -586,7 +586,7 @@ async fn carry_dynamic( // Not SOCKS is worth saying: something is pointed at this port that // should not be. A client that gives up or is refused is not. Ok(Err(SocksError::Version)) => { - return Some("收到的不是 SOCKS 请求,请把应用的代理类型设为 SOCKS5".into()); + return Some(t!("forward.error.not_socks").to_string()); } Ok(Err(_)) | Err(_) => return None, }; @@ -638,12 +638,19 @@ async fn carry_remote(open: ForwardedTcpip, target: ForwardEndpoint) -> Option { - "连接被拒绝,该端口上没有服务在监听".to_string() + t!("forward.error.local_refused").to_string() } Ok(Err(error)) => error.to_string(), - _ => "连接超时".to_string(), + _ => t!("forward.error.local_timed_out").to_string(), }; - return Some(format!("无法连接本机目标 {target}:{reason}")); + return Some( + t!( + "forward.error.local_target_failed", + target = target, + reason = reason + ) + .to_string(), + ); } }; open.reply.accept().await; @@ -677,19 +684,22 @@ async fn open_channel( /// the target or the server's rules. `None` when the SSH connection itself /// is failing: that is reported once for the forward, not per connection. fn describe_open_error(error: Option<&russh::Error>, target: &str) -> Option { - match error { - None => Some(format!("服务器连接 {target} 超时")), - Some(russh::Error::ChannelOpenFailure(reason)) => Some(match reason { + let reason = match error { + None => t!("forward.error.server_timed_out", target = target), + Some(russh::Error::ChannelOpenFailure(reason)) => match reason { ChannelOpenFailure::AdministrativelyProhibited => { - format!("服务器禁止转发到 {target}(AllowTcpForwarding 或 PermitOpen 限制)") + t!("forward.error.prohibited", target = target) } - ChannelOpenFailure::ConnectFailed => format!("服务器无法连接 {target}"), - ChannelOpenFailure::UnknownChannelType => "服务器不支持端口转发".to_string(), - ChannelOpenFailure::ResourceShortage => "服务器资源不足,无法建立转发连接".to_string(), - ChannelOpenFailure::Other { .. } => format!("服务器拒绝了到 {target} 的连接"), - }), - Some(_) => None, - } + ChannelOpenFailure::ConnectFailed => { + t!("forward.error.server_connect_failed", target = target) + } + ChannelOpenFailure::UnknownChannelType => t!("forward.error.unsupported"), + ChannelOpenFailure::ResourceShortage => t!("forward.error.resource_shortage"), + ChannelOpenFailure::Other { .. } => t!("forward.error.refused", target = target), + }, + Some(_) => return None, + }; + Some(reason.to_string()) } #[cfg(test)] diff --git a/src/host/credential.rs b/src/host/credential.rs index 89946d9..a2e17c3 100644 --- a/src/host/credential.rs +++ b/src/host/credential.rs @@ -8,6 +8,7 @@ use std::path::Path; use gpui_kit::SharedString; +use crate::i18n::t; use crate::secrets::SecretRef; use super::private_key::is_kept_in; @@ -41,11 +42,11 @@ impl CredentialKind { CredentialKind::Agent, ]; - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - CredentialKind::Password => "密码", - CredentialKind::Key => "密钥", - CredentialKind::Agent => "SSH Agent", + CredentialKind::Password => t!("host.credential_kind.password"), + CredentialKind::Key => t!("host.credential_kind.key"), + CredentialKind::Agent => "SSH Agent".into(), } } @@ -89,9 +90,9 @@ pub enum CredentialDraftError { impl fmt::Display for CredentialDraftError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - CredentialDraftError::Name => "请输入名称", - CredentialDraftError::KeyPath => "密钥凭据需要选择私钥文件", + formatter.write_str(&match self { + CredentialDraftError::Name => t!("host.credential_draft.name"), + CredentialDraftError::KeyPath => t!("host.credential_draft.key_path"), }) } } @@ -249,10 +250,11 @@ pub fn matches_credential_query(credential: &Credential, query: &str) -> bool { if query.is_empty() { return true; } + let kind = credential.kind.label(); [ Some(credential.name.as_ref()), Some(credential.user.as_ref()), - Some(credential.kind.label()), + Some(kind.as_ref()), credential.key_path.as_deref(), ] .into_iter() diff --git a/src/host/database.rs b/src/host/database.rs index aae94a5..23c2d52 100644 --- a/src/host/database.rs +++ b/src/host/database.rs @@ -12,6 +12,8 @@ use std::{ use rusqlite::{Connection, params}; +use crate::i18n::t; + use super::{ AuthKind, BookmarkSide, Credential, CredentialDraft, CredentialId, CredentialKind, ForwardDraft, ForwardEndpoint, ForwardId, ForwardKind, ForwardRule, GroupDraft, GroupId, Host, @@ -986,16 +988,20 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> { )); } if version > SCHEMA_VERSION { - return Err(refusal(format!( - "数据库来自更新版本的 ShellRS(结构版本 {version},本版本支持到 \ - {SCHEMA_VERSION}),请安装最新版本" - ))); + return Err(refusal( + t!( + "host.database.newer", + version = version, + supported = SCHEMA_VERSION + ) + .to_string(), + )); } let oldest = STEPS.first().map_or(SCHEMA_VERSION, |(from, _)| *from); if version < oldest { - return Err(refusal(format!( - "数据库来自 ShellRS 的早期开发版本(结构版本 {version}),无法升级" - ))); + return Err(refusal( + t!("host.database.too_old", version = version).to_string(), + )); } if version < SCHEMA_VERSION { back_up(connection, version); @@ -1041,7 +1047,7 @@ fn back_up(connection: &Connection, version: i64) { let backup = format!("{path}.v{version}.bak"); let _ = std::fs::remove_file(&backup); if let Err(error) = connection.execute("VACUUM INTO ?1", [&backup]) { - eprintln!("shellrs: 迁移前无法备份数据库:{error}"); + eprintln!("shellrs: cannot back up the database before migrating it: {error}"); } } diff --git a/src/host/forward.rs b/src/host/forward.rs index 9691ea6..2f78edd 100644 --- a/src/host/forward.rs +++ b/src/host/forward.rs @@ -7,6 +7,7 @@ use std::fmt; use gpui_kit::SharedString; use super::HostId; +use crate::i18n::t; /// Stable identity of a forwarding rule. Never reused within a process. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -33,11 +34,11 @@ impl ForwardKind { ForwardKind::Dynamic, ]; - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - ForwardKind::Local => "本地转发", - ForwardKind::Remote => "远程转发", - ForwardKind::Dynamic => "动态转发", + ForwardKind::Local => t!("host.forward_kind.local"), + ForwardKind::Remote => t!("host.forward_kind.remote"), + ForwardKind::Dynamic => t!("host.forward_kind.dynamic"), } } @@ -132,11 +133,11 @@ pub enum ForwardDraftError { impl fmt::Display for ForwardDraftError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - ForwardDraftError::BindHost => "请输入监听地址,地址中不能有空格", - ForwardDraftError::BindPort => "监听端口应为 1–65535 的数字", - ForwardDraftError::TargetHost => "请输入目标地址,地址中不能有空格", - ForwardDraftError::TargetPort => "目标端口应为 1–65535 的数字", + formatter.write_str(&match self { + ForwardDraftError::BindHost => t!("host.forward_draft.bind_host"), + ForwardDraftError::BindPort => t!("host.forward_draft.bind_port"), + ForwardDraftError::TargetHost => t!("host.forward_draft.target_host"), + ForwardDraftError::TargetPort => t!("host.forward_draft.target_port"), }) } } diff --git a/src/host/group_dialog.rs b/src/host/group_dialog.rs index 2c9046c..e93bebb 100644 --- a/src/host/group_dialog.rs +++ b/src/host/group_dialog.rs @@ -7,12 +7,11 @@ use gpui_kit::component::{ }; use gpui_kit::*; +use super::host_dialog::join_sentences; use super::{DeleteHandler, Dependents, GroupDraft, GroupId, HostStore, group_options}; +use crate::i18n::{join_list, t, tn}; use crate::shared::{commit_footer, confirm_delete, dismiss_form_error, form_error_notification}; -/// The label of the row that stands for "no parent" / "no group". -pub const ROOT_LABEL: &str = "(顶层)"; - /// The body of the new/rename group dialog. Owns the field states and /// validates on commit; the store is only touched when validation passes. pub struct GroupForm { @@ -47,7 +46,7 @@ impl GroupForm { (draft, group_options(read.groups(), &excluded)) }; let mut parent_ids: Vec> = vec![None]; - let mut labels: Vec = vec![ROOT_LABEL.into()]; + let mut labels: Vec = vec![t!("host.group_dialog.top_level")]; for (id, path) in options { parent_ids.push(Some(id)); labels.push(path); @@ -55,7 +54,7 @@ impl GroupForm { let name = cx.new(|cx| { InputState::new(window, cx) - .placeholder("例如 生产") + .placeholder(t!("host.group_dialog.name_placeholder")) .default_value(draft.name.clone()) }); let parent_ix = parent_ids @@ -89,9 +88,9 @@ impl GroupForm { group.parent == parent && group.name == name.as_str() && Some(group.id) != editing }); let error = if name.is_empty() { - Some("请输入分组名称") + Some(t!("host.group_dialog.enter_name")) } else if duplicate { - Some("同一层级下已有同名分组") + Some(t!("host.group_dialog.duplicate")) } else { None }; @@ -109,7 +108,10 @@ impl GroupForm { } }); if !committed { - window.push_notification(form_error_notification("无法把分组移动到它自己的下级"), cx); + window.push_notification( + form_error_notification(t!("host.group_dialog.into_itself")), + cx, + ); return false; } true @@ -122,13 +124,13 @@ impl Render for GroupForm { Form::new() .child( Field::new() - .label("名称") + .label(t!("host.group_dialog.name")) .required(true) .child(Input::new(&self.name).id("group-name").small()), ) .child( Field::new() - .label("上级分组") + .label(t!("host.group_dialog.parent")) .child(Select::new(&self.parent).small()), ), ) @@ -144,18 +146,16 @@ pub fn open_group_dialog( cx: &mut App, ) { let form = cx.new(|cx| GroupForm::new(editing, default_parent, store, window, cx)); - let title: SharedString = if editing.is_some() { - "重命名分组" + let title = if editing.is_some() { + t!("host.group_dialog.title_rename") } else { - "新建分组" - } - .into(); - let commit_label: SharedString = if editing.is_some() { - "保存" + t!("host.group_dialog.title_new") + }; + let commit_label = if editing.is_some() { + t!("common.save") } else { - "创建" - } - .into(); + t!("host.group_dialog.create") + }; window.open_dialog(cx, { let form = form.clone(); @@ -194,55 +194,47 @@ pub fn confirm_delete_group( jump_users, } = dependents; let (closes_tabs, uploads) = affected; - let mut description = describe_contents(hosts, subgroups, closes_tabs); + let mut description = contents_sentences(hosts, subgroups, closes_tabs); if uploads > 0 { - description = Some( - format!( - "{}将停止 {uploads} 个传输批次并保留续传进度。", - description.unwrap_or_default() - ) - .into(), - ); + description.push(tn!("host.delete.transfers", uploads)); } if forwards > 0 { - description = Some( - format!( - "{}这些主机的 {forwards} 条端口转发会一并删除。", - description.unwrap_or_default() - ) - .into(), - ); + description.push(tn!("host.delete_group.forwards", forwards)); } if jump_users > 0 { - description = Some( - format!( - "{}分组外有 {jump_users} 台主机经由这些主机跳转,删除后要重新选择跳板主机才能连接。", - description.unwrap_or_default() - ) - .into(), - ); + description.push(tn!("host.delete_group.jump_users", jump_users)); } - confirm_delete(name, description, on_delete, window, cx); + confirm_delete(name, join_sentences(description), on_delete, window, cx); } /// What the delete dialog says about everything that goes with the group. /// `None` for an empty group with nothing open. +#[cfg(test)] fn describe_contents(hosts: usize, subgroups: usize, closes_tabs: bool) -> Option { - let contents = match (hosts, subgroups) { - (0, 0) => None, - (0, subgroups) => Some(format!("将同时删除其中的 {subgroups} 个子分组。")), - (hosts, 0) => Some(format!("将同时删除其中的 {hosts} 台主机。")), - (hosts, subgroups) => Some(format!( - "将同时删除其中的 {hosts} 台主机和 {subgroups} 个子分组。" - )), - }; - let tabs = closes_tabs.then_some("已打开的终端和 SFTP 标签会一并关闭。"); - match (contents, tabs) { - (None, None) => None, - (Some(contents), None) => Some(contents.into()), - (None, Some(tabs)) => Some(tabs.into()), - (Some(contents), Some(tabs)) => Some(format!("{contents}{tabs}").into()), + join_sentences(contents_sentences(hosts, subgroups, closes_tabs)) +} + +/// What the delete dialog says about everything that goes with the group, +/// a sentence each. +fn contents_sentences(hosts: usize, subgroups: usize, closes_tabs: bool) -> Vec { + let mut contents = Vec::new(); + if hosts > 0 { + contents.push(tn!("host.count.hosts", hosts)); } + if subgroups > 0 { + contents.push(tn!("host.count.subgroups", subgroups)); + } + let mut sentences = Vec::new(); + if !contents.is_empty() { + sentences.push(t!( + "host.delete_group.contents", + items = join_list(&contents) + )); + } + if closes_tabs { + sentences.push(t!("host.delete_group.closes_tabs")); + } + sentences } #[cfg(test)] @@ -271,4 +263,21 @@ mod tests { Some("已打开的终端和 SFTP 标签会一并关闭。") ); } + + #[test] + fn the_description_counts_in_english_too() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + assert_eq!( + describe_contents(1, 2, true).as_deref(), + Some( + "This also deletes the group’s 1 host and 2 subgroups. \ + Open terminal and SFTP tabs will close." + ) + ); + assert_eq!( + describe_contents(3, 0, false).as_deref(), + Some("This also deletes the group’s 3 hosts.") + ); + } } diff --git a/src/host/host_dialog.rs b/src/host/host_dialog.rs index e3a8503..8cb8c33 100644 --- a/src/host/host_dialog.rs +++ b/src/host/host_dialog.rs @@ -21,6 +21,7 @@ use zeroize::Zeroizing; use crate::app::ConnectHost; use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt}; +use crate::i18n::{t, tn}; use super::secret_fields::SecretFields; use super::{ @@ -33,9 +34,6 @@ use crate::shared::{ parse_port, }; -/// The label of the row that puts a host at the root of the tree. -pub const NO_GROUP_LABEL: &str = "(无分组)"; - /// Where the dialog's top sits and how tall it may grow, as fractions of /// the window's height. const DIALOG_TOP: f32 = 0.05; @@ -61,11 +59,11 @@ impl AuthSource { AuthSource::NoPassword, ]; - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - AuthSource::Password => "密码", - AuthSource::Credential => "使用凭据", - AuthSource::NoPassword => "无密码", + AuthSource::Password => t!("host.auth.password"), + AuthSource::Credential => t!("host.auth.credential"), + AuthSource::NoPassword => t!("host.auth.no_password"), } } @@ -80,12 +78,6 @@ impl AuthSource { } } -/// What a 临时连接's dialog says under its fields. -const TEMPORARY_NOTE: &str = "仅当前使用的临时会话,不会保存到主机列表。"; - -/// What 「无密码」 tries, under the choice. -const NO_PASSWORD_NOTE: &str = "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。"; - /// How a host is reached, as the form's 「连接方式」 offers it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RouteChoice { @@ -98,11 +90,11 @@ impl RouteChoice { /// Every way, in the order the form lists them. const ALL: [RouteChoice; 3] = [RouteChoice::Direct, RouteChoice::Jump, RouteChoice::Proxy]; - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - RouteChoice::Direct => "直接连接", - RouteChoice::Jump => "SSH 跳板", - RouteChoice::Proxy => "代理连接", + RouteChoice::Direct => t!("host.route.direct"), + RouteChoice::Jump => t!("host.route.jump"), + RouteChoice::Proxy => t!("host.route.proxy"), } } @@ -115,12 +107,6 @@ impl RouteChoice { } } -/// What the jump-host box says above the chain. -const JUMP_NOTE: &str = "依次经过跳板主机连接到当前主机,可添加多台。"; - -/// What the proxy box says under its fields. -const PROXY_NOTE: &str = "目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。"; - /// A host the form offers as a jump host, as it was when the form opened. #[derive(Clone)] struct JumpHost { @@ -145,7 +131,11 @@ impl SearchableListItem for JumpHost { /// The name and the address, both of which the search looks in. fn title(&self) -> SharedString { - format!("{}({})", self.name, self.address).into() + t!( + "host.dialog.jump_host_title", + name = self.name, + address = self.address + ) } fn render(&self, _: &mut Window, cx: &mut App) -> impl IntoElement { @@ -245,7 +235,7 @@ impl HostForm { // A host with no group sits at the root of the tree, which is where // every host starts when the database is still empty. let mut group_ids: Vec> = vec![None]; - let mut group_names: Vec = vec![NO_GROUP_LABEL.into()]; + let mut group_names: Vec = vec![t!("host.dialog.no_group")]; for (id, path) in options { group_ids.push(Some(id)); group_names.push(path); @@ -263,12 +253,12 @@ impl HostForm { let name = cx.new(|cx| { InputState::new(window, cx) - .placeholder("例如 web-01") + .placeholder(t!("host.dialog.name_placeholder")) .default_value(draft.name.clone()) }); let address = cx.new(|cx| { InputState::new(window, cx) - .placeholder("主机名或 IP 地址") + .placeholder(t!("host.dialog.address_placeholder")) .default_value(draft.address.clone()) }); let port = cx.new(|cx| { @@ -331,7 +321,7 @@ impl HostForm { SelectState::new( ProxyKind::ALL .iter() - .map(|kind| SharedString::from(kind.label())) + .map(|kind| kind.label()) .collect::>(), Some(IndexPath::new(proxy_kind_ix)), window, @@ -340,7 +330,7 @@ impl HostForm { }); let proxy_host = cx.new(|cx| { InputState::new(window, cx) - .placeholder("例如 127.0.0.1") + .placeholder(t!("host.dialog.proxy_host_placeholder")) .default_value( proxy .as_ref() @@ -358,7 +348,7 @@ impl HostForm { }); let proxy_user = cx.new(|cx| { InputState::new(window, cx) - .placeholder("可选") + .placeholder(t!("host.dialog.optional")) .default_value( proxy .as_ref() @@ -368,7 +358,7 @@ impl HostForm { }); let proxy_secret = cx.new(|cx| { let mut fields = SecretFields::new(secrets, None, window, cx); - fields.set_password_placeholder("可选", window, cx); + fields.set_password_placeholder(t!("host.dialog.optional"), window, cx); fields }); if let Some(secret) = proxy.as_ref().and_then(ProxySettings::password_secret) { @@ -451,7 +441,7 @@ impl HostForm { let mut form = Self::new(None, None, store, tester, window, cx); form.temporary = true; form.name.update(cx, |input, cx| { - input.set_placeholder("可选,默认用地址", window, cx) + input.set_placeholder(t!("host.dialog.temporary_name_placeholder"), window, cx) }); form } @@ -501,7 +491,7 @@ impl HostForm { } /// The route the form describes, or what is missing from it. - fn committed_route(&self, cx: &App) -> Result { + fn committed_route(&self, cx: &App) -> Result { match self.route { RouteChoice::Direct => Ok(Route::Direct), RouteChoice::Jump if self.hops.is_empty() => Err(HostDraftError::NoJumpHosts.message()), @@ -518,7 +508,7 @@ impl HostForm { .ok_or(HostDraftError::ProxyPort.message())?; let user = self.proxy_user.read(cx).value().trim().to_string(); if user.is_empty() && !self.proxy_secret.read(cx).password(cx).is_empty() { - return Err("填写代理密码时请同时填写用户名"); + return Err(t!("host.dialog.proxy_password_needs_user")); } let kind = self .proxy_kind @@ -542,7 +532,7 @@ impl HostForm { } /// The address and port, or why they will not do. - fn endpoint(&self, cx: &App) -> Result<(String, u16), &'static str> { + fn endpoint(&self, cx: &App) -> Result<(String, u16), SharedString> { let address = self.address.read(cx).value().trim().to_string(); let port = parse_port(self.port.read(cx).value().trim()); match (address.is_empty(), port) { @@ -554,13 +544,15 @@ impl HostForm { /// The login the form's current values describe, saved or not, or why /// there is nothing to test yet. - fn login_test(&self, cx: &App) -> Result { + fn login_test(&self, cx: &App) -> Result { let (host, port) = self.endpoint(cx)?; let route = self.committed_route(cx)?; let route_login = self.store.read(cx).route_login(&route); let request = match self.source.auth() { None => { - let credential = self.selected_credential(cx).ok_or("请选择凭据")?; + let credential = self + .selected_credential(cx) + .ok_or_else(|| t!("host.dialog.choose_credential"))?; LoginTest::saved( HostLogin::with_credential(host, port, credential).with_route(route_login), ) @@ -568,7 +560,7 @@ impl HostForm { Some(auth) => { let user = self.user.read(cx).value().trim().to_string(); if user.is_empty() { - return Err("请输入用户名"); + return Err(t!("host.dialog.enter_user")); } let mut request = LoginTest::typed( HostLogin::manual(host, port, user, auth).with_route(route_login), @@ -618,7 +610,11 @@ impl HostForm { }); if let Err(error) = spawned { window.push_notification( - connection_test_notification(Err(format!("无法启动连接测试:{error}"))), + connection_test_notification(Err(t!( + "host.dialog.test_start_failed", + error = error + ) + .into())), cx, ); return; @@ -649,7 +645,7 @@ impl HostForm { continue; } Err(std::sync::mpsc::TryRecvError::Disconnected) => { - Err("连接测试意外中止".to_string()) + Err(t!("host.dialog.test_aborted").to_string()) } }; this.update_in(cx, |this, window, cx| { @@ -665,7 +661,7 @@ impl HostForm { } /// How the form says the host logs in, or what is missing. - fn committed_login(&self, cx: &App) -> Result { + fn committed_login(&self, cx: &App) -> Result { match self.source.auth() { Some(auth) => Ok(CommittedLogin::Own(auth)), None => self @@ -674,7 +670,7 @@ impl HostForm { credential: credential.id, user: credential.user.clone(), }) - .ok_or("请选择凭据"), + .ok_or_else(|| t!("host.dialog.choose_credential")), } } @@ -821,14 +817,14 @@ impl HostForm { fn own_fields(&self, form: Form, auth: AuthKind, cx: &App) -> Form { form.child( Field::new() - .label("用户名") + .label(t!("host.dialog.user")) .col_span(4) .child(Input::new(&self.user).id("host-user").small()), ) .when(auth == AuthKind::Password, |form| { form.child( Field::new() - .label("密码") + .label(t!("host.dialog.password")) .col_span(4) .child(self.fields.read(cx).password_input("host-password")), ) @@ -839,27 +835,25 @@ impl HostForm { fn credential_field(&self, cx: &App) -> Field { let summary = match self.selected_credential(cx) { Some(credential) => Some(credential_summary(credential, self.key_dir.as_deref())), - None if self.credentials.is_empty() => { - Some("还没有凭据,可在侧栏的「凭据」中新建".into()) - } + None if self.credentials.is_empty() => Some(t!("host.dialog.no_credentials_hint")), None => None, }; Field::new() - .label("凭据") + .label(t!("host.dialog.credential")) .required(true) .col_span(4) .child( Select::new(&self.credential) .id("host-credential") - .placeholder("请选择凭据") - .search_placeholder("搜索凭据") + .placeholder(t!("host.dialog.choose_credential")) + .search_placeholder(t!("host.dialog.search_credentials")) .empty(|_, cx| { div() .py_4() .text_sm() .text_center() .text_color(cx.theme().muted_foreground) - .child("还没有凭据") + .child(t!("host.dialog.no_credentials")) }) .small(), ) @@ -892,23 +886,26 @@ impl HostForm { RouteChoice::Jump => Some(self.jump_box(cx).into_any_element()), RouteChoice::Proxy => Some(self.proxy_box(cx).into_any_element()), }; - Field::new().label("连接方式").col_span(4).child( - v_flex() - .w_full() - .gap_2() - .child(choice) - .when_some(details, |field, details| { - field.child( - div() - .w_full() - .p_3() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .child(details), - ) - }), - ) + Field::new() + .label(t!("host.dialog.route")) + .col_span(4) + .child( + v_flex() + .w_full() + .gap_2() + .child(choice) + .when_some(details, |field, details| { + field.child( + div() + .w_full() + .p_3() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .child(details), + ) + }), + ) } /// The jump hosts: the way through them, one row each, and a picker to @@ -921,13 +918,14 @@ impl HostForm { }; // 本机 → 阿里云99 → 禅道 → 当前主机 + let this_computer = t!("host.dialog.this_computer"); let mut stops: Vec<(SharedString, Tag)> = vec![( - "本机".into(), + this_computer.clone(), Tag::secondary() .outline() .rounded_full() .small() - .child("本机"), + .child(this_computer), )]; for hop in &self.hops { stops.push(match name_of(hop) { @@ -939,18 +937,22 @@ impl HostForm { .child(host.name.clone()), ), None => ( - DELETED_HOST.into(), - Tag::danger().rounded_full().small().child(DELETED_HOST), + t!("host.dialog.deleted_host"), + Tag::danger() + .rounded_full() + .small() + .child(t!("host.dialog.deleted_host")), ), }); } + let this_host = t!("host.dialog.this_host"); stops.push(( - "当前主机".into(), + this_host.clone(), Tag::secondary() .outline() .rounded_full() .small() - .child("当前主机"), + .child(this_host), )); let chain_label = stops .iter() @@ -984,7 +986,7 @@ impl HostForm { ), None => ( ("jump-hop-deleted", position).into(), - DELETED_HOST.into(), + t!("host.dialog.deleted_host"), None, ), }; @@ -1028,7 +1030,7 @@ impl HostForm { .ghost() .xsmall() .icon(IconName::Close) - .tooltip("移除") + .tooltip(t!("host.dialog.remove_hop")) .on_click(cx.listener(move |this, _, window, cx| { this.remove_hop(position, window, cx) })), @@ -1037,22 +1039,22 @@ impl HostForm { let all_added = available_jump_hosts(&self.jump_hosts, &self.hops).is_empty(); let empty_note = if self.jump_hosts.is_empty() { - "还没有其他主机可做跳板" + t!("host.dialog.no_other_hosts") } else { - "其他主机都已添加" + t!("host.dialog.all_hosts_added") }; let picker = div().id("jump-add").w_full().child( Combobox::new(&self.jump_picker) .small() .disabled(all_added) - .search_placeholder("搜索主机") + .search_placeholder(t!("host.dialog.search_hosts")) .empty(move |_, cx| { div() .py_4() .text_sm() .text_center() .text_color(cx.theme().muted_foreground) - .child("没有匹配的主机") + .child(t!("host.dialog.no_matching_hosts")) }) .render_trigger(move |_, _, _| { h_flex() @@ -1063,9 +1065,9 @@ impl HostForm { .gap_1() .child(Icon::new(IconName::Plus).xsmall()) .child(if all_added { - empty_note + empty_note.clone() } else { - "添加跳板主机" + t!("host.dialog.add_jump_host") }) }), ); @@ -1077,10 +1079,10 @@ impl HostForm { div() .id("host-route-note") .test_support() - .aria_label(JUMP_NOTE) + .aria_label(t!("host.dialog.jump_note")) .text_sm() .text_color(muted) - .child(JUMP_NOTE), + .child(t!("host.dialog.jump_note")), ) .child(chain) .children(rows) @@ -1098,32 +1100,32 @@ impl HostForm { .columns(4) .child( Field::new() - .label("代理类型") + .label(t!("host.dialog.proxy_kind")) .col_span(4) .child(Select::new(&self.proxy_kind).id("host-proxy-kind").small()), ) .child( Field::new() - .label("代理地址") + .label(t!("host.dialog.proxy_address")) .required(true) .col_span(3) .child(Input::new(&self.proxy_host).id("host-proxy-host").small()), ) .child( Field::new() - .label("端口") + .label(t!("host.dialog.port")) .required(true) .child(Input::new(&self.proxy_port).id("host-proxy-port").small()), ) .child( Field::new() - .label("用户名") + .label(t!("host.dialog.user")) .col_span(2) .child(Input::new(&self.proxy_user).id("host-proxy-user").small()), ) .child( Field::new() - .label("密码") + .label(t!("host.dialog.password")) .col_span(2) .child(password.password_input("host-proxy-password")), ), @@ -1132,17 +1134,14 @@ impl HostForm { div() .id("host-route-note") .test_support() - .aria_label(PROXY_NOTE) + .aria_label(t!("host.dialog.proxy_note")) .text_sm() .text_color(cx.theme().muted_foreground) - .child(PROXY_NOTE), + .child(t!("host.dialog.proxy_note")), ) } } -/// What a jump host that has been deleted is called in the form. -const DELETED_HOST: &str = "已删除的主机"; - /// The hosts that can still be added as a jump host: those not in `hops`. fn available_jump_hosts(hosts: &[JumpHost], hops: &[Option]) -> Vec { hosts @@ -1157,9 +1156,9 @@ impl Render for HostForm { let source = self.source; let keychain = self.fields.read(cx).keychain_available(); let secret_note = if keychain { - "密码保存在系统钥匙串,不会写入 ShellRS 的数据库。" + t!("host.dialog.keychain_note") } else { - "系统钥匙串不可用,这台机器上无法保存密码,每次连接都会询问。" + t!("host.dialog.no_keychain_note") }; // Four columns so the address and its port share a row, as they are // written (`host:port`); every other field takes a row of its own. @@ -1167,26 +1166,26 @@ impl Render for HostForm { .columns(4) .child( Field::new() - .label("名称") + .label(t!("host.dialog.name")) .required(!self.temporary) .col_span(4) .child(Input::new(&self.name).id("host-name").small()), ) .child( Field::new() - .label("地址") + .label(t!("host.dialog.address")) .required(true) .col_span(3) .child(Input::new(&self.address).id("host-address").small()), ) .child( Field::new() - .label("端口") + .label(t!("host.dialog.port")) .child(Input::new(&self.port).id("host-port").small()), ) .child( Field::new() - .label("认证方式") + .label(t!("host.dialog.auth")) .col_span(4) .child( SegmentedControl::new("host-auth-source") @@ -1203,8 +1202,8 @@ impl Render for HostForm { div() .id("host-no-password-note") .test_support() - .aria_label(NO_PASSWORD_NOTE) - .child(NO_PASSWORD_NOTE) + .aria_label(t!("host.dialog.no_password_note")) + .child(t!("host.dialog.no_password_note")) }) }), ); @@ -1218,19 +1217,22 @@ impl Render for HostForm { } else { form.child( Field::new() - .label("分组") + .label(t!("host.dialog.group")) .col_span(4) .child(Select::new(&self.group).small()), ) .child(self.route_field(cx)) .child( - Field::new().label("备注").col_span(4).child( - div() - .id("host-notes") - .test_support() - .w_full() - .child(Textarea::new(&self.notes).text_sm()), - ), + Field::new() + .label(t!("host.dialog.notes")) + .col_span(4) + .child( + div() + .id("host-notes") + .test_support() + .w_full() + .child(Textarea::new(&self.notes).text_sm()), + ), ) }; v_flex() @@ -1242,10 +1244,10 @@ impl Render for HostForm { div() .id("temporary-note") .test_support() - .aria_label(TEMPORARY_NOTE) + .aria_label(t!("host.dialog.temporary_note")) .text_sm() .text_color(cx.theme().muted_foreground) - .child(TEMPORARY_NOTE), + .child(t!("host.dialog.temporary_note")), ) }) .when(!self.temporary && source == AuthSource::Password, |form| { @@ -1261,7 +1263,7 @@ impl Render for HostForm { div() .text_sm() .text_color(cx.theme().muted_foreground) - .child("保存后连接设置将立即生效并重连。"), + .child(t!("host.dialog.reconnect_note")), ) }) } @@ -1279,23 +1281,28 @@ enum CommittedLogin { /// A credential as the host form's select lists it: `运维(root · 密码)`. fn credential_option(credential: &Credential, key_dir: Option<&Path>) -> SharedString { - format!("{}({})", credential.name, credential.summary(key_dir)).into() + t!( + "host.dialog.credential_option", + name = credential.name, + summary = credential.summary(key_dir) + ) } /// What a host using `credential` logs in as and with. fn credential_summary(credential: &Credential, key_dir: Option<&Path>) -> SharedString { - let with = match credential.kind { - CredentialKind::Password => "使用凭据保存的密码".to_string(), + let user = &credential.user; + match credential.kind { + CredentialKind::Password => t!("host.dialog.logs_in_with.password", user = user), CredentialKind::Key if credential.keeps_key_in(key_dir) => { - "使用 ShellRS 保存的私钥".to_string() + t!("host.dialog.logs_in_with.kept_key", user = user) } - CredentialKind::Key => format!( - "使用私钥 {}", - credential.key_path.as_deref().unwrap_or_default() + CredentialKind::Key => t!( + "host.dialog.logs_in_with.key", + user = user, + path = credential.key_path.as_deref().unwrap_or_default() ), - CredentialKind::Agent => "使用 SSH Agent 中的密钥".to_string(), - }; - format!("以 {} 登录,{with}", credential.user).into() + CredentialKind::Agent => t!("host.dialog.logs_in_with.agent", user = user), + } } /// A trust question from the test's worker, with where to send the answer. @@ -1319,12 +1326,12 @@ fn ask_to_trust( }; let (trust, decline, dismiss) = (answer(true), answer(false), answer(false)); alert - .title("首次连接此主机") + .title(t!("host.trust.title")) .description(description.clone()) .button_props( DialogButtonProps::default() - .ok_text("信任并继续") - .cancel_text("取消"), + .ok_text(t!("host.trust.trust")) + .cancel_text(t!("common.cancel")), ) .show_cancel(true) .on_ok(move |_, _, _| { @@ -1343,8 +1350,8 @@ fn ask_to_trust( /// message says why. fn connection_test_notification(result: Result<(), String>) -> Notification { match result { - Ok(()) => Notification::success("连接成功"), - Err(reason) => Notification::error(reason).title("连接失败"), + Ok(()) => Notification::success(t!("host.test.connected")), + Err(reason) => Notification::error(reason).title(t!("host.test.failed")), } } @@ -1363,18 +1370,16 @@ pub fn open_host_dialog( cx: &mut App, ) { let form = cx.new(|cx| HostForm::new(editing, preselect_group, store, tester, window, cx)); - let title: SharedString = if editing.is_some() { - "编辑主机" + let title = if editing.is_some() { + t!("host.dialog.title_edit") } else { - "新建主机" - } - .into(); - let commit_label: SharedString = if editing.is_some() { - "保存" + t!("host.dialog.title_new") + }; + let commit_label = if editing.is_some() { + t!("common.save") } else { - "创建" - } - .into(); + t!("host.dialog.create") + }; window.open_dialog(cx, { let form = form.clone(); @@ -1408,7 +1413,7 @@ fn form_footer(form: &Entity, commit_label: SharedString, cx: &App) -> .justify_between() .child( Button::new("test-connection") - .label("测试连接") + .label(t!("host.dialog.test_connection")) .icon(crate::app::CatalogIcon::Plug) .small() .loading(form.read(cx).testing_connection) @@ -1424,7 +1429,7 @@ fn form_footer(form: &Entity, commit_label: SharedString, cx: &App) -> .gap_2() .child( Button::new("cancel") - .label("取消") + .label(t!("common.cancel")) .small() .on_click(|_, window, cx| { window.dispatch_action(Box::new(Cancel), cx); @@ -1458,12 +1463,12 @@ pub fn open_temporary_connection_dialog( let form = form.clone(); move |dialog, window, cx| { dialog - .title("临时连接") + .title(t!("host.dialog.title_temporary")) .margin_top(window.viewport_size().height * DIALOG_TOP) .max_h(window.viewport_size().height * DIALOG_MAX_HEIGHT) .overlay_closable(false) .child(form.clone()) - .footer(form_footer(&form, "连接".into(), cx)) + .footer(form_footer(&form, t!("host.dialog.connect"), cx)) .on_ok({ let form = form.clone(); let dispatch = dispatch.clone(); @@ -1525,11 +1530,11 @@ fn describe_host_delete( uploads: usize, dependents: Dependents, ) -> Option { - let mut description = String::new(); + let mut description = Vec::new(); if closes_tabs { - description.push_str("会一并关闭该主机已打开的终端和 SFTP 标签。"); + description.push(t!("host.delete.closes_tabs")); if uploads > 0 { - description.push_str(&format!("将停止 {uploads} 个传输批次并保留续传进度。")); + description.push(tn!("host.delete.transfers", uploads)); } } let Dependents { @@ -1537,14 +1542,26 @@ fn describe_host_delete( jump_users, } = dependents; if forwards > 0 { - description.push_str(&format!("将同时删除经由该主机的 {forwards} 条端口转发。")); + description.push(tn!("host.delete.forwards", forwards)); } if jump_users > 0 { - description.push_str(&format!( - "有 {jump_users} 台主机把它用作跳板主机,删除后要重新选择跳板主机才能连接。" - )); + description.push(tn!("host.delete.jump_users", jump_users)); } - (!description.is_empty()).then(|| description.into()) + join_sentences(description) +} + +/// Sentences one after another, as a description says them: Chinese ones +/// end in 。 and follow straight on, English ones take a space between. +/// `None` for no sentences. +pub fn join_sentences(sentences: Vec) -> Option { + let mut text = String::new(); + for sentence in sentences { + if text.ends_with(|c: char| c.is_ascii()) { + text.push(' '); + } + text.push_str(&sentence); + } + (!text.is_empty()).then(|| text.into()) } #[cfg(test)] @@ -1579,6 +1596,20 @@ mod tests { ); } + #[test] + fn the_sentences_of_an_english_description_are_spaced() { + crate::i18n::isolate_thread(); + crate::i18n::set_locale("en"); + assert_eq!( + describe_host_delete(true, 1, forwards(2)).as_deref(), + Some( + "Its open terminal and SFTP tabs will close. \ + 1 transfer batch will stop, with its progress kept for resuming. \ + 2 port forwards through this host will also be deleted." + ) + ); + } + #[test] fn deleting_a_jump_host_says_who_loses_their_way_through() { let dependents = Dependents { diff --git a/src/host/host_panel.rs b/src/host/host_panel.rs index 57d3811..e9068ea 100644 --- a/src/host/host_panel.rs +++ b/src/host/host_panel.rs @@ -27,6 +27,7 @@ use crate::app::{ OpenExplorer, RenameGroup, }; +use crate::i18n::{UiLocale, t, tn}; use crate::shared::{HostMark, RowTooltip, RowTooltips}; use super::{ @@ -83,12 +84,17 @@ impl HostPanel { let tree_state = cx.new(|cx| TreeState::new(cx).items(items)); let search = cx.new(|cx| { InputState::new(window, cx) - .placeholder("搜索主机或分组") + .placeholder(t!("host.panel.search")) .clean_on_escape() }); let subscriptions = vec![ cx.observe(&store, |this, _, cx| this.on_store_changed(cx)), + cx.observe_global_in::(window, |this, window, cx| { + this.search.update(cx, |search, cx| { + search.set_placeholder(t!("host.panel.search"), window, cx) + }); + }), cx.subscribe_in( &search, window, @@ -339,7 +345,7 @@ impl Panel for HostPanel { h_flex() .gap_1() .child(Icon::new(CatalogIcon::Server).small()) - .child("主机") + .child(t!("host.panel.title")) } fn toolbar_buttons(&mut self, _: &mut Window, _: &mut Context) -> Option> { @@ -347,11 +353,11 @@ impl Panel for HostPanel { Some(vec![ Button::new("new-host-panel") .icon(IconName::Plus) - .tooltip("新建主机…") + .tooltip(t!("host.menu.new_host")) .on_click(|_, window, cx| window.dispatch_action(Box::new(NewHost), cx)), Button::new("new-group") .icon(Icon::new(CatalogIcon::FolderPlus)) - .tooltip("新建分组…") + .tooltip(t!("host.menu.new_group")) .on_click(|_, window, cx| window.dispatch_action(Box::new(NewGroup), cx)), ]) } @@ -618,7 +624,7 @@ fn render_row( div() .id(("group-count", id.0)) .test_support() - .aria_label(format!("{count} 台主机")) + .aria_label(tn!("host.count.hosts", count)) .flex_shrink_0() .text_xs() .text_color(cx.theme().muted_foreground.opacity(0.7)) @@ -785,28 +791,28 @@ pub fn host_menu(menu: PopupMenu, host: &Host) -> PopupMenu { let id = host.id; // Named after what the address is, as in the tabs' menus. let copy_address = if host.address_is_ip() { - "复制 IP 地址" + t!("host.menu.copy_ip") } else { - "复制主机名" + t!("host.menu.copy_host_name") }; menu.menu_with_icon( - "连接", + t!("host.menu.connect"), Icon::new(CatalogIcon::Plug), Box::new(ConnectHost(id)), ) .menu_with_icon( - "打开 SFTP", + t!("host.menu.open_sftp"), Icon::new(CatalogIcon::FolderTree), Box::new(OpenExplorer(id)), ) .separator() .menu_with_icon( - "编辑主机…", + t!("host.menu.edit_host"), Icon::new(CatalogIcon::Pencil), Box::new(EditHost(id)), ) .menu_with_icon( - "复制", + t!("host.menu.duplicate"), Icon::new(IconName::Copy), Box::new(DuplicateHost(id)), ) @@ -817,13 +823,13 @@ pub fn host_menu(menu: PopupMenu, host: &Host) -> PopupMenu { Box::new(CopyHostAddress(id)), ) .menu_with_icon( - "复制 ID", + t!("host.menu.copy_id"), Icon::new(CatalogIcon::ClipboardCopy), Box::new(CopyHostId(id)), ) .separator() .menu_with_icon( - "删除", + t!("common.delete"), Icon::new(CatalogIcon::Trash), Box::new(DeleteHost(id)), ) @@ -837,48 +843,52 @@ fn build_context_menu(node: Option, store: &HostStore, menu: PopupMenu }, Some(HostNode::Group(id)) => menu .menu_with_icon( - "连接组内主机", + t!("host.menu.connect_group"), Icon::new(CatalogIcon::Plug), Box::new(ConnectGroup(id)), ) .separator() .menu_with_icon( - "新建主机…", + t!("host.menu.new_host"), Icon::new(IconName::Plus), Box::new(NewHostInGroup(id)), ) .menu_with_icon( - "新建子分组…", + t!("host.menu.new_subgroup"), Icon::new(CatalogIcon::FolderPlus), Box::new(NewChildGroup(id)), ) .separator() .menu_with_icon( - "展开全部分组", + t!("host.menu.expand_all"), Icon::new(IconName::FolderOpen), Box::new(ExpandAllGroups), ) .menu_with_icon( - "折叠全部分组", + t!("host.menu.collapse_all"), Icon::new(IconName::Folder), Box::new(CollapseAllGroups), ) .separator() .menu_with_icon( - "重命名分组…", + t!("host.menu.rename_group"), Icon::new(CatalogIcon::Pencil), Box::new(RenameGroup(id)), ) .separator() .menu_with_icon( - "删除分组", + t!("host.menu.delete_group"), Icon::new(CatalogIcon::Trash), Box::new(DeleteGroup(id)), ), None => menu - .menu_with_icon("新建主机…", Icon::new(IconName::Plus), Box::new(NewHost)) .menu_with_icon( - "新建分组…", + t!("host.menu.new_host"), + Icon::new(IconName::Plus), + Box::new(NewHost), + ) + .menu_with_icon( + t!("host.menu.new_group"), Icon::new(CatalogIcon::FolderPlus), Box::new(NewGroup), ), diff --git a/src/host/mod.rs b/src/host/mod.rs index 343f36f..9801880 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -22,7 +22,7 @@ pub use database::{HostDatabase, StoredData}; pub use forward::*; pub use group_dialog::{GroupForm, confirm_delete_group, open_group_dialog}; pub use host_dialog::{ - DeleteHandler, Dependents, HostForm, confirm_delete_host, open_host_dialog, + DeleteHandler, Dependents, HostForm, confirm_delete_host, join_sentences, open_host_dialog, open_temporary_connection_dialog, }; pub use host_panel::{HostPanel, host_menu}; diff --git a/src/host/model.rs b/src/host/model.rs index fd09558..3397a65 100644 --- a/src/host/model.rs +++ b/src/host/model.rs @@ -1,6 +1,7 @@ use gpui_kit::{Rgba, SharedString, rgb}; use rand::{Rng as _, distr::Alphanumeric}; +use crate::i18n::t; use crate::secrets::SecretRef; use super::CredentialId; @@ -180,10 +181,10 @@ impl ProxyKind { /// Every kind, in the order the form lists them. pub const ALL: [ProxyKind; 2] = [ProxyKind::Http, ProxyKind::Socks5]; - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - ProxyKind::Http => "HTTP 代理", - ProxyKind::Socks5 => "SOCKS5 代理", + ProxyKind::Http => t!("host.proxy_kind.http"), + ProxyKind::Socks5 => t!("host.proxy_kind.socks5"), } } @@ -375,11 +376,11 @@ pub enum ConnectionState { } impl ConnectionState { - pub fn label(self) -> &'static str { + pub fn label(self) -> SharedString { match self { - ConnectionState::Disconnected => "未连接", - ConnectionState::Connecting => "连接中", - ConnectionState::Connected => "已连接", + ConnectionState::Disconnected => t!("host.state.disconnected"), + ConnectionState::Connecting => t!("host.state.connecting"), + ConnectionState::Connected => t!("host.state.connected"), } } @@ -645,22 +646,22 @@ pub enum HostDraftError { } impl HostDraftError { - pub fn message(self) -> &'static str { + pub fn message(self) -> SharedString { match self { - HostDraftError::Name => "请输入名称", - HostDraftError::Address => "请输入地址", - HostDraftError::Port => "端口必须是 1 到 65535 之间的数字", - HostDraftError::NoJumpHosts => "请添加跳板主机", - HostDraftError::DeletedJumpHost => "请移除已删除的跳板主机", - HostDraftError::ProxyAddress => "请输入代理地址", - HostDraftError::ProxyPort => "代理端口必须是 1 到 65535 之间的数字", + HostDraftError::Name => t!("host.draft.name"), + HostDraftError::Address => t!("host.draft.address"), + HostDraftError::Port => t!("host.draft.port"), + HostDraftError::NoJumpHosts => t!("host.draft.no_jump_hosts"), + HostDraftError::DeletedJumpHost => t!("host.draft.deleted_jump_host"), + HostDraftError::ProxyAddress => t!("host.draft.proxy_address"), + HostDraftError::ProxyPort => t!("host.draft.proxy_port"), } } } impl std::fmt::Display for HostDraftError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(self.message()) + formatter.write_str(&self.message()) } } diff --git a/src/host/private_key.rs b/src/host/private_key.rs index 8d7c8e8..3a3d024 100644 --- a/src/host/private_key.rs +++ b/src/host/private_key.rs @@ -16,6 +16,7 @@ use russh::keys::{Algorithm, PrivateKey, PublicKey}; use zeroize::Zeroizing; use super::PublicId; +use crate::i18n::t; /// The kinds of key the credential form generates. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -120,10 +121,10 @@ pub enum PastedKeyError { impl fmt::Display for PastedKeyError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(match self { - PastedKeyError::Empty => "请粘贴私钥", - PastedKeyError::PublicKey => "这是公钥,请粘贴私钥(以 -----BEGIN 开头的那一段)", - PastedKeyError::Unreadable => "无法识别这段私钥,支持 OpenSSH、PEM 和 PuTTY 格式", + formatter.write_str(&match self { + PastedKeyError::Empty => t!("host.pasted_key.empty"), + PastedKeyError::PublicKey => t!("host.pasted_key.public_key"), + PastedKeyError::Unreadable => t!("host.pasted_key.unreadable"), }) } } diff --git a/src/host/quick_connect.rs b/src/host/quick_connect.rs index f1d48b6..7433fb5 100644 --- a/src/host/quick_connect.rs +++ b/src/host/quick_connect.rs @@ -24,6 +24,7 @@ use gpui_kit::*; use gpui_kit::prelude::FluentBuilder as _; use crate::app::ConnectHost; +use crate::i18n::t; use crate::shared::HostMark; use super::{HostId, HostOs, HostStore}; @@ -335,14 +336,14 @@ impl ListDelegate for QuickConnectList { cx: &mut Context>, ) -> impl IntoElement { let text = if !self.choice.has_hosts() { - "还没有保存的主机" + t!("host.quick_connect.no_hosts") } else { - "没有匹配的主机" + t!("host.quick_connect.no_matches") }; h_flex() .id("quick-connect-empty") .test_support() - .aria_label(text) + .aria_label(text.clone()) .size_full() .justify_center() .text_sm() @@ -352,7 +353,7 @@ impl ListDelegate for QuickConnectList { } /// One key and what it does, for the footer. -fn key_hint(keys: &str, label: &'static str) -> impl IntoElement { +fn key_hint(keys: &str, label: SharedString) -> impl IntoElement { h_flex() .gap_1p5() .children(Keystroke::parse(keys).ok().map(Kbd::new)) @@ -385,7 +386,7 @@ pub fn open_quick_connect_dialog( move |dialog, _, cx| { let theme = cx.theme(); dialog - .title("快速连接") + .title(t!("host.quick_connect.title")) .overlay_closable(false) .child( div() @@ -435,8 +436,7 @@ pub fn open_quick_connect_dialog( }) }) .child( - List::new(&list) - .search_placeholder("搜索主机名称、地址、用户名、备注或分组"), + List::new(&list).search_placeholder(t!("host.quick_connect.search")), ), ) .when(MULTIPLE, |dialog| { @@ -447,9 +447,12 @@ pub fn open_quick_connect_dialog( .gap_4() .text_sm() .text_color(theme.muted_foreground) - .child(key_hint("tab", "选中")) - .child(key_hint(select_all, "全选")) - .child(key_hint("shift-enter", "批量连接")), + .child(key_hint("tab", t!("host.quick_connect.choose"))) + .child(key_hint(select_all, t!("host.quick_connect.choose_all"))) + .child(key_hint( + "shift-enter", + t!("host.quick_connect.connect_chosen"), + )), ) }) } diff --git a/src/host/secret_fields.rs b/src/host/secret_fields.rs index ff6f417..4a2c3b5 100644 --- a/src/host/secret_fields.rs +++ b/src/host/secret_fields.rs @@ -13,6 +13,7 @@ use gpui_kit::component::{ use gpui_kit::*; use zeroize::Zeroizing; +use crate::i18n::t; use crate::secrets::{SecretRef, SharedSecretStore}; /// What the keychain had for the login being edited. @@ -51,17 +52,17 @@ impl SecretFields { let password = cx.new(|cx| { InputState::new(window, cx) .masked(true) - .placeholder("留空则每次连接都询问") + .placeholder(t!("host.secret_fields.ask_each_time")) }); let key_path = cx.new(|cx| { InputState::new(window, cx) - .placeholder("选择 OpenSSH 私钥文件") + .placeholder(t!("host.secret_fields.key_path_placeholder")) .default_value(key_path.unwrap_or_default()) }); let passphrase = cx.new(|cx| { InputState::new(window, cx) .masked(true) - .placeholder("留空则每次连接都询问") + .placeholder(t!("host.secret_fields.ask_each_time")) }); // A passphrase belongs to a key file, so picking another key makes // whatever is in the field meaningless. Clearing it is also the @@ -175,7 +176,7 @@ impl SecretFields { /// What the empty password field says leaving it empty does. pub fn set_password_placeholder( &mut self, - placeholder: &'static str, + placeholder: impl Into, window: &mut Window, cx: &mut Context, ) { @@ -187,7 +188,7 @@ impl SecretFields { /// What the empty passphrase field says leaving it empty does. pub fn set_passphrase_placeholder( &mut self, - placeholder: &'static str, + placeholder: impl Into, window: &mut Window, cx: &mut Context, ) { @@ -202,7 +203,7 @@ impl SecretFields { files: true, directories: false, multiple: false, - prompt: Some("选择 SSH 私钥".into()), + prompt: Some(t!("host.secret_fields.choose_key_prompt")), }); cx.spawn_in(window, async move |this, cx| { let Ok(Ok(Some(paths))) = receiver.await else { @@ -286,7 +287,7 @@ impl SecretFields { .child(Input::new(&key_path).id(id).small().flex_1()) .child( Button::new(choose_id) - .label("选择…") + .label(t!("host.secret_fields.choose")) .small() .on_click(move |_, window, cx| { fields.update(cx, |fields, cx| fields.choose_key(window, cx)) diff --git a/src/host/snippet.rs b/src/host/snippet.rs index ecb430c..0287768 100644 --- a/src/host/snippet.rs +++ b/src/host/snippet.rs @@ -8,6 +8,7 @@ use std::cmp::Ordering; use gpui_kit::SharedString; use super::{GroupId, HostId}; +use crate::i18n::t; /// Stable identity of a snippet. Never reused within a process. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -69,14 +70,14 @@ impl SnippetDraft { name: &str, command: &str, category: Option, - ) -> Result { + ) -> Result { let name = name.trim(); if name.is_empty() { - return Err("请输入名称"); + return Err(t!("host.snippet_draft.name")); } let command = command.trim_end().trim_start_matches(['\n', '\r']); if command.trim().is_empty() { - return Err("请输入命令"); + return Err(t!("host.snippet_draft.command")); } Ok(Self::new(name.to_owned(), command.to_owned(), category)) } @@ -149,10 +150,13 @@ mod tests { #[test] fn a_snippet_needs_a_name_and_a_command() { - assert_eq!(SnippetDraft::validated(" ", "ls", None), Err("请输入名称")); + assert_eq!( + SnippetDraft::validated(" ", "ls", None), + Err("请输入名称".into()) + ); assert_eq!( SnippetDraft::validated("列表", " \n \n", None), - Err("请输入命令") + Err("请输入命令".into()) ); let draft = SnippetDraft::validated( " 清理日志 ", diff --git a/src/host/ssh_link.rs b/src/host/ssh_link.rs index 478f09f..1dedf0e 100644 --- a/src/host/ssh_link.rs +++ b/src/host/ssh_link.rs @@ -10,6 +10,7 @@ use url::{Host as UrlHost, ParseError, Url}; use zeroize::Zeroizing; use super::{AuthKind, HostDraft}; +use crate::i18n::t; /// The port when the link names none. const SSH_PORT: u16 = 22; @@ -43,32 +44,24 @@ impl SshLink { pub fn parse(link: &str, tab: Option<&str>) -> Result { let link = link.trim(); if link.is_empty() { - return Err( - "启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。" - .into(), - ); + return Err(t!("host.link.missing").to_string()); } - let malformed = || { - "链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。" - .to_string() - }; + let malformed = || t!("host.link.malformed").to_string(); // Without `://`, `user:password@host` would read as the scheme // `user`, and the error would show it. if !link.contains("://") { return Err(malformed()); } let url = Url::parse(link).map_err(|error| match error { - ParseError::InvalidPort => "链接里的端口无效。".to_string(), - ParseError::EmptyHost => "链接里没有地址。".to_string(), + ParseError::InvalidPort => t!("host.link.invalid_port").to_string(), + ParseError::EmptyHost => t!("host.link.no_address").to_string(), _ => malformed(), })?; let kind = match url.scheme() { "ssh" => LinkKind::Ssh, "sftp" => LinkKind::Sftp, scheme => { - return Err(format!( - "只支持 ssh:// 和 sftp:// 链接,不支持 {scheme}://。" - )); + return Err(t!("host.link.unsupported_scheme", scheme = scheme).to_string()); } }; // `ssh` is not one of the schemes the URL standard knows, so an IPv4 @@ -81,19 +74,16 @@ impl SshLink { None => String::new(), }; if address.is_empty() { - return Err("链接里没有地址。".into()); + return Err(t!("host.link.no_address").to_string()); } let port = match url.port() { - Some(0) => return Err("链接里的端口无效。".into()), + Some(0) => return Err(t!("host.link.invalid_port").to_string()), Some(port) => port, None => SSH_PORT, }; let user = decode(url.username())?; if user.is_empty() { - return Err(format!( - "链接里没有用户名,应为 {}://用户@地址[:端口]。", - url.scheme() - )); + return Err(t!("host.link.no_user", scheme = url.scheme()).to_string()); } let password = match url.password() { Some(password) => Some(Zeroizing::new(decode(password)?)), @@ -146,7 +136,7 @@ fn decode(text: &str) -> Result { percent_decode_str(text) .decode_utf8() .map(|text| text.into_owned()) - .map_err(|_| "链接里有无法识别的字符。".to_string()) + .map_err(|_| t!("host.link.unreadable").to_string()) } #[cfg(test)] diff --git a/src/host/store.rs b/src/host/store.rs index c0d1aa3..81b92c3 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use gpui_kit::{Context, EventEmitter, SharedString, Task}; use zeroize::Zeroizing; +use crate::i18n::t; use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore}; use super::database::now_seconds; @@ -97,6 +98,121 @@ pub struct HostStore { /// How many hosts the start page lists as recently connected. const MAX_RECENT: usize = 10; +/// A change the store writes through to the database, to say which one +/// could not be saved. +#[derive(Clone, Copy, Debug)] +enum Change { + NewHost, + SaveHost, + DeleteHost, + DuplicateHost, + HostOrder, + NewGroup, + SaveGroup, + AddBookmark, + DeleteBookmark, + BookmarkOrder, + NewForward, + SaveForward, + DeleteForward, + NewCredential, + SaveCredential, + DeleteCredential, + GroupExpanded, + AllGroupsExpanded, + MoveHostNode, + DeleteGroup, + Recent, + HostOs, + NewSnippet, + SaveSnippet, + DeleteSnippet, + NewSnippetCategory, + RenameSnippetCategory, + DeleteSnippetCategory, +} + +impl Change { + /// That the change is not in the database, and the database's `error`. + fn not_saved(self, error: &rusqlite::Error) -> SharedString { + match self { + Change::NewHost => t!("host.store.not_saved.new_host", error = error), + Change::SaveHost => t!("host.store.not_saved.save_host", error = error), + Change::DeleteHost => t!("host.store.not_saved.delete_host", error = error), + Change::DuplicateHost => t!("host.store.not_saved.duplicate_host", error = error), + Change::HostOrder => t!("host.store.not_saved.host_order", error = error), + Change::NewGroup => t!("host.store.not_saved.new_group", error = error), + Change::SaveGroup => t!("host.store.not_saved.save_group", error = error), + Change::AddBookmark => t!("host.store.not_saved.add_bookmark", error = error), + Change::DeleteBookmark => t!("host.store.not_saved.delete_bookmark", error = error), + Change::BookmarkOrder => t!("host.store.not_saved.bookmark_order", error = error), + Change::NewForward => t!("host.store.not_saved.new_forward", error = error), + Change::SaveForward => t!("host.store.not_saved.save_forward", error = error), + Change::DeleteForward => t!("host.store.not_saved.delete_forward", error = error), + Change::NewCredential => t!("host.store.not_saved.new_credential", error = error), + Change::SaveCredential => t!("host.store.not_saved.save_credential", error = error), + Change::DeleteCredential => t!("host.store.not_saved.delete_credential", error = error), + Change::GroupExpanded => t!("host.store.not_saved.group_expanded", error = error), + Change::AllGroupsExpanded => { + t!("host.store.not_saved.all_groups_expanded", error = error) + } + Change::MoveHostNode => t!("host.store.not_saved.move_node", error = error), + Change::DeleteGroup => t!("host.store.not_saved.delete_group", error = error), + Change::Recent => t!("host.store.not_saved.recent", error = error), + Change::HostOs => t!("host.store.not_saved.host_os", error = error), + Change::NewSnippet => t!("host.store.not_saved.new_snippet", error = error), + Change::SaveSnippet => t!("host.store.not_saved.save_snippet", error = error), + Change::DeleteSnippet => t!("host.store.not_saved.delete_snippet", error = error), + Change::NewSnippetCategory => { + t!("host.store.not_saved.new_snippet_category", error = error) + } + Change::RenameSnippetCategory => t!( + "host.store.not_saved.rename_snippet_category", + error = error + ), + Change::DeleteSnippetCategory => t!( + "host.store.not_saved.delete_snippet_category", + error = error + ), + } + } +} + +/// What a failed keychain write of `secret` says, given the keychain's +/// error: writing it when `writing`, deleting it otherwise. +fn secret_failure(secret: &SecretRef, writing: bool) -> fn(&str) -> SharedString { + match (secret, writing) { + (SecretRef::Password { .. }, true) => { + |error| t!("host.store.secret.password_write", error = error) + } + (SecretRef::Password { .. }, false) => { + |error| t!("host.store.secret.password_delete", error = error) + } + (SecretRef::Passphrase { .. }, true) => { + |error| t!("host.store.secret.passphrase_write", error = error) + } + (SecretRef::Passphrase { .. }, false) => { + |error| t!("host.store.secret.passphrase_delete", error = error) + } + (SecretRef::Credential { .. }, true) => { + |error| t!("host.store.secret.credential_write", error = error) + } + (SecretRef::Credential { .. }, false) => { + |error| t!("host.store.secret.credential_delete", error = error) + } + (SecretRef::Proxy { .. }, true) => { + |error| t!("host.store.secret.proxy_write", error = error) + } + (SecretRef::Proxy { .. }, false) => { + |error| t!("host.store.secret.proxy_delete", error = error) + } + // Kept in memory, which does not fail. + (SecretRef::Temporary { .. }, _) => { + |error| t!("host.store.secret.temporary", error = error) + } + } +} + /// What the store tells the workspace about, beyond plain change notification. #[derive(Clone, Debug, PartialEq, Eq)] pub enum HostStoreEvent { @@ -216,9 +332,10 @@ impl HostStore { /// on the exact shape (ids 1..=6 in the order listed here). pub fn seed() -> Self { let mut store = Self::empty(); - let production = store.insert_group_unnotified(GroupDraft::new("生产", None)); - let staging = store.insert_group_unnotified(GroupDraft::new("测试", None)); - let development = store.insert_group_unnotified(GroupDraft::new("开发", None)); + // Test data in Chinese, as the UI tests look for it. + let [production, staging, development] = + ["生产", "测试", "开发"] // i18n: keep + .map(|name| store.insert_group_unnotified(GroupDraft::new(name, None))); let drafts = [ HostDraft::new( "web-01", @@ -478,7 +595,7 @@ impl HostStore { pub fn insert(&mut self, draft: HostDraft, cx: &mut Context) -> HostId { let id = self.insert_unnotified(draft); if let Some(host) = self.host(id) { - self.persist("新建主机", cx, |db| db.insert_host(host)); + self.persist(Change::NewHost, cx, |db| db.insert_host(host)); } cx.notify(); id @@ -516,7 +633,7 @@ impl HostStore { let updated = self.update_unnotified(id, draft); if updated { if let Some(host) = self.host(id) { - self.persist("保存主机", cx, |db| db.update_host(host)); + self.persist(Change::SaveHost, cx, |db| db.update_host(host)); } if let Some(previous) = previous { // The host no longer reads the password it did, say @@ -563,7 +680,7 @@ impl HostStore { let login = self.login(id); let removed = self.remove_unnotified(id); if removed { - self.persist("删除主机", cx, |db| db.remove_host(id)); + self.persist(Change::DeleteHost, cx, |db| db.remove_host(id)); if let Some(login) = login { self.forget_passwords_of(&login, cx); } @@ -595,12 +712,14 @@ impl HostStore { self.hosts.len() != before } - /// Copy a host as ` 副本`, placed right after the original. + /// Copy a host as ` 副本` (` copy`), placed right after the + /// original. The name is the copy's own from then on, in the language it + /// was made in. pub fn duplicate(&mut self, id: HostId, cx: &mut Context) -> Option { let copy = self.duplicate_unnotified(id)?; if let Some(host) = self.host(copy) { - self.persist("复制主机", cx, |db| db.insert_host(host)); - self.persist("保存主机顺序", cx, |db| { + self.persist(Change::DuplicateHost, cx, |db| db.insert_host(host)); + self.persist(Change::HostOrder, cx, |db| { db.save_tree_order(&self.groups, &self.hosts) }); } @@ -611,7 +730,7 @@ impl HostStore { pub fn duplicate_unnotified(&mut self, id: HostId) -> Option { let ix = self.hosts.iter().position(|s| s.id == id)?; let mut draft = self.hosts[ix].draft(); - draft.name = format!("{} 副本", draft.name).into(); + draft.name = t!("host.store.copy_name", name = draft.name); // Same host, so the copy already knows what it will find there. let os = self.hosts[ix].os; let copy_id = HostId(self.next_host_id); @@ -633,7 +752,7 @@ impl HostStore { pub fn insert_group(&mut self, draft: GroupDraft, cx: &mut Context) -> GroupId { let id = self.insert_group_unnotified(draft); if let Some(group) = self.group(id) { - self.persist("新建分组", cx, |db| db.insert_group(group)); + self.persist(Change::NewGroup, cx, |db| db.insert_group(group)); } cx.notify(); id @@ -654,7 +773,7 @@ impl HostStore { let updated = self.update_group_unnotified(id, draft); if updated { if let Some(group) = self.group(id) { - self.persist("保存分组", cx, |db| db.update_group(group)); + self.persist(Change::SaveGroup, cx, |db| db.update_group(group)); } cx.notify(); } @@ -711,7 +830,9 @@ impl HostStore { // A link's host is not in the database: its bookmarks live as long // as it does. if !self.is_temporary(id) { - self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path)); + self.persist(Change::AddBookmark, cx, |db| { + db.insert_bookmark(id, side, path) + }); } cx.notify(); true @@ -740,7 +861,9 @@ impl HostStore { return false; } if !self.is_temporary(id) { - self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path)); + self.persist(Change::DeleteBookmark, cx, |db| { + db.remove_bookmark(id, side, path) + }); } cx.notify(); true @@ -774,7 +897,7 @@ impl HostStore { return false; } if !self.is_temporary(id) { - self.persist("调整书签顺序", cx, |db| { + self.persist(Change::BookmarkOrder, cx, |db| { db.set_bookmark_order(id, side, self.bookmarks(id, side)) }); } @@ -827,7 +950,7 @@ impl HostStore { ) -> Option { let id = self.insert_forward_unnotified(draft)?; if let Some(rule) = self.forward(id) { - self.persist("新建端口转发", cx, |db| db.insert_forward(rule)); + self.persist(Change::NewForward, cx, |db| db.insert_forward(rule)); } cx.notify(); Some(id) @@ -864,7 +987,7 @@ impl HostStore { return false; } if let Some(rule) = self.forward(id) { - self.persist("保存端口转发", cx, |db| db.update_forward(rule)); + self.persist(Change::SaveForward, cx, |db| db.update_forward(rule)); } if needs_restart { cx.emit(HostStoreEvent::ForwardSettingsChanged(id)); @@ -890,7 +1013,7 @@ impl HostStore { if !self.remove_forward_unnotified(id) { return false; } - self.persist("删除端口转发", cx, |db| db.remove_forward(id)); + self.persist(Change::DeleteForward, cx, |db| db.remove_forward(id)); cx.notify(); true } @@ -1004,7 +1127,9 @@ impl HostStore { ) -> CredentialId { let id = self.insert_credential_unnotified(draft); if let Some(credential) = self.credential(id) { - self.persist("新建凭据", cx, |db| db.insert_credential(credential)); + self.persist(Change::NewCredential, cx, |db| { + db.insert_credential(credential) + }); } cx.notify(); id @@ -1047,7 +1172,9 @@ impl HostStore { return false; } if let Some(credential) = self.credential(id) { - self.persist("保存凭据", cx, |db| db.update_credential(credential)); + self.persist(Change::SaveCredential, cx, |db| { + db.update_credential(credential) + }); } if previous.kind == CredentialKind::Password && self @@ -1108,7 +1235,9 @@ impl HostStore { }; let released = self.remove_credential_unnotified(id); let auth = credential.kind.without_credential(); - self.persist("删除凭据", cx, |db| db.remove_credential(id, auth)); + self.persist(Change::DeleteCredential, cx, |db| { + db.remove_credential(id, auth) + }); if credential.kind == CredentialKind::Password { self.save_secret(credential.password_secret(), None, cx); } @@ -1155,7 +1284,7 @@ impl HostStore { return false; } group.expanded = expanded; - self.persist("保存分组展开状态", cx, |db| { + self.persist(Change::GroupExpanded, cx, |db| { db.set_group_expanded(id, expanded) }); cx.notify(); @@ -1170,7 +1299,7 @@ impl HostStore { for group in &mut self.groups { group.expanded = expanded; } - self.persist("保存所有分组展开状态", cx, |db| { + self.persist(Change::AllGroupsExpanded, cx, |db| { db.set_all_groups_expanded(expanded) }); cx.notify(); @@ -1183,7 +1312,7 @@ impl HostStore { if !self.move_node_unnotified(source, drop) { return false; } - self.persist("调整主机顺序", cx, |db| { + self.persist(Change::MoveHostNode, cx, |db| { db.save_tree_order(&self.groups, &self.hosts) }); cx.notify(); @@ -1287,7 +1416,7 @@ impl HostStore { let passwords = self.passwords_of(&self.hosts_under(id)); let removed = self.remove_group_unnotified(id); // One delete mirrors the whole subtree: both foreign keys cascade. - self.persist("删除分组", cx, |db| db.remove_group(id)); + self.persist(Change::DeleteGroup, cx, |db| db.remove_group(id)); for password in passwords { self.forget_password(password, cx); } @@ -1319,7 +1448,7 @@ impl HostStore { return; } if state.is_connected() && !self.is_temporary(id) { - self.persist("记录最近连接", cx, |db| { + self.persist(Change::Recent, cx, |db| { db.touch_connected(id, now_seconds()) }); } @@ -1353,7 +1482,7 @@ impl HostStore { return; } if !self.is_temporary(id) { - self.persist("记录主机系统", cx, |db| db.set_host_os(id, os)); + self.persist(Change::HostOs, cx, |db| db.set_host_os(id, os)); } cx.notify(); } @@ -1406,7 +1535,7 @@ impl HostStore { let dir = self .key_dir .as_deref() - .ok_or_else(|| io::Error::other("没有可以保存私钥的目录"))?; + .ok_or_else(|| io::Error::other(t!("host.store.no_key_dir").to_string()))?; let replace = credential .and_then(|id| self.credential(id)) .and_then(|credential| credential.key_path.as_deref()) @@ -1442,7 +1571,7 @@ impl HostStore { if let Err(error) = std::fs::remove_file(path) && error.kind() != io::ErrorKind::NotFound { - self.report_failure(format!("ShellRS 保存的私钥未能删除:{error}").into(), cx); + self.report_failure(t!("host.store.key_not_deleted", error = error), cx); } self.save_secret(SecretRef::passphrase(path), None, cx); } @@ -1472,18 +1601,7 @@ impl HostStore { cx: &mut Context, ) -> Task> { let secrets = self.secrets(); - let failure = match (&secret, value.is_some()) { - (SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串", - (SecretRef::Password { .. }, false) => "密码未能从系统钥匙串删除", - (SecretRef::Passphrase { .. }, true) => "私钥口令未能写入系统钥匙串", - (SecretRef::Passphrase { .. }, false) => "私钥口令未能从系统钥匙串删除", - (SecretRef::Credential { .. }, true) => "凭据的密码未能写入系统钥匙串", - (SecretRef::Credential { .. }, false) => "凭据的密码未能从系统钥匙串删除", - (SecretRef::Proxy { .. }, true) => "代理的密码未能写入系统钥匙串", - (SecretRef::Proxy { .. }, false) => "代理的密码未能从系统钥匙串删除", - // Kept in memory, which does not fail. - (SecretRef::Temporary { .. }, _) => "这次连接的密码未能记下", - }; + let failure = secret_failure(&secret, value.is_some()); cx.spawn(async move |this, cx| { let result = cx .background_executor() @@ -1495,7 +1613,7 @@ impl HostStore { }) .await; result.map_err(|error| { - let message = SharedString::from(format!("{failure}:{error}")); + let message = failure(&error.to_string()); this.update(cx, |_, cx| { cx.emit(HostStoreEvent::PersistFailed(message.clone())); }) @@ -1556,14 +1674,14 @@ impl HostStore { /// write becomes an event; the in-memory change stands. fn persist( &self, - action: &str, + change: Change, cx: &mut Context, write: impl FnOnce(&HostDatabase) -> rusqlite::Result<()>, ) { if let Some(database) = &self.database && let Err(error) = write(database) { - self.report_failure(format!("{action}未能保存到本地数据库:{error}").into(), cx); + self.report_failure(change.not_saved(&error), cx); } } diff --git a/src/host/store/snippets.rs b/src/host/store/snippets.rs index d0ed2c5..1c0655a 100644 --- a/src/host/store/snippets.rs +++ b/src/host/store/snippets.rs @@ -3,7 +3,7 @@ use gpui_kit::{Context, SharedString}; -use super::HostStore; +use super::{Change, HostStore}; use crate::host::{ Snippet, SnippetCategory, SnippetCategoryId, SnippetDraft, SnippetId, SnippetScope, }; @@ -40,7 +40,7 @@ impl HostStore { pub fn insert_snippet(&mut self, draft: SnippetDraft, cx: &mut Context) -> SnippetId { let id = self.insert_snippet_unnotified(draft); if let Some(snippet) = self.snippet(id) { - self.persist("新建命令片段", cx, |db| db.insert_snippet(snippet)); + self.persist(Change::NewSnippet, cx, |db| db.insert_snippet(snippet)); } cx.notify(); id @@ -64,7 +64,7 @@ impl HostStore { return false; } if let Some(snippet) = self.snippet(id) { - self.persist("保存命令片段", cx, |db| db.update_snippet(snippet)); + self.persist(Change::SaveSnippet, cx, |db| db.update_snippet(snippet)); } cx.notify(); true @@ -83,7 +83,7 @@ impl HostStore { if !self.remove_snippet_unnotified(id) { return false; } - self.persist("删除命令片段", cx, |db| db.remove_snippet(id)); + self.persist(Change::DeleteSnippet, cx, |db| db.remove_snippet(id)); cx.notify(); true } @@ -101,7 +101,7 @@ impl HostStore { ) -> SnippetCategoryId { let id = self.insert_snippet_category_unnotified(name); if let Some(category) = self.snippet_category(id) { - self.persist("新建片段分类", cx, |db| { + self.persist(Change::NewSnippetCategory, cx, |db| { db.insert_snippet_category(category) }); } @@ -132,7 +132,7 @@ impl HostStore { return false; } if let Some(category) = self.snippet_category(id) { - self.persist("重命名片段分类", cx, |db| { + self.persist(Change::RenameSnippetCategory, cx, |db| { db.update_snippet_category(category) }); } @@ -166,7 +166,7 @@ impl HostStore { return false; } // The database takes the snippets through `ON DELETE CASCADE`. - self.persist("删除片段分类", cx, |db| { + self.persist(Change::DeleteSnippetCategory, cx, |db| { db.remove_snippet_category(id) }); cx.notify(); diff --git a/src/secrets/keychain.rs b/src/secrets/keychain.rs index 41a2901..47ea195 100644 --- a/src/secrets/keychain.rs +++ b/src/secrets/keychain.rs @@ -3,6 +3,7 @@ use keyring::{Entry, Error}; use zeroize::Zeroizing; use super::{SERVICE, SecretRef, SecretStore}; +use crate::i18n::{t, tn}; /// 真正的系统钥匙串。 /// @@ -22,7 +23,7 @@ impl KeychainSecretStore { } fn entry(&self, secret: &SecretRef) -> Result { - Entry::new(SERVICE, &secret.account()).map_err(|error| describe("打开", error)) + Entry::new(SERVICE, &secret.account()).map_err(|error| describe(Action::Open, error)) } } @@ -37,20 +38,20 @@ impl SecretStore for KeychainSecretStore { match self.entry(secret)?.get_password() { Ok(value) => Ok(Some(Zeroizing::new(value))), Err(Error::NoEntry) => Ok(None), - Err(error) => Err(describe("读取", error)), + Err(error) => Err(describe(Action::Read, error)), } } fn set(&self, secret: &SecretRef, value: &str) -> Result<()> { self.entry(secret)? .set_password(value) - .map_err(|error| describe("写入", error)) + .map_err(|error| describe(Action::Write, error)) } fn delete(&self, secret: &SecretRef) -> Result<()> { match self.entry(secret)?.delete_credential() { Ok(()) | Err(Error::NoEntry) => Ok(()), - Err(error) => Err(describe("删除", error)), + Err(error) => Err(describe(Action::Delete, error)), } } @@ -59,18 +60,32 @@ impl SecretStore for KeychainSecretStore { } } -/// 把钥匙串错误翻成中文。`keyring` 的 `Display` 从不打印秘密本身,但仍然只在 +/// 把钥匙串错误说成界面语言的话。`keyring` 的 `Display` 从不打印秘密本身,但仍然只在 /// 兜底分支里用它,免得将来上游改了实现把明文带出来。 -fn describe(action: &str, error: Error) -> anyhow::Error { +fn describe(action: Action, error: Error) -> anyhow::Error { let reason = match error { - Error::NoDefaultStore => "系统钥匙串不可用".to_string(), - Error::NoStorageAccess(_) => "系统钥匙串被锁定或拒绝访问".to_string(), - Error::BadEncoding(_) => "已保存的内容不是有效的 UTF-8 文本".to_string(), - Error::Ambiguous(items) => format!("钥匙串里有 {} 条同名条目", items.len()), - Error::TooLong(_, limit) => format!("超过系统钥匙串 {limit} 个字符的长度上限"), - other => other.to_string(), + Error::NoDefaultStore => t!("secrets.keychain.no_store"), + Error::NoStorageAccess(_) => t!("secrets.keychain.no_access"), + Error::BadEncoding(_) => t!("secrets.keychain.bad_encoding"), + Error::Ambiguous(items) => tn!("secrets.keychain.ambiguous", items.len()), + Error::TooLong(_, limit) => t!("secrets.keychain.too_long", limit = limit), + other => other.to_string().into(), }; - anyhow!("{action}系统钥匙串条目失败:{reason}") + anyhow!(match action { + Action::Open => t!("secrets.keychain.open_failed", reason = reason), + Action::Read => t!("secrets.keychain.read_failed", reason = reason), + Action::Write => t!("secrets.keychain.write_failed", reason = reason), + Action::Delete => t!("secrets.keychain.delete_failed", reason = reason), + }) +} + +/// What was done to a keychain entry, for the error that says it failed. +#[derive(Clone, Copy)] +enum Action { + Open, + Read, + Write, + Delete, } #[cfg(test)] diff --git a/src/secrets/memory.rs b/src/secrets/memory.rs index b5d4550..4f877ca 100644 --- a/src/secrets/memory.rs +++ b/src/secrets/memory.rs @@ -5,6 +5,7 @@ use anyhow::{Result, bail}; use zeroize::Zeroizing; use super::{SecretRef, SecretStore}; +use crate::i18n::t; /// 进程内的秘密存储,给测试用:行为和真钥匙串一致,但什么都不落地。 #[derive(Default)] @@ -60,7 +61,7 @@ impl SecretStore for NoSecretStore { } fn set(&self, _: &SecretRef, _: &str) -> Result<()> { - bail!("系统钥匙串不可用,密码无法保存") + bail!(t!("secrets.unavailable")) } fn delete(&self, _: &SecretRef) -> Result<()> { diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 9738ea1..0059559 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -1835,7 +1835,7 @@ mod tests { let reply = ConnectionPromptReply::Answers(vec![ConnectionSecret::new("super-secret")]); let debug = format!("{reply:?}"); assert!(!debug.contains("super-secret")); - assert!(debug.contains("已隐藏")); + assert!(debug.contains("redacted")); } #[test] diff --git a/src/workspace/status_bar.rs b/src/workspace/status_bar.rs index cf60727..f12c323 100644 --- a/src/workspace/status_bar.rs +++ b/src/workspace/status_bar.rs @@ -109,7 +109,7 @@ impl RenderOnce for WorkspaceStatus { ) } None => ( - t!("workspace.status.not_connected"), + ConnectionState::Disconnected.label(), Icon::new(CatalogIcon::Unplug).text_color(muted), None, terminal,