Compare commits

..
8 Commits
Author SHA1 Message Date
XuzandClaude Opus 5.5 09dc45e7a2 版本号改为 0.1.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:24:36 +08:00
XuzandClaude Opus 5.5 2de318a4e6 开始页加「快速连接」:搜索保存的主机,回车或单击连接
「最近连接」标题右边,「新建主机…」左边加一个小号的「快速连接」按钮(两个按钮都改成小号)。
打开的对话框列出所有保存的主机,连接过的按最近连接排在前面,其余按名称排;每行是系统徽章、
名称和「分组 · 用户@地址:端口」。名称、地址、用户名、备注和分组都能搜,几个词都要匹配。
打开时不高亮任何一台,输入后高亮第一个匹配的,回车或单击连接。

多选(Tab 选中、⌘A 全选、⇧↵ 批量连接)已经做好但先关掉,常量 MULTIPLE 打开即可。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 19:20:57 +08:00
XuzandClaude Opus 5.5 9474c9e789 临时连接:标题栏「临时连接…」打开对话框,连一台主机而不保存
标题栏右上角的「新建主机…」换成「临时连接…」(新建主机还在主机列表的「+」、
右键菜单和 ⌘N)。对话框沿用主机对话框,去掉分组、连接方式和备注,名称可不填;
密码只留在内存里,不进钥匙串。连上后功能齐全(不像外部连接只开一个通道),
不进主机列表和最近连接,标签都关掉就没了;可以使用凭据,凭据改动和删除也
照顾到它。外部 CLI 不区分临时连接和外部连接,表格写「(未保存)」。

测试连接选「密码」却没填时,按「无密码」那样试(免认证、SSH Agent、默认私钥),
服务器要密码时报「未填写密码」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 15:52:29 +08:00
XuzandClaude Opus 5.5 835894625f README:功能列表补上右侧栏工具,截图一节加右侧栏工具的拼图
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:03:05 +08:00
XuzandClaude Opus 5.5 28d4799154 README:写明可以像 Xshell、WinSCP 一样从堡垒机打开
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:54:07 +08:00
XuzandClaude Opus 5.5 f1a7c68907 版本号改为 0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:51:28 +08:00
XuzandClaude Opus 5.5 76f832caf6 Agent skill 去掉外部连接那一条
ID 失效时 CLI 自己会报 host_not_found,Errors 一节已经写了重新 list;agent 也看不到
标签开没开,按名字 list --query 就能找到外部连接。skill 回到 3c0871e 之前的内容。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:45:02 +08:00
XuzandClaude Opus 5.5 6dcd084840 版本号改为 0.1.0:第一个正式版
更新日志的 0.1.0 一节补上右侧栏和从堡垒机打开;README 不再说目前只有 Beta 版。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 17:09:33 +08:00
29 changed files with 1316 additions and 143 deletions

No files matched your search

