主机、凭据、端口转发的文案接入多语言

host/、credential/、forward/、connection.rs、secrets/ 的界面文案、校验和报错改经 t! / tn!,
放进 locales/{host,credential,forward,connection,secrets}.yml,中文逐字不变。
写库失败按改动种类各给整句(Change),钥匙串失败同理;删除说明按句拼接(join_sentences),
数量用 tn!。三个侧栏列表的搜索框占位随语言切换重设。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 22:03:29 +08:00
1 parent 352ffcef55
commit 1c61269c52
33 files changed
+2080 -658

No files matched your search

+21
View File
@@ -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
+156
View File
@@ -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
+286
View File
@@ -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}
+613
View File
@@ -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}"
+38
View File
@@ -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
-3
View File
@@ -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}"
+64 -21
View File
@@ -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(<redacted>)")
}
}
@@ -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."
);
}
}
+101 -75
View File
@@ -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<String> {
fn usage_note(&self) -> Option<SharedString> {
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, _, _| {
+24 -18
View File
@@ -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::<UiLocale>(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)),
)
+49 -42
View File
@@ -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<Dot>, cx: &App) -> impl IntoElement {
fn render_hop(label: Option<SharedString>, dot: Option<Dot>, 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<Dot>, 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()
+62 -48
View File
@@ -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::<Vec<_>>(),
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::<Vec<_>>(),
)
};
@@ -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<Self>) -> 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<u16>) -> Vec<&'static str> {
fn bind_notes(kind: ForwardKind, host: &str, port: Option<u16>) -> Vec<SharedString> {
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();
+30 -19
View File
@@ -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::<UiLocale>(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)),
)
+9 -8
View File
@@ -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;
}
}
+50 -40
View File
@@ -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::<MissingCredential>())
{
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<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
async fn bind_listeners(bind: &ForwardEndpoint) -> Result<Vec<TcpListener>, 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<Vec<TcpListener>, 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<S
open.reply.reject(ChannelOpenFailure::ConnectFailed).await;
let reason = match failed {
Ok(Err(error)) if error.kind() == std::io::ErrorKind::ConnectionRefused => {
"连接被拒绝,该端口上没有服务在监听".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<String> {
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)]
+10 -8
View File
@@ -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()
+14 -8
View File
@@ -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}");
}
}
+10 -9
View File
@@ -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"),
})
}
}
+66 -57
View File
@@ -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<Option<GroupId>> = vec![None];
let mut labels: Vec<SharedString> = vec![ROOT_LABEL.into()];
let mut labels: Vec<SharedString> = 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<SharedString> {
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<SharedString> {
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.")
);
}
}
+181 -150
View File
@@ -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<Option<GroupId>> = vec![None];
let mut group_names: Vec<SharedString> = vec![NO_GROUP_LABEL.into()];
let mut group_names: Vec<SharedString> = 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::<Vec<_>>(),
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<Route, &'static str> {
fn committed_route(&self, cx: &App) -> Result<Route, SharedString> {
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<LoginTest, &'static str> {
fn login_test(&self, cx: &App) -> Result<LoginTest, SharedString> {
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<CommittedLogin, &'static str> {
fn committed_login(&self, cx: &App) -> Result<CommittedLogin, SharedString> {
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<HostId>]) -> Vec<JumpHost> {
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<HostForm>, 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<HostForm>, 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<SharedString> {
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<SharedString>) -> Option<SharedString> {
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 {
+32 -22
View File
@@ -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::<UiLocale>(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<Self>) -> Option<Vec<Button>> {
@@ -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<HostNode>, 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),
),
+1 -1
View File
@@ -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};
+17 -16
View File
@@ -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())
}
}
+5 -4
View File
@@ -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"),
})
}
}
+13 -10
View File
@@ -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<ListState<Self>>,
) -> 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"),
)),
)
})
}
+8 -7
View File
@@ -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<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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<SharedString>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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))
+9 -5
View File
@@ -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<SnippetCategoryId>,
) -> Result<Self, &'static str> {
) -> Result<Self, SharedString> {
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(
" 清理日志 ",
+10 -20
View File
@@ -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<Self, String> {
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<String, String> {
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)]
+162 -44
View File
@@ -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<Self>) -> 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 `<name> 副本`, placed right after the original.
/// Copy a host as `<name> 副本` (`<name> 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<Self>) -> Option<HostId> {
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<HostId> {
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<Self>) -> 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<ForwardId> {
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<Self>,
) -> Task<Result<(), SharedString>> {
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<Self>,
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);
}
}
+7 -7
View File
@@ -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<Self>) -> 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();
+28 -13
View File
@@ -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> {
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)]
+2 -1
View File
@@ -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<()> {
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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,