主机、凭据、端口转发的文案接入多语言
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:
1 parent
352ffcef55
commit
1c61269c52
33 files changed
+2080
-658
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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, _, _| {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user