+19
View File
@@ -2,6 +2,23 @@
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.2]
### 新增
- 临时连接:标题栏右上角的「临时连接…」连一台主机而不保存,密码只留在内存里(新建主机请用主机列表的「+」或 ⌘N)。
- 开始页的「快速连接」:搜索保存的主机,回车或单击连接。
### 改进
- 「测试连接」不再要求填写密码,没填时按「无密码」的方式尝试。
## [0.1.1]
### 改进
- 精简了给 AI Agent 的 Skill 说明。
## [0.1.0]
ShellRS 的第一个公开版本。
@@ -12,8 +29,10 @@ ShellRS 的第一个公开版本。
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。
- 右侧栏:跟着当前远程终端的系统监控、网络连接、进程管理、系统服务、Docker、历史命令和命令片段。
- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。
- 从堡垒机打开:像调用 Xshell、WinSCP 一样用 `ssh://`、`sftp://` 链接打开终端或 SFTP,不保存连接。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
+11 -6
View File
@@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
**保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
@@ -42,10 +42,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。
- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option<GroupId>`,`Host.os` 是 `Option<HostOs>`,`Host.credential` 是 `Option<CredentialId>`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Host.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 host 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`HostLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`snippet.rs`(命令片段的模型:`Snippet` / `SnippetDraft` / `SnippetCategory` / `SnippetScope`(`All` / `Group` / `Host`,界面只会是 `All`),`validated`、`matches`、`by_name` 是纯函数;放在 host 里的理由同 `forward.rs`,片段将来绑定的分组或主机删了要一起删)、`store/snippets.rs`(`impl HostStore` 的片段和分类部分,`store.rs` 的子模块所以能用它的私有字段)、`secret_fields.rs`(`SecretFields`:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`HostDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`HostStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:<id>` / `s:<id>`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`(`HostForm`;`HostForm::temporary` / `open_temporary_connection_dialog` 是临时连接的对话框)、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`quick_connect.rs`(开始页的「快速连接」,用户给了参考图:gpui-kit 的 `List`(搜索框、↑↓、回车和单击确认)列出保存的主机,`quick_hosts` 排序(最近连接的在前,其余按名称),`QuickHost::matches` 几个词都要在名称、地址、用户名、备注或分组路径里,打开时不高亮任何一台(用户嫌预先选中一台别扭),搜索后由 `List` 自己高亮第一个匹配的、回车就连它。`QuickChoice` 是不依赖窗口的选择状态(高亮行、Tab / ⌘A 选中的、⇧↵ 连哪些),都有单元测试。**多选先关掉了**(用户定的,常量 `MULTIPLE` 为假):开着时搜索框自己要的 Tab(`IndentInline`)、⌘A(`SelectAll`)和 ⇧↵(`Enter { shift }`)由外层容器在捕获阶段 `capture_action` 截下再 `stop_propagation`,⌘ 单击也是选中,页脚列出这三个键;关着时这些都不挂,Tab、⌘A 照搜索框的本意。普通回车照常经输入框传给 `List` 的确认。连接是关掉对话框后 `window.defer` 在工作区的 focus handle 上逐台派发 `ConnectHost`,同临时连接)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。
- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
- `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `<data_dir>/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `HostLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(`ssh::LATENCY_INTERVAL`)用 `ssh::round_trip`(`latency.rs`:`Handle::send_ping()`,即 `keepalive@openssh.com`,服务器回成功或失败都算Line truncated
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `ssh`:`connection.rs` 定义 `LoginTest`(登录快照加表单当前值,秘密用 `Zeroizing`,`Debug` 打码;`LoginTest::typed` 是主机自己的密码或无密码,`LoginTest::saved` 是使用凭据)和 `ConnectionTester` 接口,工作区用和终端 / SFTP 共享的 `SshConnector` 造 `ssh::SshConnectionTester` 注入进去(`Workspace::new_with_services` 的 `connection_tester` 参数,UI 测试注入 `FakeConnectionTester`)。测试用的秘密来源 `FormSecrets` 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(`saved`)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(`LoginTest::with_proxy_password`),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。`SshPrompts::non_interactive()` 让缺密码 / 口令 / 键盘交互直接以带类型的 `MissingCredential` 失败而不弹框,失败原因由 `tester.rs` 的 `describe_test_failure` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`LoginTest::password_left_empty` 为真,测试按「无密码」登录(免认证、SSH Agent、默认私钥),服务器要密码时(`PasswordWanted`,「无密码」那句报错的类型)改报「未填写密码」,不说「请改用…」(`a_test_with_the_password_left_empty_goes_without_one`)。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。`TemporarySecretStore`(`temporary.rs`)是 `HostStore` 包在注入的存储外面的一层:`SecretRef::Temporary { host }`(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,`remember` / `forget` 只碰内存、可以在 UI 线程上调。服务名是 `SERVICE` = `shellrs`。
- `explorer/` — SFTP 标签页的界面。`explorer_panel.rs`(连接、事件轮询、`execute` 执行全部 `ExplorerCommand`、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 `ExplorerStatus`)、`transfer_queue.rs`(纯逻辑的 `TransferQueue`:批次的排队、状态与「来源」「进度」列的文字)、`queue_panel.rs`(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、`file_pane.rs`(一侧面板:两行工具栏、目录列表 `Select`、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、`path_label.rs`(WinSCP 的路径标签:`/home/tester/` 逐级可点,悬停时从根到该级高亮,放不下时中段折成 `…`;点当前目录或双击空白处打开「打开目录/书签」)、`open_directory_dialog.rs`(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、`file_listing.rs`(`DataTable` 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、`selection.rs`(纯函数的多选模型)、`history.rs`(后退 / 前进)、`pane_menu.rs`(右键、书签、新建菜单)、`pane_operations.rs`(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、`file_dialogs.rs`(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 `ExplorerCommand::Operate { remote, operation: PaneOperation }`)、`properties_dialog.rs`(权限 3×3 与八进制,`PermissionDraft` 是纯函数)、`transfer_dialog.rs`(上传 / 下载共用的确认框 `TransferForm`、冲突 / 续传 / 出错问答、关闭确认)、`model.rs`(`FileEntry` 保存原始值,`FileSizeFormat::format` / `format_size` / `format_changed` / `format_rights` 负责显示;大小列按 `FileSizeFormat`:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 `format_size`)。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
- `sftp/` — 可注入传输与本地目录接口(`LocalDirectoryProvider` 也负责本地的废纸篓、重命名、新建和改权限)、`RemotePath`、russh-sftp 3.0.0 协议适配(`RemoteFs`:lstat / stat / read_dir / 读写 / rmdir / setstat;`list` 解析 `longname` 得到所有者,并发 `stat` 解析链接目标)、`operations.rs`(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、`upload.rs` / `download.rs`(有界并发分块传输、`.filepart`、冲突与续传问答)、`meter.rs`(`TransferMeter`:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、`journal.rs`(原子 JSON 续传记录:上传在 `upload-resume/`,下载在 `download-resume/`)、`worker.rs`(`TransferBatch` 二选一、三次自动重连、空闲时监听连接关闭)。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。
@@ -64,7 +64,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、下划线 `TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。
- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。
- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`links.rs`(`impl Workspace` 的外部连接部分:`open_link`、`main` 用的 `open_link_once_open`(`window.defer`,同 `notify_once_open`)和最后一个标签关掉时的 `forget_unused_temporary_host`)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉;标题右边是小号的「快速连接」(`QuickConnect`,primary)和「新建主机…」两个按钮)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `<data_dir>/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
- `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。
- `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后,先由 `link.rs` 的 `link_arguments` 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 `-url` / `-newtab` / `-newwin`、WinSCP 的开关(`/sessionname=` 当标签名,`/newinstance`、`/ini=` 等列在 `WINSCP_SWITCHES` 里的忽略;只认列出的名字,所以 `/tmp/x` 这种路径不算),或形如 `scheme://…`,所以 `shellrs exec web "curl http://x"` 仍是命令;认出是链接却没给地址时 `url` 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
@@ -134,8 +134,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex<Option<Vec<OpenLink>>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- **临时连接不保存,功能齐全。** 标题栏右上角原来的「新建主机…」换成了「临时连接…」(用户要的;新建主机还在主机列表的「+」、右键菜单和 ⌘N),派发 `NewTemporaryConnection`。
- 对话框是 `HostForm::temporary`(`open_temporary_connection_dialog`):新建主机的地址、端口、认证方式(密码 / 使用凭据 / 无密码)和密码,没有分组、连接方式和备注,「测试连接」照旧(用户要的),名称可以不填(用地址),下面一行说明「仅当前使用的临时会话,不会保存到主机列表。」(用户给的文案)。页脚 `form_footer` 三个主机对话框共用。
- `commit_temporary` 调 `HostStore::insert_temporary`:分组和连接方式一律丢掉(直连),填的密码只 `remember` 在 `TemporarySecretStore` 的内存里(用户定的:不进钥匙串)。然后在对话框关掉之后(`window.defer`)经工作区的 focus handle 派发 `ConnectHost`,新标签才留得住焦点。
- 它不记进 `external`:不是 shell_only,右侧栏工具全有,也不收起左侧栏。其余和外部连接一样:不进主机树、最近连接和数据库,最后一个标签关掉就 `remove_temporary`,标签菜单没有「编辑主机…」,外部 CLI 照样列出。
- `login_of` 只把端点自己的密码(`SecretRef::Password`)换成 `Temporary`,用密码凭据时照用凭据的。改凭据(用户名跟着变、重连)和删凭据(改回自己登录)也照顾临时主机:`logins`、`update_credential_unnotified`、`remove_credential_unnotified` 都把 `temporary` 算进去;凭据的用量和删除确认只数保存的主机。`a_temporary_connection_goes_directly_and_may_use_a_credential` 和 UI 的 `a_temporary_connection_has_every_feature_and_saves_nothing` 守着。
- **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。
- `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(外部连接)」(做临时连接时要让 `HostInfo` 分得出两种);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
- `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(未保存)」(用户定的:临时连接和外部连接在外部 CLI 里不区分,agent 用不着,ID 失效时 CLI 自己报 `host_not_found`);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。
- **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。
- 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。
- 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。
@@ -203,4 +208,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode` / `workbuddy`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltLine truncated
Generated
+1 -1
View File
@@ -7686,7 +7686,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0-beta.9"
version = "0.1.2"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.0-beta.9"
version = "0.1.2"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
+22 -3
View File
@@ -24,12 +24,12 @@
## About
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style dual-pane SFTP browser and SSH port forwarding together in one cross-platform app, which bastion hosts such as JumpServer can launch the way they launch Xshell and WinSCP. It is written in Rust, and its UI is built on GPUI, the GPU-accelerated UI framework of the Zed editor (through [gpui-kit](https://gpui-kit.com)). There is no Electron and no JVM.
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
> [!NOTE]
> ShellRS is in beta, and its interface is currently in Simplified Chinese only. An English translation is in progress.
> ShellRS's interface is currently in Simplified Chinese only. An English translation is in progress.
## Features
@@ -48,6 +48,14 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
- Find (smart case) and local clear; configurable font, size and line height.
- Drag tabs into side-by-side or stacked splits to watch a terminal and SFTP together.
**Right sidebar tools**
- They follow the current remote terminal and read and run on that terminal's own connection, with no second login. ⌘⌥B (Ctrl+Alt+B elsewhere) shows or hides them.
- System monitor: system details, CPU (per core too), memory, network rates and disk use, refreshed every 2 seconds.
- Network connections, processes and services: search and filter, process details, ending processes, starting, stopping and restarting systemd services, switching them on at boot, and their logs. These three and the monitor are for Linux only.
- Docker: containers grouped by compose project, with start, stop, restart, details and logs, plus volumes, images and networks. When not root, passwordless sudo is used (for services as well).
- Command history reads `~/.bash_history` on the host, and snippets, shared by every host, can be sorted into categories; click one to type it into the terminal, or run it straight away.
**SFTP**
- A dual-pane browser modeled on WinSCP Commander: symmetric local and remote panes with WinSCP's columns, path labels, bookmarks and shortcuts (F5, F2, F7, F8…).
@@ -56,6 +64,12 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
- Delete (local items go to the Trash), rename, create, and change permissions (a 3×3 grid plus octal, optionally recursive).
- Each SFTP tab has its own connection and asks only for the SFTP subsystem, so no shell is needed on the server.
**Opening from a bastion host**
- Launched the way Xshell and WinSCP are: in the client of a bastion host such as JumpServer, set ShellRS as the SSH or SFTP client. An `ssh://` link opens a terminal, an `sftp://` link an SFTP tab.
- Understands Xshell's `-url` and `-newtab` and WinSCP's `/sessionname=`. When ShellRS is already running, the link opens there.
- What opens is an unsaved external connection: it stays out of the host list, keeps the link's password in memory only and goes away with its tabs. Each connection uses a single channel, so bastion hosts that allow no more still work. See the [user manual](docs/manual.md#从堡垒机打开外部连接) (in Chinese).
**Port forwarding**
- Local (`-L`), remote (`-R`) and dynamic (`-D`, SOCKS5 / SOCKS4) forwards.
@@ -84,6 +98,11 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
<em>Dock splits: a remote terminal and SFTP stacked in one window</em>
</p>
<p align="center">
<img src="docs/screenshots/tools.png" alt="Five right sidebar tools side by side: system monitor, processes, services, Docker and network connections"><br>
<em>Right sidebar tools: system monitor, processes, services, Docker and network connections</em>
</p>
<p align="center">
<img src="docs/screenshots/sftp.png" alt="Dual-pane SFTP: three local items selected on the left, remote /etc on the right, and a transfer queue with one batch uploading and one waiting"><br>
<em>WinSCP-style SFTP: highlight selection and a transfer queue with progress, speed and time left</em>
@@ -96,7 +115,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
## Download
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). All releases so far are betas (marked Pre-release).
Download from [shellrs.com/download](https://shellrs.com/download), which offers the package for your system, or from GitHub [Releases](https://github.com/since2006/shell-rs/releases). Betas are marked Pre-release there; to get new features early, switch the update channel to Beta in Settings › About (设置 › 关于).
| System | Package | Notes |
| --- | --- | --- |
+22 -3
View File
@@ -24,12 +24,12 @@
## 简介
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SFTP 文件管理、SSH 端口转发放进同一个跨平台应用,JumpServer 等堡垒机也可以像调用 Xshell、WinSCP 一样调用它。它用 Rust 编写,界面基于 Zed 编辑器的 GPU 加速 UI 框架 GPUI(通过 [gpui-kit](https://gpui-kit.com)),不依赖 Electron 或 JVM。
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
> [!NOTE]
> 目前是 Beta 版本,界面只有简体中文。
> 界面目前只有简体中文。
## 功能
@@ -48,6 +48,14 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
- 支持查找(智能大小写)和本地清屏,字体、字号、行高都可以设置。
- 标签可以拖成左右或上下分栏,终端和 SFTP 并排看。
**右侧栏工具**
- 跟着当前的远程终端,在这条终端自己的连接上读取和执行,不另外登录;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏。
- 系统监控:系统信息、CPU(含每个核)、内存、网卡速率和磁盘占用,每 2 秒刷新。
- 网络连接、进程管理和系统服务:搜索和筛选,查看进程详情、结束进程,启动、停止、重启 systemd 服务,开关开机启动,查看日志。这三项和系统监控只支持 Linux。
- Docker:按 compose 项目列出容器,启停、重启、看详情和日志,管理卷、镜像和网络。不是 root 时用免密码的 sudo(系统服务也是)。
- 历史命令读主机上的 `~/.bash_history`,命令片段所有主机共用、可以分类;点一下输入到终端,或直接执行。
**SFTP**
- 对标 WinSCP Commander 的双栏文件浏览器:本地和远程两侧对称,WinSCP 的列、路径标签、书签和快捷键(F5、F2、F7、F8……)。
@@ -56,6 +64,12 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
- 删除(本地移到废纸篓)、重命名、新建、修改权限(3×3 复选框加八进制,可递归)。
- 每个 SFTP 标签单独建立连接,只请求 SFTP 子系统,不要求服务器提供 shell。
**从堡垒机打开**
- 兼容 Xshell 和 WinSCP 的命令行调用方式:在 JumpServer 等堡垒机的客户端里把 ShellRS 设成 SSH / SFTP 客户端,`ssh://` 链接打开终端,`sftp://` 链接打开 SFTP。
- 认得 Xshell 的 `-url`、`-newtab` 和 WinSCP 的 `/sessionname=`;ShellRS 已在运行时,链接交给正在运行的那个打开。
- 打开的是不保存的外部连接:不进主机列表,链接里的密码只留在内存里,标签关掉就没了。每条连接只用一个通道,不允许多开通道的堡垒机也能用。详见[使用手册](docs/manual.md#从堡垒机打开外部连接)。
**端口转发**
- 本地(`-L`)、远程(`-R`)和动态(`-D`,SOCKS5 / SOCKS4)转发。
@@ -84,6 +98,11 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
<em>Dock 分栏:远程终端和 SFTP 上下并排</em>
</p>
<p align="center">
<img src="docs/screenshots/tools.png" alt="右侧栏的五个工具并排:系统监控、进程管理、系统服务、Docker 和网络连接"><br>
<em>右侧栏工具:系统监控、进程管理、系统服务、Docker 和网络连接</em>
</p>
<p align="center">
<img src="docs/screenshots/sftp.png" alt="双栏 SFTP:左侧本地目录多选了三项,右侧是远程 /etc,下方传输队列里一批正在上传、一批等待中"><br>
<em>WinSCP 式双栏 SFTP:高亮多选,传输队列显示进度、速度和剩余时间</em>
@@ -96,7 +115,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
## 下载
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。目前发布的都是 Beta 版(在 Releases 里标为 Pre-release)。
到官网 [shellrs.com/download](https://shellrs.com/download) 下载,页面会按你的系统给出对应的安装包;也可以到 GitHub [Releases](https://github.com/since2006/shell-rs/releases) 下载。Beta 版在 Releases 里标为 Pre-release,想提前用上新功能,可以在「设置 › 关于」里把更新渠道切到 Beta。
| 系统 | 安装包 | 说明 |
| --- | --- | --- |
+16 -3
View File
@@ -20,7 +20,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。
无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。「测试连接」时选了「密码」却没填,就像「无密码」那样试一遍(免认证、SSH Agent、默认私钥),连不上就说明原因。
主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走:
@@ -36,8 +36,21 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
鼠标停在主机上,行的右侧会显示 `用户@地址:端口`,主机有备注时下面接着显示备注(太长的只显示前 6 行)。右键主机可连接、打开 SFTP、编辑、复制、复制 IP 地址(地址是域名时叫「复制主机名」)、复制 ID 或删除。「复制 ID」复制的是主机的 16 位随机标识(形如 `Jwg5rHvXCxw89paM`),供其他工具标识这台主机:它在主机创建时生成,改名、改地址都不变,复制出来的主机得到新的 ID,删掉的 ID 不会再分配。没有打开任何标签时,中间区显示「最近连接」列表,双击或回车连接;右键其中的主机弹出同一个菜单,并选中该主机。
列表上方「新建主机…」左边的「快速连接」打开一个搜索框,列出所有保存的主机:最近连接过的在前,其余按名称排,每行写着分组和 `用户@地址:端口`。输入名称、地址、用户名、备注或分组里的字就能筛选,几个词用空格隔开时都要匹配。打开时不高亮任何一台;输入后高亮第一个匹配的,回车就连它;也可以 ↑↓ 移动,或者直接单击一台连接。
拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。
## 临时连接
标题栏右上角的「临时连接…」连一台主机而不保存它,适合只登一次的机器。对话框和新建主机一样填地址、端口和认证方式(密码、使用凭据或无密码),没有分组、连接方式和备注,名称可以不填(用地址);可以先「测试连接」。点「连接」打开一个终端标签:
- **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。
- **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。
- **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。
- 外部 CLI 的 `shellrs list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。
要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。
## 端口转发
点标题栏的端口转发图标,左侧栏换成转发列表。每条转发规则经由一台已保存的主机,启动时为自己建立一条 SSH 连接,不依赖该主机的终端或 SFTP 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。
@@ -118,7 +131,7 @@ shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
```
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的外部连接(`"temporary": true`),见「从堡垒机打开(外部连接)」。
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
@@ -145,7 +158,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
- Linux:AppImage 本身。
- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(外部连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

+5
View File
@@ -21,6 +21,11 @@ gpui_kit::actions!(
[
/// Open the new-host dialog for a host at the root of the tree.
NewHost,
/// Open the 临时连接 dialog: connect to a host without saving it.
NewTemporaryConnection,
/// Open 快速连接: search the saved hosts and connect to one or
/// several.
QuickConnect,
/// Open the new-group dialog for a top-level group.
NewGroup,
/// Open a new local login-shell terminal.
-1
View File
@@ -65,7 +65,6 @@ The exit code is in `$LASTEXITCODE`.
- `shellrs exec` takes a host id and one complete remote shell command string, opens a temporary SSH connection, runs the command, and closes the connection. Its stdout and stderr are the remote command's, and its exit code is the remote command's exit code.
- The remote command gets no stdin, so interactive programs (editors, pagers, password prompts) will not work. Use non-interactive flags such as `--yes`, `-y`, or `--no-pager`.
- Long-running tasks should use tmux, nohup, or systemd on the remote host.
- Hosts with `"temporary": true` in `shellrs list` were opened in ShellRS from a bastion host's link rather than saved. Their id stays the same while their tab is open in ShellRS, so keep using it for every command; there is no need to list before each one. Once the tab is closed the id is gone (`host_not_found`), and opening the host from the bastion again gives it a new id: list again only then. Every command logs in to them again, which a bastion host may refuse if it gave out a one-time login; report that to the user instead of retrying.
- `shellrs upload` takes a host id, a local file or folder path, and a remote destination path. `shellrs download` takes a host id, a remote file or folder path, and a local destination path. Folders are copied recursively, and existing files are overwritten.
- Destination paths follow scp: if the destination exists and is a directory, the source keeps its name inside that directory; otherwise the destination path is the final file or folder path, and its parent directory must exist.
+3 -3
View File
@@ -253,10 +253,10 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
[
host.id.clone(),
host.name.clone(),
// Every temporary host is an external one so far; a 临时连接
// typed into ShellRS, when it comes, needs telling apart here.
// 临时连接 and 外部连接 alike: an agent needs no telling
// them apart, only that the host is not saved.
if host.temporary {
"(外部连接)".to_string()
"(未保存)".to_string()
} else {
host.group.clone().unwrap_or_default()
},
+4 -4
View File
@@ -172,10 +172,10 @@ pub struct HostInfo {
pub port: u16,
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
pub os: Option<String>,
/// Connected to without being saved (so far always an 外部连接, which a
/// bastion host's link opened): listed while its tab is open, under one
/// ID for as long; opened again, it is another host with another ID.
/// Missing from an older app's answer, which listed saved hosts only.
/// Connected to without being saved (a 临时连接, or an 外部连接 a bastion
/// host's link opened): listed while its tab is open, under one ID for
/// as long; opened again, it is another host with another ID. Missing
/// from an older app's answer, which listed saved hosts only.
#[serde(default)]
pub temporary: bool,
}
+5 -5
View File
@@ -86,7 +86,7 @@ impl CliTarget {
}
}
/// Marked as opened from a link, not saved.
/// Marked as not saved.
fn temporary(mut self) -> Self {
self.info.temporary = true;
self
@@ -101,10 +101,10 @@ impl CliTarget {
&self.login
}
/// Every host in `store`, with its group path, then the ones opened
/// from a link. A request logs in to those afresh too, with the link's
/// user and password; a bastion host may refuse a login it gave out for
/// one use.
/// Every host in `store`, with its group path, then the ones connected
/// to without saving them. A request logs in to those afresh too, with
/// the password kept in memory; a bastion host may refuse a login it
/// gave out for one use.
pub fn all(store: &HostStore) -> Vec<Self> {
let saved = store.hosts().iter().map(|host| {
let names = host
+1 -1
View File
@@ -420,7 +420,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
// A table says so where the group goes.
let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false);
assert!(
table.lines().nth(2).unwrap().contains("(外部连接)"),
table.lines().nth(2).unwrap().contains("(未保存)"),
"{table}"
);
}
+11
View File
@@ -319,6 +319,17 @@ impl LoginTest {
pub fn login(&self) -> &crate::host::HostLogin {
&self.login
}
/// A login by password typed into the form with the password left
/// empty. 测试连接 then goes without one, as 「无密码」 would, rather than
/// stop at the missing password (the user's call).
pub fn password_left_empty(&self) -> bool {
self.login.method == crate::host::LoginMethod::Password
&& self
.typed
.as_ref()
.is_some_and(|typed| typed.password.is_none())
}
pub fn host(&self) -> &str {
&self.login.host
}
+194 -59
View File
@@ -17,7 +17,9 @@ use gpui_kit::component::{
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use zeroize::Zeroizing;
use crate::app::ConnectHost;
use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt};
use super::secret_fields::SecretFields;
@@ -78,6 +80,9 @@ 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 中的默认私钥。";
@@ -168,6 +173,9 @@ type JumpPicker = ComboboxState<SearchableVec<JumpHost>>;
pub struct HostForm {
store: Entity<HostStore>,
editing: Option<HostId>,
/// A 临时连接's form: no group, route or notes, a name it may leave out,
/// and a commit that saves nothing.
temporary: bool,
name: Entity<InputState>,
address: Entity<InputState>,
port: Entity<InputState>,
@@ -399,6 +407,7 @@ impl HostForm {
Self {
store,
editing,
temporary: false,
name,
address,
port,
@@ -431,6 +440,22 @@ impl HostForm {
}
}
/// The form of a 临时连接: the login of a new host, without what only a
/// saved host has (its group, its route, its notes).
pub fn temporary(
store: Entity<HostStore>,
tester: SharedConnectionTester,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
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)
});
form
}
fn set_source(&mut self, source: AuthSource, cx: &mut Context<Self>) {
if self.source != source {
self.source = source;
@@ -741,6 +766,54 @@ impl HostForm {
true
}
/// Validate and connect without saving: the host goes into the store's
/// memory, its password into the store's memory too, never the
/// keychain. The host to connect to, or `None` when the form said what
/// is wrong.
pub fn commit_temporary(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<HostId> {
let checked = self
.endpoint(cx)
.and_then(|endpoint| Ok((endpoint, self.committed_login(cx)?)));
let ((host, port), login) = match checked {
Ok(checked) => checked,
Err(error) => {
window.push_notification(form_error_notification(error), cx);
return None;
}
};
let name = self.name.read(cx).value().trim().to_string();
let name = if name.is_empty() { host.clone() } else { name };
let mut password = None;
let draft = match login {
CommittedLogin::Saved { credential, user } => {
HostDraft::new(name, host, port, user, AuthKind::default(), None)
.with_credential(credential)
}
CommittedLogin::Own(auth) => {
let user = self.user.read(cx).value().trim().to_string();
let user = if user.is_empty() {
DEFAULT_USER.to_string()
} else {
user
};
if auth == AuthKind::Password {
password = Some(Zeroizing::new(self.fields.read(cx).password(cx)));
}
HostDraft::new(name, host, port, user, auth, None)
}
};
let id = self
.store
.update(cx, |store, cx| store.insert_temporary(draft, password, cx));
self.fields
.update(cx, |fields, cx| fields.clear(window, cx));
Some(id)
}
/// The fields of a host that logs in on its own: its user, and its
/// password when it has one.
fn own_fields(&self, form: Form, auth: AuthKind, cx: &App) -> Form {
@@ -1093,7 +1166,7 @@ impl Render for HostForm {
.child(
Field::new()
.label("名称")
.required(true)
.required(!self.temporary)
.col_span(4)
.child(Input::new(&self.name).id("host-name").small()),
)
@@ -1137,28 +1210,43 @@ impl Render for HostForm {
Some(auth) => self.own_fields(form, auth, cx),
None => form.child(self.credential_field(cx)),
};
// What only a saved host has.
let form = if self.temporary {
form
} else {
form.child(
Field::new()
.label("分组")
.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()),
),
)
};
v_flex()
.gap_3()
.w_full()
.child(
.child(form)
.when(self.temporary, |form| {
form.child(
Field::new()
.label("分组")
.col_span(4)
.child(Select::new(&self.group).small()),
div()
.id("temporary-note")
.test_support()
.aria_label(TEMPORARY_NOTE)
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(TEMPORARY_NOTE),
)
.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()),
),
),
)
.when(source == AuthSource::Password, |form| {
})
.when(!self.temporary && source == AuthSource::Password, |form| {
form.child(
div()
.text_sm()
@@ -1300,47 +1388,7 @@ pub fn open_host_dialog(
// Closed by its buttons or Escape, not by a click beside it.
.overlay_closable(false)
.child(form.clone())
.footer(
DialogFooter::new()
.w_full()
.justify_between()
.child(
Button::new("test-connection")
.label("测试连接")
.icon(crate::app::CatalogIcon::Plug)
.small()
.loading(form.read(cx).testing_connection)
.on_click({
let form = form.clone();
move |event, window, cx| {
form.update(cx, |form, cx| {
form.test_connection(event, window, cx)
});
}
}),
)
.child(
h_flex()
.gap_2()
.child(Button::new("cancel").label("取消").small().on_click(
|_, window, cx| {
window.dispatch_action(Box::new(Cancel), cx);
},
))
.child(
Button::new("commit")
.primary()
.label(commit_label.clone())
.small()
.on_click(|_, window, cx| {
window.dispatch_action(
Box::new(Confirm { secondary: false }),
cx,
);
}),
),
),
)
.footer(form_footer(&form, commit_label.clone(), cx))
.on_ok({
let form = form.clone();
move |_, window, cx| form.update(cx, |form, cx| form.commit(window, cx))
@@ -1350,6 +1398,93 @@ pub fn open_host_dialog(
});
}
/// The host form's footer: 「测试连接」 on the left, 「取消」 and the
/// commit on the right. The new-host, edit-host and 临时连接 dialogs share it.
fn form_footer(form: &Entity<HostForm>, commit_label: SharedString, cx: &App) -> DialogFooter {
DialogFooter::new()
.w_full()
.justify_between()
.child(
Button::new("test-connection")
.label("测试连接")
.icon(crate::app::CatalogIcon::Plug)
.small()
.loading(form.read(cx).testing_connection)
.on_click({
let form = form.clone();
move |event, window, cx| {
form.update(cx, |form, cx| form.test_connection(event, window, cx));
}
}),
)
.child(
h_flex()
.gap_2()
.child(
Button::new("cancel")
.label("取消")
.small()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Cancel), cx);
}),
)
.child(
Button::new("commit")
.primary()
.label(commit_label)
.small()
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
}),
),
)
}
/// Open the 临时连接 dialog: a host's login, connected to in a terminal tab
/// without being saved. `dispatch` is the workspace's focus handle, which the
/// connection is asked of once the dialog has closed and left the focus
/// nowhere.
pub fn open_temporary_connection_dialog(
store: Entity<HostStore>,
tester: SharedConnectionTester,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let form = cx.new(|cx| HostForm::temporary(store, tester, window, cx));
window.open_dialog(cx, {
let form = form.clone();
move |dialog, window, cx| {
dialog
.title("临时连接")
.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))
.on_ok({
let form = form.clone();
let dispatch = dispatch.clone();
move |_, window, cx| {
let Some(host) =
form.update(cx, |form, cx| form.commit_temporary(window, cx))
else {
return false;
};
// After the dialog is gone, so the new tab keeps the
// focus it takes.
let dispatch = dispatch.clone();
window.defer(cx, move |window, cx| {
dispatch.dispatch_action(&ConnectHost(host), window, cx)
});
true
}
})
.on_close(|_, window, cx| dismiss_form_error(window, cx))
}
});
}
/// What depends on hosts about to be deleted, beyond their own tabs.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Dependents {
+6 -1
View File
@@ -11,6 +11,7 @@ mod login;
mod model;
mod outline;
mod private_key;
mod quick_connect;
mod secret_fields;
mod snippet;
mod ssh_link;
@@ -20,12 +21,16 @@ pub use credential::*;
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};
pub use host_dialog::{
DeleteHandler, Dependents, HostForm, confirm_delete_host, open_host_dialog,
open_temporary_connection_dialog,
};
pub use host_panel::{HostPanel, host_menu};
pub use login::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin};
pub use model::*;
pub use outline::{HostNode, NodeDrop, group_options, host_tree_items, matches_query};
pub use private_key::{GeneratedKey, KeyAlgorithm, PastedKey, PastedKeyError, read_public_key};
pub use quick_connect::{QuickChoice, QuickHost, open_quick_connect_dialog, quick_hosts};
pub use secret_fields::SecretFields;
pub use snippet::*;
pub use ssh_link::{LinkKind, SshLink};
+545
View File
@@ -0,0 +1,545 @@
//! 快速连接: the start page's palette for reaching saved hosts by typing a
//! few letters of them.
//!
//! The list is gpui-kit's `List` (its search box, ↑↓, Enter and a click
//! connect the highlighted host). Choosing several and connecting them
//! together is built but switched off, see [`MULTIPLE`]: the search box
//! would keep Tab, ⌘A and ⇧↵ for itself, so the palette takes those actions
//! on their way to it, in the capture phase: Tab chooses the highlighted
//! host, ⌘A every host shown, ⇧↵ connects the chosen ones.
use std::collections::HashSet;
use gpui_kit::component::{
ActiveTheme as _, IconName, IndexPath, Sizable as _, WindowExt as _,
dialog::DialogFooter,
h_flex,
input::{Enter, IndentInline, SelectAll},
kbd::Kbd,
list::{List, ListDelegate, ListItem, ListState},
v_flex,
};
use gpui_kit::*;
use gpui_kit::prelude::FluentBuilder as _;
use crate::app::ConnectHost;
use crate::shared::HostMark;
use super::{HostId, HostOs, HostStore};
/// Whether several hosts can be chosen (Tab, ⌘A, ⌘-click) and connected to
/// together (⇧↵), with the keys in the footer. Off for now (the user's
/// call); turning it on brings all of that back.
const MULTIPLE: bool = false;
/// A host as the palette lists it, as it was when the palette opened: the
/// dialog is modal, so nothing changes under it.
#[derive(Clone, Debug, PartialEq)]
pub struct QuickHost {
pub id: HostId,
pub name: SharedString,
pub user: SharedString,
pub address: SharedString,
/// `user@address:port`.
pub endpoint: SharedString,
/// The full group path (`公司 / MySQL 集群`), when it has a group.
pub group: Option<SharedString>,
pub notes: SharedString,
pub os: Option<HostOs>,
}
impl QuickHost {
/// Whether every word of `query` is somewhere in the name, address,
/// user, notes or group path, whatever the case.
pub fn matches(&self, query: &str) -> bool {
let fields = [
&self.name,
&self.address,
&self.user,
&self.notes,
self.group.as_ref().unwrap_or(&self.name),
]
.map(|field| field.to_lowercase());
query
.to_lowercase()
.split_whitespace()
.all(|word| fields.iter().any(|field| field.contains(word)))
}
/// The line under the name: the group, then where it logs in.
pub fn detail(&self) -> String {
match &self.group {
Some(group) => format!("{group} · {}", self.endpoint),
None => self.endpoint.to_string(),
}
}
}
/// The saved hosts, those connected to lately first (the latest first), then
/// the rest by name.
pub fn quick_hosts(store: &HostStore) -> Vec<QuickHost> {
let of = |host: &super::Host| QuickHost {
id: host.id,
name: host.name.clone(),
user: host.user.clone(),
address: host.address.clone(),
endpoint: host.endpoint().into(),
group: host
.group
.map(|group| SharedString::from(store.group_path(group))),
notes: host.notes.clone(),
os: host.os,
};
let mut hosts: Vec<QuickHost> = store
.recent_hosts()
.filter(|host| !store.is_temporary(host.id))
.map(of)
.collect();
let recent: HashSet<HostId> = hosts.iter().map(|host| host.id).collect();
let mut rest: Vec<QuickHost> = store
.hosts()
.iter()
.filter(|host| !recent.contains(&host.id))
.map(of)
.collect();
rest.sort_by_key(|host| host.name.to_lowercase());
hosts.extend(rest);
hosts
}
/// What the palette shows, which row is highlighted and which hosts have
/// been chosen: the list's state, apart from the list.
pub struct QuickChoice {
hosts: Vec<QuickHost>,
/// Indices into `hosts` of the ones the query matches, in order.
shown: Vec<usize>,
/// Chosen with Tab or ⌘A, for ⇧↵ to connect together. Kept while the
/// query changes, so hosts found by different searches go together.
chosen: HashSet<HostId>,
/// The highlighted row of `shown`.
selected: Option<usize>,
}
impl QuickChoice {
pub fn new(hosts: Vec<QuickHost>) -> Self {
Self {
shown: (0..hosts.len()).collect(),
hosts,
chosen: HashSet::new(),
selected: None,
}
}
/// Show the hosts `query` matches.
pub fn search(&mut self, query: &str) {
self.shown = (0..self.hosts.len())
.filter(|ix| self.hosts[*ix].matches(query))
.collect();
}
/// Highlight a row of what is shown.
pub fn highlight(&mut self, row: Option<usize>) {
self.selected = row;
}
/// The host on a row of what is shown.
pub fn shown(&self, row: usize) -> Option<&QuickHost> {
self.shown.get(row).map(|ix| &self.hosts[*ix])
}
pub fn shown_count(&self) -> usize {
self.shown.len()
}
pub fn is_chosen(&self, id: HostId) -> bool {
self.chosen.contains(&id)
}
fn highlighted(&self) -> Option<HostId> {
self.selected
.and_then(|row| self.shown(row))
.map(|host| host.id)
}
/// Tab: choose the highlighted host, or take it back.
pub fn toggle_highlighted(&mut self) {
if let Some(id) = self.highlighted()
&& !self.chosen.remove(&id)
{
self.chosen.insert(id);
}
}
/// ⌘A: choose every host shown; when they all are already, take them
/// back.
pub fn choose_all(&mut self) {
let shown: Vec<HostId> = self.shown.iter().map(|ix| self.hosts[*ix].id).collect();
if shown.iter().all(|id| self.chosen.contains(id)) {
for id in &shown {
self.chosen.remove(id);
}
} else {
self.chosen.extend(shown);
}
}
/// What Enter connects: the highlighted host.
pub fn to_connect_one(&self) -> Vec<HostId> {
self.highlighted().into_iter().collect()
}
/// What ⇧↵ connects: the chosen hosts in the list's order, or the
/// highlighted one when none is chosen.
pub fn to_connect(&self) -> Vec<HostId> {
let chosen: Vec<HostId> = self
.hosts
.iter()
.map(|host| host.id)
.filter(|id| self.chosen.contains(id))
.collect();
if chosen.is_empty() {
self.to_connect_one()
} else {
chosen
}
}
fn has_hosts(&self) -> bool {
!self.hosts.is_empty()
}
}
/// The palette's list: its choice, and where to ask for connections.
pub struct QuickConnectList {
choice: QuickChoice,
/// The workspace's focus handle, which connecting is asked of: once the
/// dialog has closed, the focus is nowhere.
dispatch: FocusHandle,
}
impl QuickConnectList {
pub fn new(hosts: Vec<QuickHost>, dispatch: FocusHandle) -> Self {
Self {
choice: QuickChoice::new(hosts),
dispatch,
}
}
pub fn choice(&self) -> &QuickChoice {
&self.choice
}
pub fn choice_mut(&mut self) -> &mut QuickChoice {
&mut self.choice
}
/// Close the palette and connect to `hosts`, each in a terminal tab.
pub fn connect(&self, hosts: Vec<HostId>, window: &mut Window, cx: &mut App) {
if hosts.is_empty() {
return;
}
window.close_dialog(cx);
let dispatch = self.dispatch.clone();
window.defer(cx, move |window, cx| {
for host in hosts {
dispatch.dispatch_action(&ConnectHost(host), window, cx);
}
});
}
}
impl ListDelegate for QuickConnectList {
type Item = ListItem;
fn perform_search(
&mut self,
query: &str,
_: &mut Window,
_: &mut Context<ListState<Self>>,
) -> Task<()> {
self.choice.search(query);
Task::ready(())
}
fn items_count(&self, _: usize, _: &App) -> usize {
self.choice.shown_count()
}
fn render_item(
&mut self,
ix: IndexPath,
_: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> Option<ListItem> {
let host = self.choice.shown(ix.row)?;
let chosen = self.choice.is_chosen(host.id);
let detail = host.detail();
let muted = cx.theme().muted_foreground;
Some(
ListItem::new(("quick-connect-row", host.id.0))
.py_1p5()
.rounded(cx.theme().radius)
.when(MULTIPLE, |item| {
item.check_icon(IconName::Check).confirmed(chosen)
})
.child(
h_flex()
.id(("quick-connect-host", host.id.0))
.test_support()
.aria_label(SharedString::from(format!("{} · {detail}", host.name)))
.aria_selected(chosen)
.gap_3()
.child(
HostMark::new(
("quick-connect-os", host.id.0),
host.name.clone(),
host.os,
)
.without_tooltip()
.small(),
)
.child(
v_flex()
.min_w_0()
.child(div().truncate().child(host.name.clone()))
.child(div().text_sm().text_color(muted).truncate().child(detail)),
),
),
)
}
fn set_selected_index(
&mut self,
ix: Option<IndexPath>,
_: &mut Window,
_: &mut Context<ListState<Self>>,
) {
self.choice.highlight(ix.map(|ix| ix.row));
}
/// Enter or a click connects the highlighted host; with ⌘ (Ctrl) and
/// [`MULTIPLE`] on, it is chosen instead, as Tab does.
fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<ListState<Self>>) {
if secondary && MULTIPLE {
self.choice.toggle_highlighted();
cx.notify();
} else {
self.connect(self.choice.to_connect_one(), window, cx);
}
}
fn render_empty(
&mut self,
_: &mut Window,
cx: &mut Context<ListState<Self>>,
) -> impl IntoElement {
let text = if !self.choice.has_hosts() {
"还没有保存的主机"
} else {
"没有匹配的主机"
};
h_flex()
.id("quick-connect-empty")
.test_support()
.aria_label(text)
.size_full()
.justify_center()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(text)
}
}
/// One key and what it does, for the footer.
fn key_hint(keys: &str, label: &'static str) -> impl IntoElement {
h_flex()
.gap_1p5()
.children(Keystroke::parse(keys).ok().map(Kbd::new))
.child(label)
}
/// Open 快速连接: search the saved hosts and connect to one, or choose
/// several and connect to them together. `dispatch` is the workspace's
/// focus handle.
pub fn open_quick_connect_dialog(
store: Entity<HostStore>,
dispatch: FocusHandle,
window: &mut Window,
cx: &mut App,
) {
let hosts = quick_hosts(store.read(cx));
// Nothing is highlighted until there is something to go by (the user's
// call: a host picked in advance read as chosen for them). Searching
// highlights the first match, as `List` does, and ↓ the first host.
let list = cx.new(|cx| {
ListState::new(QuickConnectList::new(hosts, dispatch), window, cx).searchable(true)
});
let select_all = if cfg!(target_os = "macos") {
"cmd-a"
} else {
"ctrl-a"
};
window.open_dialog(cx, {
let list = list.clone();
move |dialog, _, cx| {
let theme = cx.theme();
dialog
.title("快速连接")
.overlay_closable(false)
.child(
div()
.id("quick-connect")
.test_support()
.w_full()
.h(rems(26.))
.border_1()
.border_color(theme.border)
.rounded(theme.radius)
.overflow_hidden()
// The search box's own Tab, ⌘A and ⇧↵, taken on the
// way to it.
.when(MULTIPLE, |palette| {
palette
.capture_action({
let list = list.clone();
move |_: &IndentInline, _, cx| {
list.update(cx, |list, cx| {
list.delegate_mut().choice_mut().toggle_highlighted();
cx.notify();
});
cx.stop_propagation();
}
})
.capture_action({
let list = list.clone();
move |_: &SelectAll, _, cx| {
list.update(cx, |list, cx| {
list.delegate_mut().choice_mut().choose_all();
cx.notify();
});
cx.stop_propagation();
}
})
.capture_action({
let list = list.clone();
move |enter: &Enter, window, cx| {
if enter.shift {
list.update(cx, |list, cx| {
let hosts = list.delegate().choice().to_connect();
list.delegate().connect(hosts, window, cx);
});
cx.stop_propagation();
}
}
})
})
.child(
List::new(&list)
.search_placeholder("搜索主机名称、地址、用户名、备注或分组"),
),
)
.when(MULTIPLE, |dialog| {
dialog.footer(
DialogFooter::new()
.w_full()
.justify_end()
.gap_4()
.text_sm()
.text_color(theme.muted_foreground)
.child(key_hint("tab", "选中"))
.child(key_hint(select_all, "全选"))
.child(key_hint("shift-enter", "批量连接")),
)
})
}
});
// The search box takes the keys at once: focusing it later in another
// update would lose to the dialog's own focus.
list.update(cx, |list, cx| list.focus(window, cx));
}
#[cfg(test)]
mod tests {
use super::super::{
AuthKind, ConnectionState, GroupDraft, GroupId, HostDraft, HostId, HostStore,
};
use super::{QuickChoice, QuickHost, quick_hosts};
fn host(name: &str, address: &str, group: Option<GroupId>) -> HostDraft {
HostDraft::new(name, address, 22, "root", AuthKind::Password, group)
}
#[test]
fn the_hosts_connected_to_lately_come_first_then_the_rest_by_name() {
let mut store = HostStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("公司", None));
let zen = store.insert_unnotified(host("禅道", "8.138.95.125", None));
let web = store.insert_unnotified(host("web", "10.0.0.1", Some(group)));
let db = store.insert_unnotified(host("DB", "10.0.0.2", None));
let api = store.insert_unnotified(host("api", "10.0.0.3", None));
store.set_state_unnotified(web, ConnectionState::Connected);
store.set_state_unnotified(zen, ConnectionState::Connected);
let hosts = quick_hosts(&store);
let ids: Vec<HostId> = hosts.iter().map(|host| host.id).collect();
assert_eq!(ids, [zen, web, api, db]);
assert_eq!(hosts[0].detail(), "root@8.138.95.125:22");
assert_eq!(hosts[1].detail(), "公司 · root@10.0.0.1:22");
}
fn quick(id: u64, name: &str) -> QuickHost {
QuickHost {
id: HostId(id),
name: name.into(),
user: "deploy".into(),
address: "172.16.1.166".into(),
endpoint: "deploy@172.16.1.166:22".into(),
group: Some("公司 / CI".into()),
notes: "构建服务器".into(),
os: None,
}
}
#[test]
fn every_word_is_looked_for_in_the_name_address_user_notes_and_group() {
let host = quick(1, "teamcity");
for query in ["", "TEAM", "172.16", "deploy", "构建", "公司 ci", "ci 166"] {
assert!(host.matches(query), "{query:?}");
}
for query in ["jenkins", "公司 jenkins"] {
assert!(!host.matches(query), "{query:?}");
}
}
#[test]
fn tab_and_select_all_choose_what_shift_enter_connects() {
let mut choice =
QuickChoice::new(vec![quick(1, "alpha"), quick(2, "beta"), quick(3, "gamma")]);
// Nothing chosen: the highlighted host, Enter's as well.
choice.highlight(Some(1));
assert_eq!(choice.to_connect(), [HostId(2)]);
assert_eq!(choice.to_connect_one(), [HostId(2)]);
// Tab chooses and takes back; the list's order, not the choosing's.
choice.toggle_highlighted();
choice.highlight(Some(0));
choice.toggle_highlighted();
assert_eq!(choice.to_connect(), [HostId(1), HostId(2)]);
choice.toggle_highlighted();
assert_eq!(choice.to_connect(), [HostId(2)]);
// ⌘A chooses what is shown, then takes it all back. A choice made
// under another search stays.
choice.search("gamma");
assert_eq!(choice.shown_count(), 1);
choice.choose_all();
assert_eq!(choice.to_connect(), [HostId(2), HostId(3)]);
choice.search("");
choice.choose_all();
assert_eq!(choice.to_connect(), [HostId(1), HostId(2), HostId(3)]);
choice.choose_all();
choice.highlight(Some(2));
assert_eq!(choice.to_connect(), [HostId(3)]);
}
}
+101 -16
View File
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
use zeroize::Zeroizing;
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore};
@@ -46,12 +47,11 @@ mod snippets;
/// A temporary host is one connected to without saving it, kept apart from
/// the saved ones: found by id like any host, so its tabs work as a saved
/// host's do, but never listed, never written to the database and gone with
/// its last tab; its password stays in `secrets`' memory. The only kind so
/// far is the external one, which a bastion host opened with an `ssh://` or
/// its last tab; its password stays in `secrets`' memory. There are two
/// kinds: a 临时连接 typed into ShellRS's own dialog, with every feature, and
/// an external one, which a bastion host opened with an `ssh://` or
/// `sftp://` link (外部连接, see [`SshLink`]) and whose terminals get one
/// channel alone. A
/// 临时连接 typed into ShellRS, to come, will be temporary with every
/// feature.
/// channel alone.
pub struct HostStore {
groups: Vec<HostGroup>,
hosts: Vec<Host>,
@@ -301,8 +301,8 @@ impl HostStore {
.find(|host| host.id == id)
}
/// The temporary hosts, oldest first. Only the external CLI lists them;
/// nothing else that lists hosts does.
/// The temporary hosts (临时连接 and 外部连接), oldest first. Only the
/// external CLI lists them; nothing else that lists hosts does.
pub fn temporary_hosts(&self) -> &[Host] {
&self.temporary
}
@@ -329,14 +329,41 @@ impl HostStore {
}
pub fn insert_external_unnotified(&mut self, link: SshLink) -> HostId {
let id = self.insert_temporary_unnotified(link.draft(), link.password);
self.external.insert(id);
id
}
/// Connect to `draft` without saving it (临时连接): a temporary host the
/// tabs find by id, kept in memory until [`Self::remove_temporary`], with
/// `password` in memory too. It may log in with a saved credential; its
/// group, route and notes are not looked at, it goes directly.
pub fn insert_temporary(
&mut self,
draft: HostDraft,
password: Option<Zeroizing<String>>,
cx: &mut Context<Self>,
) -> HostId {
let id = self.insert_temporary_unnotified(draft, password);
cx.notify();
id
}
pub fn insert_temporary_unnotified(
&mut self,
draft: HostDraft,
password: Option<Zeroizing<String>>,
) -> HostId {
let mut draft = normalized(&self.credentials, draft);
draft.group = None;
draft.route = Route::Direct;
let id = HostId(self.next_host_id);
self.next_host_id += 1;
let mut host = Host::new(id, link.draft());
let mut host = Host::new(id, draft);
// What the external CLI knows it by while it lasts.
host.public_id = self.unused_public_id();
self.temporary.push(host);
self.external.insert(id);
if let Some(password) = link.password {
if let Some(password) = password.filter(|password| !password.is_empty()) {
self.secrets.remember(id.0, password);
}
id
@@ -897,9 +924,10 @@ impl HostStore {
.direct_login(host)
.with_route(self.route_login(&host.route));
// A temporary host's password is its own, never a saved host's for
// the same `user@host:port`; and a bastion host that opened a link
// gets the terminal alone.
if self.is_temporary(host.id) {
// the same `user@host:port` (a password credential's is still the
// credential's); and a bastion host that opened a link gets the
// terminal alone.
if self.is_temporary(host.id) && matches!(login.password, SecretRef::Password { .. }) {
login.password = SecretRef::temporary(host.id.0);
}
login.shell_only = self.is_external(host.id);
@@ -942,10 +970,12 @@ impl HostStore {
.count()
}
/// Every host's login, to tell afterwards whose changed.
/// Every host's login, temporary ones included, to tell afterwards
/// whose changed.
fn logins(&self) -> Vec<(HostId, HostLogin)> {
self.hosts
.iter()
.chain(&self.temporary)
.map(|host| (host.id, self.login_of(host)))
.collect()
}
@@ -1055,7 +1085,7 @@ impl HostStore {
credential.keychain_id = keychain_id;
credential.sort_order = sort_order;
let user = credential.user.clone();
for host in &mut self.hosts {
for host in self.hosts.iter_mut().chain(&mut self.temporary) {
if host.credential == Some(id) {
host.user = user.clone();
}
@@ -1093,7 +1123,9 @@ impl HostStore {
};
let auth = self.credentials.remove(index).kind.without_credential();
let mut released = Vec::new();
for host in &mut self.hosts {
// A temporary host using it goes on as its saved ones do, though
// the database never hears of it.
for host in self.hosts.iter_mut().chain(&mut self.temporary) {
if host.credential == Some(id) {
host.credential = None;
host.auth = auth;
@@ -2584,6 +2616,59 @@ mod tests {
SshLink::parse(text, Some("堡垒机")).unwrap()
}
#[test]
fn a_temporary_connection_goes_directly_and_may_use_a_credential() {
let mut store = HostStore::empty();
let saved = store.insert_unnotified(draft("web", None));
let ops = store.insert_credential_unnotified(CredentialDraft::new(
"运维",
CredentialKind::Password,
"ops",
));
let deploy = store.insert_credential_unnotified(
CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"),
);
// A group and a route are a saved host's, and are not looked at.
let typed = store.insert_temporary_unnotified(
draft("typed", Some(GroupId(9))).with_route(Route::Jump(vec![Some(saved)])),
Some(Zeroizing::new("p@ss".into())),
);
let with_ops =
store.insert_temporary_unnotified(draft("with-ops", None).with_credential(ops), None);
let with_key = store
.insert_temporary_unnotified(draft("with-key", None).with_credential(deploy), None);
assert_eq!(store.hosts().len(), 1);
let host = store.host(typed).unwrap();
assert_eq!((host.group, &host.route), (None, &Route::Direct));
assert!(!store.is_external(typed));
// Its own password is in memory; a password credential's is the
// credential's, as for a saved host.
let login = store.login(typed).unwrap();
assert_eq!(login.password, SecretRef::temporary(typed.0));
assert!(!login.shell_only);
let login = store.login(with_ops).unwrap();
assert_eq!(login.user, "ops");
assert_eq!(
login.password,
store.credential(ops).unwrap().password_secret()
);
assert_eq!(
store.login(with_key).unwrap().password,
SecretRef::temporary(with_key.0)
);
// It follows the credential, and logs in on its own once that goes.
store.update_credential_unnotified(
ops,
CredentialDraft::new("运维", CredentialKind::Password, "admin"),
);
assert_eq!(store.host(with_ops).unwrap().user.as_ref(), "admin");
assert_eq!(store.remove_credential_unnotified(deploy), [with_key]);
let host = store.host(with_key).unwrap();
assert_eq!((host.credential, host.user.as_ref()), (None, "deploy"));
}
#[test]
fn a_link_host_is_found_by_id_but_never_listed() {
let keychain = Arc::new(crate::secrets::InMemorySecretStore::default());
+15 -1
View File
@@ -506,6 +506,20 @@ impl std::fmt::Display for MissingCredential {
impl std::error::Error for MissingCredential {}
/// A login without a password (「无密码」) that the server wanted a password
/// from. Typed, so that a connection test with the password left empty can
/// say that rather than point at 「无密码」.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PasswordWanted;
impl std::fmt::Display for PasswordWanted {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」")
}
}
impl std::error::Error for PasswordWanted {}
impl SshPrompts {
pub fn new(
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
@@ -902,7 +916,7 @@ async fn authenticate(
if methods.contains(&MethodKind::Password)
|| methods.contains(&MethodKind::KeyboardInteractive)
{
bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」");
return Err(PasswordWanted.into());
}
bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥");
}
+22 -4
View File
@@ -9,15 +9,15 @@ use tokio::sync::watch;
use zeroize::Zeroizing;
use super::connection::{
MissingCredential, RouteFailure, SshConnectionConfig, SshConnector, SshPrompts, lock,
timeout_excluding_prompts,
MissingCredential, PasswordWanted, RouteFailure, SshConnectionConfig, SshConnector, SshPrompts,
lock, timeout_excluding_prompts,
};
use crate::{
connection::{
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
TrustCallback,
},
host::{JumpLogin, LoginRoute},
host::{JumpLogin, LoginMethod, LoginRoute},
secrets::{SecretRef, SecretStore, SharedSecretStore},
};
@@ -75,7 +75,16 @@ impl SshConnectionTester {
return Err(format!("无法解析主机 {host}"));
}
let config = SshConnectionConfig::from(request.login());
// No password typed: go without one, as 「无密码」 does (the server's
// leave, the SSH agent, the default keys), and fail if that fails.
let without_password = request.password_left_empty();
let config = if without_password {
let mut login = request.login().clone();
login.method = LoginMethod::NoPassword;
SshConnectionConfig::from(&login)
} else {
SshConnectionConfig::from(request.login())
};
let secrets: SharedSecretStore =
Arc::new(FormSecrets::new(&request, self.connector.secrets().clone()));
let host_trust = Arc::new(Mutex::new(HostTrust::default()));
@@ -139,6 +148,15 @@ impl SshConnectionTester {
.await;
Ok(())
}
// The server wanted the password that was left empty: that, not
// 「无密码」, is what to tell someone who chose 「密码」.
Err(error)
if without_password
&& error.downcast_ref::<RouteFailure>().is_none()
&& error.chain().any(|cause| cause.is::<PasswordWanted>()) =>
{
Err(MissingCredential::Password { rejected: false }.to_string())
}
Err(error) => Err(describe_test_failure(&error, *lock(&host_trust))),
}
}
+40
View File
@@ -1348,6 +1348,46 @@ mod tests {
);
}
/// 测试连接 with the password left empty goes without one, as 「无密码」
/// does: the agent gets its turn, and a server that wants a password
/// hears that none was typed.
#[cfg(unix)]
#[test]
fn a_test_with_the_password_left_empty_goes_without_one() {
let key = random_key();
let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let Some(password_server) = start_server(TestAuth::Password) else {
panic!("loopback became unavailable during the test")
};
let directory = tempfile::tempdir().unwrap();
let agent = start_agent(&[key]);
let connector = || {
SshConnector::new(
directory.path().join("known_hosts"),
Arc::new(NoSecretStore),
)
.with_agent(crate::ssh::AgentLocation::At(agent.path.clone()))
};
let by_password = |port| HostLogin::manual("127.0.0.1", port, "tester", AuthKind::Password);
assert_eq!(
test_through(
connector(),
crate::connection::LoginTest::typed(by_password(server.port))
),
Ok(())
);
assert_eq!(
test_through(
connector(),
crate::connection::LoginTest::typed(by_password(password_server.port))
),
Err("未填写密码".to_string())
);
}
#[test]
fn a_login_without_a_password_refuses_a_server_that_wants_one() {
let Some(server) = start_server(TestAuth::Password) else {
+34 -7
View File
@@ -1,13 +1,20 @@
use std::{cell::Cell, rc::Rc};
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _, button::Button, h_flex, list::ListItem,
menu::ContextMenuExt as _, scroll::ScrollableElement as _, v_flex,
ActiveTheme as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
list::ListItem,
menu::ContextMenuExt as _,
scroll::ScrollableElement as _,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{CatalogIcon, ConnectHost, ConnectSelected, NewHost, RECENT_HOSTS_CONTEXT};
use crate::app::{
CatalogIcon, ConnectHost, ConnectSelected, NewHost, QuickConnect, RECENT_HOSTS_CONTEXT,
};
use crate::host::{HostId, HostOs, HostStore, host_menu};
use crate::shared::HostMark;
@@ -103,10 +110,30 @@ impl RecentHosts {
),
)
.child(
Button::new("recent-new-host")
.icon(IconName::Plus)
.label("新建主机…")
.on_click(move |_, window, cx| target.dispatch_action(&NewHost, window, cx)),
h_flex()
.gap_2()
.child(
Button::new("recent-quick-connect")
.primary()
.small()
.icon(IconName::Search)
.label("快速连接")
.on_click({
let target = target.clone();
move |_, window, cx| {
target.dispatch_action(&QuickConnect, window, cx)
}
}),
)
.child(
Button::new("recent-new-host")
.small()
.icon(IconName::Plus)
.label("新建主机…")
.on_click(move |_, window, cx| {
target.dispatch_action(&NewHost, window, cx)
}),
),
)
}
+10 -7
View File
@@ -7,8 +7,8 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{
CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate,
ToggleHostPanel, ToggleTheme,
CatalogIcon, NewLocalTerminal, NewTemporaryConnection, ShowCredentials, ShowForwards,
ShowHosts, ShowUpdate, ToggleHostPanel, ToggleTheme,
};
use crate::shared::tinted;
use crate::update::UpdateBadge;
@@ -31,7 +31,7 @@ pub fn render_title_bar(
cx: &App,
) -> TitleBar {
let dark = cx.theme().is_dark();
let (mode, toggle, new_host, new_local, theme, show_update) = (
let (mode, toggle, temporary, new_local, theme, show_update) = (
target.clone(),
target.clone(),
target.clone(),
@@ -121,13 +121,16 @@ pub fn render_title_bar(
}),
)
.child(
Button::new("new-host")
// Saved hosts are made in the host list; up here is the
// quick way in, to a host that is not saved.
Button::new("temporary-connection")
.ghost()
.small()
.icon(IconName::Plus)
.label("新建主机…")
.icon(CatalogIcon::Plug)
.label("临时连接…")
.tooltip("连接一台主机,不保存")
.on_click(move |_, window, cx| {
new_host.dispatch_action(&NewHost, window, cx)
temporary.dispatch_action(&NewTemporaryConnection, window, cx)
}),
)
.child(
+27 -4
View File
@@ -26,10 +26,11 @@ use crate::app::{
DisconnectTerminal, DismissTerminalFind, DuplicateHost, EditHost, ExpandAllGroups,
ExplorerAction, ExplorerCommand, ExplorerShortcut, FindInTerminal, FindNextInTerminal,
FindPreviousInTerminal, FocusSearch, InstallAgentSkill, InstallCliCommand, MoveHostNode,
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, OpenExplorer, OpenSettings,
PasteTerminal, ReconnectTerminal, RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand,
RenameExplorer, RenameGroup, RenameTerminal, RestartLocalTerminal, SetFileSizeFormat,
ToggleHostPanel, ToggleTheme, ZoomIn, ZoomOut, ZoomReset,
NewChildGroup, NewGroup, NewHost, NewHostInGroup, NewLocalTerminal, NewTemporaryConnection,
OpenExplorer, OpenSettings, PasteTerminal, QuickConnect, ReconnectTerminal,
RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, RenameExplorer, RenameGroup,
RenameTerminal, RestartLocalTerminal, SetFileSizeFormat, ToggleHostPanel, ToggleTheme, ZoomIn,
ZoomOut, ZoomReset,
};
use crate::cli::{CliIntegration, CliServer, CliTarget, IntegrationPaths, SshCliBackend};
use crate::connection::{
@@ -45,6 +46,7 @@ use crate::forward::{
use crate::host::{
ConnectionState, Dependents, ForwardId, GroupId, HostId, HostNode, HostPanel, HostStore,
HostStoreEvent, confirm_delete_group, confirm_delete_host, open_group_dialog, open_host_dialog,
open_quick_connect_dialog, open_temporary_connection_dialog,
};
use crate::settings::{
Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent,
@@ -1682,6 +1684,25 @@ impl Workspace {
);
}
fn on_new_temporary_connection(
&mut self,
_: &NewTemporaryConnection,
window: &mut Window,
cx: &mut Context<Self>,
) {
open_temporary_connection_dialog(
self.store.clone(),
self.connection_tester.clone(),
self.focus_handle.clone(),
window,
cx,
);
}
fn on_quick_connect(&mut self, _: &QuickConnect, window: &mut Window, cx: &mut Context<Self>) {
open_quick_connect_dialog(self.store.clone(), self.focus_handle.clone(), window, cx);
}
fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context<Self>) {
// A host opened from a link is not saved, so there is nothing to
// edit.
@@ -2215,6 +2236,8 @@ impl Render for Workspace {
.key_context("Workspace")
.track_focus(&self.focus_handle)
.on_action(cx.listener(Self::on_new_host))
.on_action(cx.listener(Self::on_new_temporary_connection))
.on_action(cx.listener(Self::on_quick_connect))
.on_action(cx.listener(Self::on_new_local_terminal))
.on_action(cx.listener(Self::on_edit_host))
.on_action(cx.listener(Self::on_duplicate_host))
+123
View File
@@ -768,6 +768,129 @@ async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContex
store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none()));
}
/// The title bar's 临时连接: a host's login without its group, route or
/// notes, connected to with every feature and saved nowhere.
#[gpui_kit::test]
async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) {
use shellrs::app::CloseTerminal;
let data = tempfile::tempdir().unwrap();
let path = data.path().join("shellrs.db");
let keychain = Arc::new(InMemorySecretStore::default());
let store = HostStore::load(HostDatabase::open(&path).unwrap())
.unwrap()
.with_secrets(keychain.clone());
let remote = Arc::new(RecordingRemoteProvider::default());
let tester = Arc::new(FakeConnectionTester::default());
let (handle, workspace) =
open_workspace_with_credentials(cx, store, remote.clone(), tester.clone());
in_frame(cx, handle, |window, cx| {
window.click("temporary-connection", cx)
});
in_frame(cx, handle, |window, cx| {
// Only what a login needs.
assert!(window.try_find("host-route").is_none());
assert!(window.try_find("host-notes").is_none());
assert!(window.try_find("test-connection").is_some());
assert_eq!(
window.find("temporary-note").label(),
Some("仅当前使用的临时会话,不会保存到主机列表。")
);
// The name may be left out.
window.click("host-address", cx);
window.input("10.0.0.9", cx);
window.click("host-port", cx);
window.press("cmd-a", cx);
window.input("2222", cx);
window.click("host-user", cx);
window.press("cmd-a", cx);
window.input("deploy", cx);
window.click("host-password", cx);
window.input("p@ss", cx);
window.click("test-connection", cx);
});
// 测试连接 tries it as it would connect: directly.
cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| {
window.render_frame(cx);
window.notifications(cx).len() == 1
})
.await;
assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]);
in_frame(cx, handle, |window, cx| window.click("commit", cx));
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 10.0.0.9")
})
.await;
let login = remote.logins().pop().expect("connected");
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "deploy")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
// Every channel it likes, unlike a bastion host's link.
assert!(!login.shell_only);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("10.0.0.9")
);
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
// The hosts stay where they were.
assert_eq!(window.find("show-hosts").checked(), Some(true));
for tool in [
"tool-snippets",
"tool-history",
"tool-docker",
"tool-monitor",
] {
assert!(window.try_find(tool).is_some(), "{tool}");
}
})
.unwrap();
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_temporary(LINK_HOST));
assert!(!store.is_external(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
assert_eq!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
});
assert!(keychain.is_empty());
// It goes with its tab, password and all.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
})
.unwrap();
cx.run_until_parked();
store.read_with(cx, |store, _| {
assert!(store.host(LINK_HOST).is_none());
assert!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.is_none()
);
});
let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
assert!(saved.hosts().is_empty());
}
#[gpui_kit::test]
async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_credentials(
+1 -1
View File
@@ -232,7 +232,7 @@ async fn a_host_can_use_a_credential_instead_of_typing_a_login(cx: &mut TestAppC
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("web-01", cx);
+10 -10
View File
@@ -8,7 +8,7 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such(
cx: &mut TestAppContext,
) {
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
// A new host logs in with a password.
let sources = window.within("host-auth-source");
@@ -84,7 +84,7 @@ fn chain(window: &mut gpui_kit::Window) -> Option<String> {
async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppContext) {
let (store, [aliyun, zentao, _]) = store_with_jump_hosts();
let (handle, workspace) = open_workspace_with_store(cx, store);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
// A new host connects directly, with nothing more to fill in.
let routes = window.within("host-route");
@@ -203,7 +203,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
let (store, _) = store_with_jump_hosts();
let tester = Arc::new(FakeConnectionTester::default());
let (handle, _) = open_workspace_with_tester(cx, store, tester.clone());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("db", cx);
@@ -238,7 +238,7 @@ async fn testing_a_connection_through_a_jump_host_sends_its_login(cx: &mut TestA
#[gpui_kit::test]
async fn a_hosts_notes_take_several_lines_and_come_back_when_edited(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace_with_store(cx, HostStore::empty());
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("db", cx);
@@ -334,7 +334,7 @@ async fn a_host_behind_a_proxy_keeps_the_proxys_password_in_the_keychain(cx: &mu
HostStore::empty().with_secrets(secrets.clone()),
tester.clone(),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.input("abroad", cx);
@@ -457,7 +457,7 @@ async fn connection_edits_reconnect_once_but_display_edits_do_not(cx: &mut TestA
#[gpui_kit::test]
async fn a_form_error_sits_above_the_dialog_and_a_click_puts_it_away(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
window.click("host-name", cx);
window.click("commit", cx);
@@ -485,7 +485,7 @@ async fn new_host_dialog_validates_then_inserts(cx: &mut TestAppContext) {
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -640,7 +640,7 @@ async fn a_connection_test_needs_a_host_and_a_user_first(cx: &mut TestAppContext
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -770,7 +770,7 @@ async fn a_new_host_saves_its_password_to_the_keychain(cx: &mut TestAppContext)
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
window.click("new-host", cx);
window.click("new-host-panel", cx);
})
.unwrap();
cx.run_until_parked();
@@ -944,7 +944,7 @@ async fn a_dialogs_choices_are_equal_segments_of_one_track(cx: &mut TestAppConte
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
in_frame(cx, handle, |window, cx| window.click("new-host", cx));
in_frame(cx, handle, |window, cx| window.click("new-host-panel", cx));
in_frame(cx, handle, |window, cx| {
let name = window.find("host-name").bounds();
let mut group = window.within("host-auth-source");
+67 -2
View File
@@ -196,6 +196,69 @@ async fn enter_connects_the_selected_recent_host(cx: &mut TestAppContext) {
.await;
}
/// 快速连接 on the start page: search the saved hosts and connect to one
/// with Enter. (Choosing several is switched off for now.)
#[gpui_kit::test]
async fn quick_connect_finds_saved_hosts_and_connects_one(cx: &mut TestAppContext) {
use shellrs::host::GroupDraft;
let mut store = HostStore::empty();
let group = store.insert_group_unnotified(GroupDraft::new("数据库", None));
let [web, db, cache] = [
("web-01", "10.0.0.1", None),
("db-01", "10.0.0.2", Some(group)),
("cache", "10.0.0.3", None),
]
.map(|(name, address, group)| {
store.insert_unnotified(HostDraft::new(
name,
address,
22,
"root",
AuthKind::Password,
group,
))
});
let (handle, _) = open_workspace_with_store(cx, store);
let row = |id: HostId| ("quick-connect-host", id.0);
in_frame(cx, handle, |window, cx| {
window.click("recent-quick-connect", cx)
});
in_frame(cx, handle, |window, cx| {
// Every saved host, by name while none has been connected to.
let tops: Vec<f32> = [cache, db, web]
.map(|id| window.find(row(id)).bounds().origin.y.into())
.to_vec();
assert!(tops.is_sorted(), "{tops:?}");
assert_eq!(
window.find(row(db)).label(),
Some("db-01 · 数据库 · root@10.0.0.2:22")
);
// The search box has the keys: a group's name finds its hosts.
window.input("数据", cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.try_find(row(web)).is_none()
})
.await;
in_frame(cx, handle, |window, cx| {
assert!(window.try_find(row(db)).is_some());
window.press("enter", cx);
});
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window
.try_find(("terminal-tab", 1u64))
.is_some_and(|tab| tab.label() == Some("db-01"))
})
.await;
in_frame(cx, handle, |window, _| {
assert!(window.try_find("quick-connect").is_none());
});
}
/// The start page's row menu is the host tree's menu. Menus are not
/// driven here, so this dispatches what its items dispatch, from the page:
/// the page is drawn deferred over the dock, and its actions must still
@@ -628,7 +691,8 @@ async fn a_click_beside_a_dialog_does_not_close_it(cx: &mut TestAppContext) {
cx.run_until_parked();
for (open, field) in [
("new-host", "host-name"),
("new-host-panel", "host-name"),
("temporary-connection", "host-address"),
("new-group", "group-name"),
("new-forward", "forward-name"),
("new-credential", "credential-name"),
@@ -681,7 +745,8 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu
cx.run_until_parked();
for (open, field, error) in [
("new-host", "host-name", "请输入名称"),
("new-host-panel", "host-name", "请输入名称"),
("temporary-connection", "host-address", "请输入地址"),
("new-group", "group-name", "请输入分组名称"),
("new-forward", "forward-name", "请选择端口转发经由的主机"),
("new-credential", "credential-name", "请输入名称"),