Compare commits

..
22 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
XuzandClaude Opus 5.5 ac6f8eaeba 版本号改为 0.1.0-beta.9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:49 +08:00
XuzandClaude Opus 5.5 2feae2fe3b 外部连接:sftp:// 链接打开 SFTP 标签,兼容 WinSCP 的调用方式
堡垒机像调 WinSCP 一样拉起 ShellRS(`ShellRS sftp://用户[:密码]@地址[:端口]`,
前面可以有 `/sessionname=名称`),开一个不保存的 SFTP 标签,左侧栏收起;
其余规则和 ssh:// 的外部连接一样。WinSCP 的其他开关忽略,链接里的路径暂不认。

补了 SFTP 不带密码、只凭账号(堡垒机令牌)登录的协议测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:17 +08:00
XuzandClaude Opus 5.5 d5370f8bac 外部连接:改名并在打开时收起左侧栏
命名(用户定的):命令行链接打开的叫「外部连接」(不保存、只开终端);以后在
ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。代码里共同的「不
保存」统一叫 temporary(HostStore 的 temporary / is_temporary、
SecretRef::Temporary、TemporarySecretStore,和 CLI 的 JSON 字段一致),「只开
终端」改由来源决定:HostStore::is_external,login_of 只对它置 shell_only,右
侧栏也看它。shellrs list 表格写「(外部连接)」。

外部连接打开时左侧栏开着就收起(同 ⌘B),终端占满窗口;链接有误时不收。
命令片段面板本来就列出全部片段,外部连接照常都能看到;以后做适用范围时外部
和临时连接只显示「所有主机」的,规则记在 CLAUDE.md。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:17:03 +08:00
XuzandClaude Opus 5.5 fd34edfc1c 版本号改为 0.1.0-beta.8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:40:05 +08:00
XuzandClaude Opus 5.5 4fc7704954 临时连接只用终端这一个通道
用户的堡垒机在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会
话:正替终端登录目标机时到来,终端报「No more authentication methods
available」;探测跑完通道一关,刚出提示符就退出。Xshell 和 OpenSSH 只开终
端一个通道。HostLogin 加 shell_only(临时主机置上):不做系统探测,工具的
命令直接拒绝、不发给服务器,右侧栏只给不执行命令的命令片段。延迟测量是全局
请求,照常。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:39:46 +08:00
XuzandClaude Opus 5.5 67d36c8210 版本号改为 0.1.0-beta.7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:48 +08:00
XuzandClaude Opus 5.5 c0bc970de8 SSH:密钥交换按 OpenSSH 的默认,连接失败写明原因
用户的堡垒机是老 Java 上的 Apache SSHD 2.10:没有 curve25519,DH 组交换最大
2048 位。russh 默认不带 NIST 曲线、组交换要至少 3072 位,于是选了组交换、服务
器找不到素数就断开;OpenSSH 选 ecdh-sha2-nistp256 能连上。现在照 OpenSSH 的
顺序加上 ecdh-sha2-nistp*,组交换下限 2048(OpenSSH 的),不加 SHA-1、CBC。
route_tests 用只给 2048 位组、只有 NIST 曲线的服务端守着。

终端和 SFTP 的连接失败改用 describe_login_error:沿错误链给出真正的原因,
没有共同算法时写明服务器支持哪些,找不到时给整条错误链,不再只剩「无法建立
SSH 连接,请检查地址、端口和主机密钥」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00
XuzandClaude Opus 5.5 e7fcc7ca40 Docker:同一个镜像有多个标签时每个标签一行,按标签查看和删除
docker images 每个「仓库:标签」一行,同一镜像的几行镜像 ID 相同,卡片和删除
按钮的 id 因此重复:调试版构建在镜像页直接崩溃,正式版里详情和删除只认第一
行,按 ID 删多标签镜像还会被 Docker 拒绝。现在每行按 Image::key 认:有标签
用「仓库:标签」(删除只去掉这个标签),没有标签用镜像 ID。只有仓库名的镜像
显示为「仓库:<none>」、按 ID 操作,不再被当成 仓库:latest;valid_id 允许 /,
带仓库地址的镜像可以删除和查看详情。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00
XuzandClaude Opus 5.5 269fab6550 版本号改为 0.1.0-beta.6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:45:10 +08:00
XuzandClaude Opus 5.5 3c0871eb4e 外部 CLI:shellrs list 也列出临时连接
堡垒机打开的临时连接排在保存的主机后面,JSON 里 "temporary": true,表格的
分组一栏写「(临时连接)」。标签开着期间 ID 不变;每条命令照常新建连接,用
内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录。旧版应用的回答缺
这个字段时读成保存的主机,不升协议版本。skill 里写明这些。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:44:33 +08:00
XuzandClaude Opus 5.5 87c5cfb120 堡垒机用 ssh:// 链接打开临时连接(兼容 Xshell 的调用方式)
`ShellRS ssh://用户[:密码]@地址[:端口]` 或 `ShellRS -url … -newtab 名称`
开一个终端标签连过去。只看第一个参数判断是不是链接,外部 CLI 的命令不受
影响;Windows 发布版也认。

- 临时连接不保存:HostStore 把它放在单独的 transient 里,按 id 找得到,
  但不在 hosts() 里,不写库、不进最近连接;终端和 SFTP 标签都关掉就忘掉。
- 链接里的密码只在 TransientSecretStore 的内存里(SecretRef::Transient),
  不进钥匙串。
- ShellRS 已在运行时,Request::Activate 带上链接交给它打开;不带链接时消息
  和以前一字不差,旧版收到也只是调出窗口,所以不升协议版本。
- 链接有问题时弹通知说明原因,文字里不带链接原文。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:24:42 +08:00
XuzandClaude Opus 5.5 ba63b3d73f 在线升级:标题栏按钮更醒目,更新对话框不再列更新说明
标题栏的更新按钮改成 success 色的浅色按钮(出错时 warning 色),不再是
普通图标加一个小绿点。更新对话框不再显示更新说明,页脚两个按钮:「查看
更新内容」打开 https://shellrs.com/changelog,「重启并安装」照旧;去掉
「稍后」,用关闭按钮或 Esc 关。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:25:41 +08:00
XuzandClaude Opus 5.5 de0fafe6ee Docker:有多个容器的 compose 项目默认折叠
只有一个容器、而且它在运行的项目默认展开;多个容器的项目展开会把下面的
都挤下去,卡片上已经写着「运行中/总数」。手动展开或折叠照旧记在面板里。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 00:25:36 +08:00
XuzandClaude Opus 5.5 7886cb7138 外部 CLI:Agent Skills 增加 WorkBuddy
安装到 ~/.workbuddy/skills(Windows 是 %USERPROFILE%\.workbuddy\skills)。
设置页的 skill 行数按 AgentKind::ALL 的长度算,不再写死。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:25:40 +08:00
56 changed files with 3616 additions and 356 deletions

No files matched your search

+54 -1
View File
@@ -1,6 +1,23 @@
# 更新日志
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和 ShellRS 的更新对话框里,用 Markdown 书写,面向使用者。
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
## [0.1.2]
### 新增
- 临时连接:标题栏右上角的「临时连接…」连一台主机而不保存,密码只留在内存里(新建主机请用主机列表的「+」或 ⌘N)。
- 开始页的「快速连接」:搜索保存的主机,回车或单击连接。
### 改进
- 「测试连接」不再要求填写密码,没填时按「无密码」的方式尝试。
## [0.1.1]
### 改进
- 精简了给 AI Agent 的 Skill 说明。
## [0.1.0]
@@ -12,11 +29,47 @@ ShellRS 的第一个公开版本。
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。
- 右侧栏:跟着当前远程终端的系统监控、网络连接、进程管理、系统服务、Docker、历史命令和命令片段。
- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。
- 从堡垒机打开:像调用 Xshell、WinSCP 一样用 `ssh://`、`sftp://` 链接打开终端或 SFTP,不保存连接。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
## [0.1.0-beta.9]
### 新增
- 堡垒机可以像调用 WinSCP 一样,用 `sftp://` 链接打开 ShellRS 的 SFTP。
- 从堡垒机打开时,左侧主机栏自动收起,终端或 SFTP 占满窗口。
## [0.1.0-beta.8]
### 修复
- 从堡垒机打开的连接登录后立即断开。
## [0.1.0-beta.7]
### 修复
- 连不上部分堡垒机:Apache SSHD 等不支持 curve25519、只能提供 2048 位 DH 组的 SSH 服务器,以前握手时会被断开。现在密钥交换和 OpenSSH 的默认一致,支持 ECDH(NIST 曲线),DH 组交换也接受 2048 位的组;OpenSSH 默认能连上的服务器,ShellRS 都能连上。
- 连接失败时写明具体原因,比如连接被拒绝、连接超时,或者和服务器没有共同的算法(列出服务器支持哪些),不再只有一句「无法建立 SSH 连接,请检查地址、端口和主机密钥」。
- Docker:同一个镜像打了多个标签时,每个标签单独一行,查看详情和删除都对应这一行的标签,删除只去掉这个标签。只有仓库名、没有标签的镜像显示为「仓库:<none>」,不再被当成「仓库:latest」。带仓库地址的镜像(如 `registry.example.com/app:1.0`)现在也能删除和查看详情。
## [0.1.0-beta.6]
### 新增
- 从堡垒机打开:JumpServer 等堡垒机可以像调用 Xshell 一样调用 ShellRS(`ShellRS ssh://用户@地址:端口`,或 `-url ssh://… -newtab 名称`),打开一个终端标签连过去。这是临时连接:不保存到主机列表,标签都关掉后就没了;链接里的密码只留在内存里。ShellRS 已经在运行时,链接交给它打开,不会再起一个。堡垒机里要填的程序:Windows 是安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`),macOS 是 `/Applications/ShellRS.app/Contents/MacOS/shellrs`。
- 外部 CLI:`shellrs list` 也列出标签开着的临时连接(标为临时),Agent 可以用它执行命令、传输文件。
- 外部 CLI:Agent Skills 可以安装到 WorkBuddy。
### 改进
- 新版本下载好后,标题栏右上角的更新按钮改成绿色,更醒目。更新对话框不再列出更新说明,改为「查看更新内容」打开官网的更新日志,旁边是「重启并安装」。
- Docker:有多个容器的 compose 项目默认折叠,只有一个容器且在运行的项目默认展开。
## [0.1.0-beta.5]
### 新增
+27 -13
View File
@@ -8,9 +8,9 @@ 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 和测试连接都照它走。功能的详细说明见 `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
已确认的产品决定: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
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
@@ -42,11 +42,11 @@ 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`)。
- `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` 一样,解析不了的名字也算网络错误。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`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`(`Latency` / `LatencyLevel` 在 `connection.rs`,画法是 `shared::LatencyLabel`)。SFTP 标签同样显示:工作线程另起一个 `measure_latency` 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 `SftpEvent::Latency`;`ExplorerPanel` 在 `Connecting` / `Disconnected` 时清掉,只在已连接时由 `title_suffix` 显示。这个任务只在一次 ping 期间攥着 `Arc<SftpClient>`,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。
- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
- `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:<id>`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。
- `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` 按类型映射成中文。选「密码」又没填密码时不拦着(用户定的:不强制填密码,连不上就连不上):`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 写锁。
- `forward/` — 端口转发。规则的模型在 `host/forward.rs`,这里是运行和界面。
@@ -64,10 +64,10 @@ 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` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`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_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。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 往管道里写的就是这样)。
- `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 往管道里写的就是这样)。
- `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
- `client.rs`:命令这一端,连 `app::cli_socket_path()`(即 `app::cli_endpoint(data_dir)`:Unix 上是 `<data_dir>/cli.sock`,Windows 上是 `\\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>`,所以 `SHELLRS_DATA_DIR` 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。`connect` 按平台实现,读帧循环 `talk` 共用。进度行用 `\r` 加补空格覆盖,不用 `\x1b[2K`(老 conhost 不认)。
- `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。
@@ -118,7 +118,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity<TerminalView> }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`、`ProcessPanel`、`ServicePanel`、`DockerPanel`、`HistoryPanel`、`SnippetPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它)。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。
- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result<String, String>`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,几个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。
- **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box<Reading>)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-`),加双引号。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
- **Docker(`docker/`)一次读出四类对象。** 命令(`linux::command()`)先补 PATH(`/usr/local/bin`、`/opt/homebrew/bin`:Docker Desktop、Homebrew),再定 `$d`:root 或 `docker version` 能连上 daemon(在 docker 组里)就是 `docker`,否则 `sudo -n docker`(所有 Docker 命令都用这一段前缀 `DOCKER`);`@@version` 是 `$d version --format "{{.Server.Version}}" 2>&1`,不以数字开头就是连不上,`complaint` 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 `ps -a --no-trunc`、`volume ls`、`images`、`network ls --no-trunc` 都用 `--format "{{json .}}"`,每行一个对象(Docker 1.13 以后都有),按行 `serde_json` 解析,认不出的行跳过。`ps` 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 `=` 的一段接到前一个值上;老 Docker 没有 `State` 字段,从 `Status`(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 `DockerTable::new` 按容器的 `Mounts`、`Image`(`nginx` 就是 `nginx:latest`;镜像没名字了容器会显示它的 ID)、`Networks` 算。拼进命令的 ID 和名字先过 `valid_id`(字母数字开头,只许字母数字和 `_.:-/`,`/` 是给带仓库地址的镜像名的),加双引号。`docker images` 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 `Image::key` 认:有标签的是 `仓库:标签`,没有标签的是镜像 ID(只有仓库名的 `nginx` 会被 Docker 当成 `nginx:latest`,显示成 `nginx:<none>`,也只按 ID 算在用);卡片 id、详情(`image inspect`)和删除(`rmi`,有别的标签时只去掉这一个)都用它,`DockerTable::new` 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。`the_script_asks_docker_for_json` 用一个假 `docker` 脚本(按前两个参数回答)真跑读数命令,查模板 `{{json .}}` 原样到了 docker 手里。标签页是 `shared::count_tabs`(见下面「表单里的几选一用 `shared::SegmentedControl`」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(`overflow_hidden().whitespace_nowrap().text_ellipsis_middle()`,用户要的:「registry.ap-…hello-world:latest」),右边的标签 `flex_shrink_0` 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 `list`(项目卡片展开后高度变,行里带着展开状态,变了就 `splice`);项目那一行的单击派发 `ToggleDockerProject`,按钮 `stop_propagation`。详情一律是 `DetailSection { id, title, body }` 的列表(`SectionBody::Rows` 标签和值、`List` 一行一个名字、`Text` 等宽文本),`rows()` 把空值写成「—」;容器的 `ContainerDetails::sections()` 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(`container-basics:名称`、`volume-basics:挂载点`、`image-users:<容器>`、`network-subnets:<子网>`),等宽文本是「{前缀}-{区块}」(`container-environment`、`image-environment`)。打开详情是一个动作 `ShowDockerObject { object, id }`(容器也是),工作区按种类开容器的或另外三种的对话框。`ControlContainers { subject, ids, command }` 和 `RemoveDockerObject { object, id, name }` 由工作区处理,执行、通知和之后重读列表走 `tools.rs` 的 `run_on_host(HostCommand { script, outcome, done, failed, then })`,系统服务的命令也用它。
- **历史命令(`history/`)读 bash 的历史文件,点了往终端的输入行里打字。** 命令(`bash::command()`)是一行 `sh -c '…'`:`~/.bash_history` 读得到就印 `@@size`(`wc -c`)和 `@@history` 加文件最后 `LIMIT`(512 KB)字节(`tail -c`),否则 `@@missing`;两样都没有(没有 `sh`)是不支持。`@@history` 之后原样是文件,命令本身可能以 `@@` 开头,所以只认第一个;文件比 `LIMIT` 长时丢掉第一行(从行中间切开的)。设了 `HISTTIMEFORMAT` 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(`#` 后面不全是数字的是注释命令,不是时间)。`the_script_reads_the_end_of_the_history_file` 把 `$HOME` 换成临时目录、用本机的 `sh` 真跑。卡片的单击和执行按钮派发 `EnterCommand { command, run }`(按钮 `stop_propagation`),工作区的 `on_enter_command` 对当前远程终端调 `TerminalView::enter_command`,成功就把焦点给终端;引擎的 `encode_command` 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;`replace` 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),`run` 时再加回车;`ALT_SCREEN`(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 `CopyCommand(String)`。
- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。
- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL <pid> 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc/<pid>/cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。
@@ -130,10 +130,24 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **无头测试里的文字比真实的窄。** UI 测试的文字系统是 GPUI 的 `NoopTextSystem`:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(`the_three_kinds_of_forward_share_one_row` 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。
- **示意图的流向一直循环,播放用 gpui-kit 自带的。** 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 `gpui_kit::base::motion::animate_keyframes` 加 `IterationCount::Infinite`,只给出一圈里从 0 到 1 的匀速进度,键里带 `ForwardForm::flow` 这个代次;圆点在一圈里怎么走是纯函数 `diagram::flow_dots`:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(`Easing::EaseInOut`)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。`animate_keyframes` 在 `cx.reduce_motion()` 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(`diagram::stops`)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(`diagram::explain`)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 `set_reduce_motion`,生产环境里动画总会播放。对话框比别的宽(`rems(42.)`)也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。
- **表结构(`SCHEMA_VERSION` 是 14)。** 发布前重构过一次,所有表都是 `STRICT`(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。`groups`(`parent_id` 自引用、`ON DELETE CASCADE`,有 `groups_parent_id` 索引)→ `credentials`(只有密钥凭据有 `key_path`)→ `hosts` → `host_jumps`(`WITHOUT ROWID`,主键 `(host_id, position)`,`jump_id` 是 `ON DELETE SET NULL`)、`bookmarks`(`WITHOUT ROWID`,主键 `(host_id, side, path)`,没有自增 id)、`forwards`。`hosts` 一行装下主机的全部字段(地址列叫 `address`,和界面的「地址」、`Host.address` 一致):登录是 `auth`(`password` / `no-password` / `credential`)加 `username` 或 `credential_id`——用凭据的主机 `username` 为 NULL、用户名只存在凭据上,`credential_id` 外键没有级联动作,所以还有主机在用的凭据删不掉,`remove_credential` 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 `route`(`direct` / `jump` / `proxy`)加四个 `proxy_*` 列(只有代理路由才有值,CHECK 守着);`notes` 非空字符串默认 `''`;`os`、`last_connected_at` 由连接写,表单不碰。`HostRow::of` 是模型到这些列的唯一映射,`load` 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),`migrate` 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 `snippet_categories` 和 `snippets`(`category_id` 级联;`scope_group_id` / `scope_host_id` 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 `groups` / `hosts` 级联,内存里 `remove_unnotified` / `remove_group_unnotified` 照样丢掉;最后一列 `run_on_click` 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),`STEPS` 里是 13 → 14 这一步(`Step::Sql`,步骤是历史,`SCHEMA` 以后再改它也不跟着改),`a_version_13_file_gets_the_snippets_and_keeps_its_hosts` 用冻结的 13 版 schema 守着。以后改表:升 `SCHEMA_VERSION`,在 `STEPS` 里加一步(改表用 `Step::Sql`,要在 Rust 里算就用 `Step::Code`,都在一个事务里、最后写 `user_version`),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(`schema_of` 去掉空白排版后逐字比较)。**加字段可以直接 `ALTER TABLE ADD COLUMN`**:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 `SCHEMA` 里也写在最后一列之后,去掉排版后两边相同(`a_column_added_later_matches_the_schema` 守着);一对多的东西才另建表。
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path())`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate` 后直接退出。
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- **一个数据目录只跑一个 ShellRS。** 两个进程各自「内存说了算」会撞 id、互相覆盖。`main` 在起图形界面之前调 `cli::activate_running_app(cli_socket_path(), link)`:连得上外部 CLI 的套接字就说明已有实例,发一个 `Request::Activate { open }`(带着启动时的链接)后直接退出。不带链接时它序列化成和以前一字不差的 `{"type":"activate"}`(`skip_serializing_if`),旧版收到带 `open` 的也只当普通的 Activate(serde 忽略多余字段),所以**不升** `PROTOCOL_VERSION`:升了会让 PATH 里已装的 `shellrs-cli` 全部报版本不符。
- 服务端在版本检查和「启用外部 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)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(未保存)」(用户定的:临时连接和外部连接在外部 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` 只认保存的主机。
- **打开时标签占满窗口**(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):`open_link` 成功后左侧 Dock 开着就 `toggle_dock` 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。
- **命令片段照常全有**:片段面板列出库里的全部片段,不看是哪台主机(`store.snippets()`),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。
- 链接解析失败(不是 `ssh://` / `sftp://`、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
- `tests/cli_bin.rs` 的 `shellrs_opened_with_a_link_hands_it_to_the_running_app` 用真的 `shellrs` 二进制交接链接(含中文 `-newtab`,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。
- **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存,再调用部署挂钩(`SITE_DEPLOY_HOOK`)让官网 shellrs.com 重新构建。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `docs/release.md`。
@@ -160,7 +174,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **主机系统每次连上都重探一遍。** 认证过、shell 起来、`Started` 发出去之后,传输层另开一条 exec 通道跑 `uname -s; cat /etc/os-release`,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 `cmd /c ver`,还是不认就放弃,主机保留原来的标记。结果沿 `TerminalTransportEvent::HostOsDetected` → 引擎 → `TerminalView` → `TerminalPanel` → 工作区 → `HostStore::set_host_os` 这条链走,和 prompt 是同一套管线。`os` 列由 `set_host_os` 单独写,`update_host` 不碰它(同 `last_connected_at`)。标记是 `shared::HostMark`,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 `muted` 底色加主机名称第一个字。徽章沿用 `Avatar` 的圆形画法,尺寸走 `Sizable`——树里和远程终端 / SFTP 标签上用 `small`(20px),开始页用默认的 `medium`(32px)。`AlmaLinux` 和 `macOS` 没有单一品牌色,改用 `foreground` / `background`,这也是 Apple 官方的画法。品牌色写在 `host_os!` 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 `plain_mark` 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(`without_tooltip`),因为整行已经有一个,两个会叠在一起。
- **登录快照在 UI 线程解析。** 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 `CliTarget` 拿到的都是 `HostLogin`(`HostStore::login(id)` / `login_of`,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 `route: LoginRoute`——每台跳板主机的登录也查好了,`JumpLogin::Host { name, login }` 或已删除的 `JumpLogin::Deleted`;跳板自己的 `route` 一律是直连;主机表单测试连接时用 `HostStore::route_login` 把表单上的 `Route` 解析成同样的东西),不是 `Host`。`HostLogin` 只含影响登录的字段并派生 `PartialEq`(`JumpLogin` 手写 `PartialEq`、不比 `name`,跳板改名不让后面的主机重连),主机 `update` 和 `update_credential` 都在改之前取一遍**全部主机**的快照(`logins`),改完按「前后快照不相等」逐台发 `ConnectionSettingsChanged`(`emit_changed_logins`),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 `Host.user` 里冗余存一份凭据的用户名(`auth` 存默认的 `Password`;库里这一行的 `username` 是 NULL、`auth` 是 `credential`),store 在保存主机(`normalized`)、改凭据和 `load` 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 `host.user` 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。
- **ShellRS 保存的私钥是数据目录 `keys/` 下的文件。** 目录由 `HostStore::with_key_dir` 给出:`main` 只在库打开成功时才给(`app::keys_dir()`),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(`Credential::keeps_key_in`,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。`save_private_key` 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 `forget_key_file` 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,**从不删除或覆盖**(`a_kept_key_goes_with_the_last_credential_using_it` 守着)。`Cargo.toml` 给 `rsa`、`crypto-bigint`、`crypto-primes`、`bcrypt-pbkdf`、`blowfish` 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。
- **秘密只进钥匙串,不进数据库。** `database.rs` 的 `schema_never_contains_secret_columns` 守着这条线(它检查库里的每一张表)。写入的唯一入口是 `HostStore::save_secret`:它在后台线程上调钥匙串,失败同样走 `PersistFailed`。主机换了端点或被删掉时,store 用纯函数 `password_in_use` 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,`*_loaded` 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 `HostDraft`,它派生了 `Debug`(`CredentialDraft` 同理)。凭据的密码由凭据对话框经 `save_secret` 写入 `SecretRef::Credential`;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。`password_in_use` 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(`SecretRef::Proxy`,`proxy:<用户>@<代理地址>:<端口>`)同端点密码:主机对话框用第二个 `SecretFields` 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(`forget_password` / `password_in_use` 同时认两种)。唯一不进钥匙串的是不保存的连接的密码(`SecretRef::Temporary`):只在 `TemporarySecretStore` 的内存里,随临时主机一起忘掉。
- **id 由内存分配。** `next_host_id` / `next_group_id` / `next_forward_id` / `next_credential_id` 四个独立计数器,`HostStore::load` 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。`HostStore::seed()` 生产环境不再使用,是 UI 测试的夹具。
- **对外只给 `PublicId`,不给 `HostId`。** 主机右键「复制 ID」复制的是 `Host.public_id`:16 位字母数字随机串(`rand` 的 `Alphanumeric`,约 95 bit),存在 `hosts.public_id`(可为空,`UNIQUE`)。新建和复制主机时由 `HostStore::unused_public_id` 生成、保证不和内存里已有的重复;`update_unnotified` 从草稿重建主机时必须带上原来的 `public_id`,`update_host` 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 `sqlite3` 手工插入的行没有它,在每次打开库时由 `fill_missing_public_ids` 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。
- **状态经由 store 流动。** 面板和对话框拿到 `Entity<HostStore>` 的克隆,通过它的方法修改(方法内部会 `notify`),消费方用 `cx.observe(&store, ..)`。状态栏得知当前主机的路径是 `BasePanel::set_active` → `store.set_active`;`on_removed` 负责复位状态并发出 `TerminalPanelEvent::Closed` / `ExplorerPanelEvent::Closed`,工作区据此清理注册表。
@@ -194,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`,状态行的 `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-tooltip` / `forward-tooltip` / `credential-tooltiLine 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
+3 -1
View File
@@ -7686,7 +7686,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0-beta.5"
version = "0.1.2"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -7705,6 +7705,7 @@ dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"percent-encoding",
"portable-pty",
"rand 0.9.5",
"reqwest",
@@ -7720,6 +7721,7 @@ dependencies = [
"tokio",
"trash",
"unicode-width",
"url",
"uuid",
"windows-registry 0.6.1",
"windows-sys 0.61.2",
+4 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.0-beta.5"
version = "0.1.2"
edition = "2024"
publish = false
description = "SSH client and host manager: terminal, SFTP, port forwarding"
@@ -44,6 +44,9 @@ rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] }
unicode-width = "0.2"
# `ssh://user:password@host:port` links, as bastion hosts open Xshell with.
url = "2"
percent-encoding = "2"
zeroize = "1"
keyring = "4"
trash = "5"
+23 -4
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.
@@ -70,7 +84,7 @@ Hosts, groups, credentials and forwarding rules live in a local SQLite database.
**A CLI for AI agents**
- `shellrs list` / `exec` / `upload` / `download`. The running ShellRS logs in on the command's behalf, so the command itself never handles a password or private key.
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex or OpenCode from the settings page.
- Install `shellrs` into your PATH and an Agent Skill for Claude Code, Codex, OpenCode or WorkBuddy from the settings page.
**Security and updates**
@@ -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 |
| --- | --- | --- |
+23 -4
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)转发。
@@ -70,7 +84,7 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
**给 AI Agent 用的 CLI**
- `shellrs list` / `exec` / `upload` / `download`,由正在运行的 ShellRS 代为登录,命令本身不接触密码和私钥。
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode 安装 Agent Skill。
- 在设置页一键把 `shellrs` 放进 PATH,并为 Claude Code、Codex、OpenCode、WorkBuddy 安装 Agent Skill。
**安全与更新**
@@ -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。
| 系统 | 安装包 | 说明 |
| --- | --- | --- |
+42 -5
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 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。
@@ -107,7 +120,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- Linux:链接到 `~/.local/bin/shellrs`。
- macOS、Linux 上「移除」只删除指向 shellrs 的链接,同名的其他文件不会被改动。
- Windows:图形程序没有控制台,命令是单独的控制台程序 `shellrs-cli.exe`(与 `shellrs.exe` 放在一起)。「安装」把它复制成 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`,并把这个文件夹加入当前用户的 PATH(不需要管理员),重新打开的终端和 Agent 才能找到它。ShellRS 升级后启动时会自动更新这份副本;更新不了时显示「更新」。副本正被使用时也能更新。「移除」删掉副本并把文件夹移出 PATH。
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
- **Agent Skills**:把教 Agent 使用 `shellrs` 的 skill 写到通用目录(`~/.agents/skills`)或 Codex、Claude Code、OpenCode、WorkBuddy 各自的 skills 目录;内容和当前版本不同时显示「更新」。「复制 skills」把 skill 全文复制到剪贴板。
命令(`shellrs --help` 查看完整说明):
@@ -118,12 +131,36 @@ shellrs upload <ID> <本地路径> <远程路径>
shellrs download <ID> <远程路径> <本地路径>
```
- `<ID>` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。
- `<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。
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里,多行或带引号的命令用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`);输出有中文时先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
## 从堡垒机打开(外部连接)
JumpServer 等堡垒机的本地客户端可以像调用 Xshell 一样调用 ShellRS 打开终端,也可以像调用 WinSCP 一样调用它打开 SFTP:把 ShellRS 设成 SSH 客户端或 SFTP 客户端,参数照 Xshell 或 WinSCP 的写法填。ShellRS 收到 `ssh://` 链接就打开一个终端标签连过去,收到 `sftp://` 链接就打开一个 SFTP 标签:
```sh
ShellRS ssh://用户[:密码]@地址[:端口]
ShellRS -url ssh://用户[:密码]@地址[:端口] -newtab 标签名
ShellRS sftp://用户[:密码]@地址[:端口]
ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
```
- 认得 Xshell 的 `-url`(链接)、`-newtab`(标签名,没给就用地址)和 `-newwin`(忽略:ShellRS 只有一个窗口),以及 WinSCP 的 `/sessionname=标签名`(也可以写成 `-sessionname=`),不分大小写;`/newinstance`、`/ini=nul` 等别的选项忽略。只支持 `ssh://` 和 `sftp://`,端口默认 22。用户名和密码里的特殊字符可以按 URL 的规则转义(`@` 写成 `%40`);用户名里本身带 `@` 也行,最后一个 `@` 后面才是地址。`sftp://` 链接里的路径(WinSCP 用它指定打开哪个目录)目前不认,远程一侧照常打开主目录。
- **标签占满窗口**:打开外部连接时,左侧的主机列表自动收起(同 ⌘B),按 ⌘B(其他平台 Ctrl+B)或双击标签即可展开。
- **外部连接,不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」,其余(在新标签页中连接、打开 SFTP、重新连接)和保存的主机一样。
- **只用一个通道**:很多堡垒机在同一条连接上又开一个通道时会结束整个会话。SFTP 标签本来就只开一个;终端则不探测主机系统(标签上显示名称的首字母),右侧栏只有「命令片段」,历史命令、Docker、系统服务、进程管理、网络连接和系统监控都要另开通道执行命令,这里不提供。
- 链接里的密码只留在内存里,不进钥匙串,重新连接时照样用它;没带密码时,服务器要密码就弹框问。第一次连到某台堡垒机要信任它的主机密钥,记在 ShellRS 自己的 `known_hosts` 里,下次不再问。
- ShellRS 已经在运行时,链接交给正在运行的那个打开,窗口调到前面,不会再起一个 ShellRS。链接有问题(不是 `ssh://` 或 `sftp://`、没有用户名或地址、端口不对)时,ShellRS 弹通知说明原因。
- 堡垒机要调用的程序:
- 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 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
## 标签页
远程终端标签和 SFTP 标签都以主机系统徽章为图标(与主机列表相同)。右键标签可重命名(仅本次运行有效,留空恢复为主机的名称,用于区分同一台主机的多个连接)、在新标签页中连接、打开 SFTP、复制 IP 地址 / 主机名、重新连接、编辑主机,以及关闭当前、左侧、右侧、其他或全部标签。SFTP 标签的菜单是「重命名标签…」(留空恢复为「主机的名称 · SFTP」)、打开 SFTP、复制 IP 地址 / 主机名、重新连接加关闭命令,标签栏右侧和远程终端一样有「SFTP」和「重新连接」按钮;本地终端标签是「重新启动」加关闭命令。标签栏右侧「…」按钮的菜单与当前标签的右键菜单相同。双击任一标签的标题可隐藏或显示左侧的侧栏(同 ⌘B 和标题栏的按钮)。远程终端运行时和 SFTP 标签已连接时,标签栏右侧显示这条 SSH 连接的往返延迟,每 5 秒更新一次:低于 100 ms 为绿色,100–200 ms 为黄色,高于 200 ms 或超时为红色。SFTP 传输进行中也照常测量,读数会随传输占满带宽而升高。批量关闭时,正在上传的 SFTP 标签仍会先确认。
@@ -147,7 +184,7 @@ shellrs download <ID> <远程路径> <本地路径>
bash 在 shell 退出时才把这次执行的命令写进历史文件,所以当前终端里刚执行的命令要等它退出后才会出现。列表在打开历史命令或切换终端时读取,之后点刷新按钮才重新读取;历史文件很长时只读最后 512 KB。
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,有容器在运行的项目默认展开;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
**Docker** 顶部是 Docker 和 Compose 的版本,下面「容器」「卷」「镜像」「网络」四个标签页带数量。「容器」页里,同一个 compose 项目的容器归在项目卡片下(项目名、运行中 / 总数、项目目录),点项目那一行折叠或展开,只有一个容器、而且它在运行的项目默认展开,有多个容器的项目默认折叠;不属于任何项目的在「独立容器」下。每个项目和容器右边有「停止」(或「启动」)和「重启」,项目的按钮作用于它所有的容器;点容器或它的「…」打开详情:「详情」页是名称、ID、镜像、创建时间、入口、命令、端口映射、挂载、环境变量和标签,「日志」页是容器最近 200 行输出;底部可以启动、停止、重启,已停止的容器还可以删除。「卷」「镜像」「网络」页列出各自的名称和基本信息,标出是否有容器在用;没有容器在用的可以点右边的删除按钮删除(Docker 自带的 bridge、host、none 网络不能删)。点卡片打开详情:卷是驱动、挂载点、创建时间、使用它的容器、选项和标签;镜像是完整 ID、标签、摘要、创建时间、大小、平台、层数、使用它的容器、运行配置(入口、命令、工作目录、用户、暴露端口)、环境变量和标签;网络是驱动、范围、IPv6、子网和网关、连在上面的容器及其地址、选项和标签。详情底部也可以删除。
停止、重启和删除都会先确认,启动直接执行,结果以通知显示,之后列表重新读取。不是以 root 登录、也不在 docker 组里时,通过免密码的 sudo 执行。列表在打开 Docker 或切换终端时读取,之后点刷新按钮才重新读取。主机没有安装 Docker 或 Docker 没有运行时会写明。
@@ -316,7 +353,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 |
| Linux x64 | AppImage | 支持 |
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启并安装」就换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现绿色的更新按钮。点开后,「查看更新内容」打开官网的更新日志,「重启并安装」换成新版本并重新打开。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
「设置 › 关于」里的「应用更新」只有三行:
Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

+9 -1
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.
@@ -121,12 +126,15 @@ gpui_kit::actions!(
CheckForUpdates,
/// Download the newer ShellRS that was found.
DownloadUpdate,
/// Show the newer ShellRS: what changed, and restarting into it.
/// Show the newer ShellRS: restarting into it, and where to read
/// what changed.
ShowUpdate,
/// Restart into the downloaded ShellRS.
RestartToUpdate,
/// Open the page to download ShellRS by hand.
OpenDownloadPage,
/// Open the website's changelog.
OpenChangelog,
]
);
+2 -1
View File
@@ -65,7 +65,8 @@ icon_assets!(
Ban,
CircleDot,
FileText,
Zap
Zap,
ExternalLink
]
);
+15 -8
View File
@@ -10,6 +10,7 @@ use std::{
use unicode_width::UnicodeWidthStr as _;
use super::link::OpenLink;
use super::protocol::{
Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request, TransferCounters,
TransferSummary, parse_json, read_frame, write_json,
@@ -61,14 +62,14 @@ pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
/// forward. One too busy to answer is running all the same.
const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(2);
/// Ask the ShellRS listening at `endpoint` to bring its window forward;
/// whether there is one. `false` means nothing is running there and the
/// caller is free to start.
/// Ask the ShellRS listening at `endpoint` to bring its window forward,
/// and to open `open` there; whether there is one. `false` means nothing is
/// running there and the caller is free to start.
///
/// Whatever takes the connection counts as running, whether or not it
/// understands the request: an older ShellRS answers with an error, and a
/// second copy on the same data would be worse than a window left behind.
pub fn activate_running_app(endpoint: &Path) -> bool {
pub fn activate_running_app(endpoint: &Path, open: Option<OpenLink>) -> bool {
let Ok(stream) = connect(endpoint) else {
return false;
};
@@ -78,7 +79,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
let asked = std::thread::Builder::new()
.name("shellrs-activate".into())
.spawn(move || {
let _ = done.send(ask_to_come_forward(&stream));
let _ = done.send(ask_to_come_forward(&stream, open));
});
if asked.is_ok() {
let _ = heard.recv_timeout(ACTIVATION_TIMEOUT);
@@ -88,7 +89,7 @@ pub fn activate_running_app(endpoint: &Path) -> bool {
/// Send the request and wait for the answer, whatever it says: by then the
/// app has the request.
fn ask_to_come_forward<S>(stream: S) -> io::Result<()>
fn ask_to_come_forward<S>(stream: S, open: Option<OpenLink>) -> io::Result<()>
where
S: Copy + io::Read + Write,
{
@@ -97,7 +98,7 @@ where
&mut writer,
&Envelope {
version: PROTOCOL_VERSION,
request: Request::Activate,
request: Request::Activate { open },
},
)?;
read_frame(&mut BufReader::new(stream)).map(|_| ())
@@ -252,7 +253,13 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
[
host.id.clone(),
host.name.clone(),
host.group.clone().unwrap_or_default(),
// 临时连接 and 外部连接 alike: an agent needs no telling
// them apart, only that the host is not saved.
if host.temporary {
"(未保存)".to_string()
} else {
host.group.clone().unwrap_or_default()
},
host.address(),
host.os
.as_deref()
+13 -1
View File
@@ -23,15 +23,17 @@ pub enum AgentKind {
Codex,
ClaudeCode,
OpenCode,
WorkBuddy,
}
impl AgentKind {
/// In the order the settings page lists them.
pub const ALL: [AgentKind; 4] = [
pub const ALL: [AgentKind; 5] = [
AgentKind::Generic,
AgentKind::Codex,
AgentKind::ClaudeCode,
AgentKind::OpenCode,
AgentKind::WorkBuddy,
];
pub fn label(self) -> &'static str {
@@ -40,6 +42,7 @@ impl AgentKind {
AgentKind::Codex => "Codex",
AgentKind::ClaudeCode => "Claude Code",
AgentKind::OpenCode => "OpenCode",
AgentKind::WorkBuddy => "WorkBuddy",
}
}
@@ -57,6 +60,7 @@ impl AgentKind {
AgentKind::Codex => "codex",
AgentKind::ClaudeCode => "claude",
AgentKind::OpenCode => "opencode",
AgentKind::WorkBuddy => "workbuddy",
}
}
@@ -68,6 +72,7 @@ impl AgentKind {
AgentKind::Codex => &[".codex", "skills"],
AgentKind::ClaudeCode => &[".claude", "skills"],
AgentKind::OpenCode => &[".config", "opencode", "skills"],
AgentKind::WorkBuddy => &[".workbuddy", "skills"],
}
}
}
@@ -441,6 +446,13 @@ mod tests {
.join("shellrs")
.join("SKILL.md")
);
assert_eq!(
paths.skill_file(AgentKind::WorkBuddy),
home.join(".workbuddy")
.join("skills")
.join("shellrs")
.join("SKILL.md")
);
}
#[test]
+280
View File
@@ -0,0 +1,280 @@
//! The links ShellRS is opened with, the way bastion hosts open Xshell:
//! `ShellRS ssh://user@host:port`, or `ShellRS -url ssh://… -newtab 名称`;
//! and WinSCP: `ShellRS sftp://user@host:port`, or with
//! `/sessionname=名称` in front.
//!
//! Only told apart from the `shellrs` command's arguments here; the link
//! itself is read by the app (`host::SshLink`), which says what is wrong
//! with it in a notification. Opened again, ShellRS hands the link to the
//! one already running, inside [`super::Request::Activate`].
use std::ffi::OsString;
use std::fmt;
use serde::{Deserialize, Serialize};
/// A link to connect to, as the caller wrote it.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenLink {
/// May hold a password. Empty when the arguments named no link, so the
/// app can say so: a second ShellRS on Windows has no console to.
pub url: String,
/// What to call the tab: Xshell's `-newtab`, WinSCP's `/sessionname`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<String>,
}
impl fmt::Debug for OpenLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OpenLink")
.field("url", &without_password(&self.url))
.field("tab", &self.tab)
.finish()
}
}
/// `url` with its password replaced, for logs. Never the text itself when
/// it cannot be read: where the password ends is unknown then.
fn without_password(url: &str) -> String {
if url.is_empty() {
return String::new();
}
match url::Url::parse(url) {
Ok(mut parsed) => {
if parsed.password().is_some() {
let _ = parsed.set_password(Some("***"));
}
parsed.to_string()
}
Err(_) => "<无法解析的链接>".into(),
}
}
/// Xshell's options. `-newwin` asks for a window of its own, which means
/// nothing to ShellRS's one window.
const URL_OPTION: &str = "-url";
const TAB_OPTION: &str = "-newtab";
const WINDOW_OPTION: &str = "-newwin";
/// WinSCP's switches, written `/name[=value]` or `-name[=value]`:
/// `/sessionname=名称` names the tab, the others mean nothing to ShellRS
/// (`/newinstance` asks for a window of its own, `/ini=nul` not to read
/// WinSCP's settings, `/privatekey=` and `/hostkey=` are ShellRS's to
/// know).
const WINSCP_SWITCHES: &[&str] = &[
SESSION_NAME,
"newinstance",
"privatekey",
"hostkey",
"passphrase",
"rawsettings",
"ini",
"log",
"timeout",
];
const SESSION_NAME: &str = "sessionname";
/// The link `args` (without the program) open, or `None` when they are the
/// `shellrs` command's. Only the first argument decides, so `shellrs exec
/// web "curl http://x"` stays a command: a link launch starts with one of
/// Xshell's options, one of WinSCP's switches or the link itself.
pub fn link_arguments(args: &[OsString]) -> Option<OpenLink> {
let args: Vec<String> = args
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
let first = args.first()?;
if !is_option(first) && winscp_switch(first).is_none() && !looks_like_link(first) {
return None;
}
let mut given = None;
let mut bare = None;
let mut tab = None;
let mut args = args.into_iter().peekable();
while let Some(arg) = args.next() {
let option = arg.to_lowercase();
if option == URL_OPTION {
let url = args.next();
given = given.or(url);
} else if option == TAB_OPTION {
// Its name, unless the next argument is something else.
if args
.peek()
.is_some_and(|next| !next.starts_with('-') && !looks_like_link(next))
{
tab = tab_name(&args.next().unwrap_or_default()).or(tab);
}
} else if let Some((switch, value)) = winscp_switch(&arg) {
if switch == SESSION_NAME {
tab = value.and_then(tab_name).or(tab);
}
} else if option == WINDOW_OPTION || arg.starts_with('-') {
// Ignored, like any option ShellRS does not know.
} else if bare.is_none() && looks_like_link(&arg) {
bare = Some(arg);
}
}
Some(OpenLink {
url: given.or(bare).unwrap_or_default(),
tab,
})
}
fn is_option(arg: &str) -> bool {
[URL_OPTION, TAB_OPTION, WINDOW_OPTION].contains(&arg.to_lowercase().as_str())
}
/// The name and value of one of WinSCP's switches, the name in lower case.
fn winscp_switch(arg: &str) -> Option<(String, Option<&str>)> {
let switch = arg.strip_prefix(['/', '-'])?;
let (name, value) = match switch.split_once('=') {
Some((name, value)) => (name, Some(value)),
None => (switch, None),
};
let name = name.to_lowercase();
WINSCP_SWITCHES
.contains(&name.as_str())
.then_some((name, value))
}
/// A tab's name as given, without the spaces or quotes around it; `None`
/// when that leaves nothing.
fn tab_name(given: &str) -> Option<String> {
let name = given.trim().trim_matches('"').trim();
(!name.is_empty()).then(|| name.to_string())
}
/// `scheme://…`, whatever the scheme: the app says which ones it opens.
fn looks_like_link(arg: &str) -> bool {
let Some((scheme, _)) = arg.split_once("://") else {
return false;
};
let mut chars = scheme.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
#[cfg(test)]
mod tests {
use super::*;
fn link(args: &[&str]) -> Option<OpenLink> {
let args: Vec<OsString> = args.iter().map(OsString::from).collect();
link_arguments(&args)
}
fn open(url: &str, tab: Option<&str>) -> Option<OpenLink> {
Some(OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
})
}
#[test]
fn a_link_on_its_own_opens() {
let url = "ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024";
assert_eq!(link(&[url]), open(url, None));
}
#[test]
fn xshells_options_are_understood_in_any_case_and_order() {
let url = "ssh://deploy:secret@10.0.0.9:2222";
assert_eq!(
link(&["-url", url, "-newtab", "生产库"]),
open(url, Some("生产库"))
);
assert_eq!(
link(&["-NewTab", " 生产库 ", "-newwin", "-URL", url]),
open(url, Some("生产库"))
);
// `-newtab` without a name.
assert_eq!(link(&["-newtab", url]), open(url, None));
assert_eq!(link(&["-newwin", url, "-newtab"]), open(url, None));
// `-url` wins over a bare link, and the first of each counts.
assert_eq!(
link(&["ssh://a@b", "-url", url, "-url", "ssh://c@d"]),
open(url, None)
);
// Unknown options are passed over, their values with them unless
// they look like a link.
assert_eq!(link(&["-newwin", "-folder", "x", url]), open(url, None));
}
#[test]
fn winscps_switches_are_understood_too() {
// How JumpServer opens WinSCP: the link alone.
let url = "sftp://b478e26f:secret@172.16.0.28:12024";
assert_eq!(link(&[url]), open(url, None));
assert_eq!(
link(&["/sessionname=生产库", url]),
open(url, Some("生产库"))
);
assert_eq!(
link(&[
"/newinstance",
"/ini=nul",
"-SessionName=\"文件 服务器\"",
url
]),
open(url, Some("文件 服务器"))
);
assert_eq!(
link(&[
"/rawsettings",
"Compression=1",
url,
"/hostkey=ssh-ed25519 255 x"
]),
open(url, None)
);
assert_eq!(link(&["/sessionname=生产库"]), open("", Some("生产库")));
}
#[test]
fn options_without_a_link_still_open_so_the_app_can_say_why() {
assert_eq!(link(&["-newtab", "生产库"]), open("", Some("生产库")));
assert_eq!(link(&["-url"]), open("", None));
}
#[test]
fn the_commands_arguments_are_left_to_the_command() {
for args in [
&[][..],
&["list"],
&["exec", "web", "curl http://example.com"],
&["upload", "web", "./a", "/tmp/"],
&["--help"],
&["-h"],
&["--version"],
&["ssh-not-a-link"],
&["://nothing-before"],
&["/tmp/sessionname"],
&["/usr/bin/sftp://x"],
] {
assert_eq!(link(args), None, "{args:?}");
}
}
#[test]
fn debug_hides_the_password() {
let shown = format!(
"{:?}",
OpenLink {
url: "ssh://deploy:hunter2@10.0.0.9".into(),
tab: None,
}
);
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("deploy"), "{shown}");
let unreadable = format!(
"{:?}",
OpenLink {
url: "ssh://deploy:hunter2@[bad".into(),
tab: None,
}
);
assert!(!unreadable.contains("hunter2"), "{unreadable}");
}
}
+5 -2
View File
@@ -9,6 +9,7 @@ mod install;
#[cfg(any(windows, test))]
mod install_windows;
mod integration;
mod link;
#[cfg(windows)]
mod pipe_windows;
mod protocol;
@@ -32,6 +33,7 @@ pub use install::{
update_outdated_binary, update_outdated_skills,
};
pub use integration::{CliIntegration, IntegrationStatus};
pub use link::{OpenLink, link_arguments};
pub use protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
pub use server::{CliBackend, CliServer, CliTarget};
@@ -287,8 +289,9 @@ impl<W: Write> Write for ConsoleText<W> {
}
}
/// Whether the process was started as the command rather than the app:
/// with arguments, apart from the one macOS adds when launching an app.
/// The arguments the process was started with, apart from the one macOS
/// adds when launching an app; `None` without any. The command's, unless
/// [`link_arguments`] finds a link to open in them.
pub fn command_line_arguments() -> Option<Vec<OsString>> {
let args: Vec<OsString> = std::env::args_os()
.skip(1)
+50 -5
View File
@@ -13,6 +13,8 @@ use std::{
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use super::link::OpenLink;
/// Bumped when a request or reply changes shape. The command and the app
/// come from the same build, so they differ only while an older copy still
/// runs, or on Windows while the copy on the PATH has not been updated.
@@ -124,10 +126,16 @@ pub enum Request {
source: String,
destination: PathBuf,
},
/// Bring the app's window forward. Not the `shellrs` command's: ShellRS
/// sends it when it is opened while it is already running, so it is
/// answered whatever 启用外部 CLI says.
Activate,
/// Bring the app's window forward, and open `open` in it. Not the
/// `shellrs` command's: ShellRS sends it when it is opened while it is
/// already running, so it is answered whatever 启用外部 CLI says.
///
/// Without a link it is `{"type":"activate"}`, as it always was. A
/// ShellRS from before links ignores `open` and only comes forward.
Activate {
#[serde(default, skip_serializing_if = "Option::is_none")]
open: Option<OpenLink>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -151,7 +159,7 @@ pub enum Reply {
},
}
/// A saved host as the CLI lists it.
/// A host as the CLI lists it: a saved one, or a temporary one.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// The host's public ID, the one 复制 ID copies.
@@ -164,6 +172,12 @@ pub struct HostInfo {
pub port: u16,
/// The detected operating system, as stored (`ubuntu`, `macos`, …).
pub os: Option<String>,
/// 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,
}
impl HostInfo {
@@ -300,4 +314,35 @@ mod tests {
);
}
}
#[test]
fn activating_without_a_link_is_what_it_always_was() {
let plain = Envelope {
version: PROTOCOL_VERSION,
request: Request::Activate { open: None },
};
assert_eq!(
serde_json::to_string(&plain).unwrap(),
r#"{"version":2,"request":{"type":"activate"}}"#
);
let read: Envelope = parse_json(br#"{"version":2,"request":{"type":"activate"}}"#).unwrap();
assert_eq!(read, plain);
let with_link = Request::Activate {
open: Some(OpenLink {
url: "ssh://token@10.0.0.9:2222".into(),
tab: Some("堡垒机".into()),
}),
};
let wire = serde_json::to_vec(&with_link).unwrap();
assert_eq!(parse_json::<Request>(&wire).unwrap(), with_link);
// How a ShellRS from before links read it: still a plain Activate.
#[derive(Debug, PartialEq, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum Before {
Activate,
}
assert_eq!(parse_json::<Before>(&wire).unwrap(), Before::Activate);
}
}
+51 -28
View File
@@ -6,7 +6,7 @@ use std::{
io::{self, BufReader},
path::{Path, PathBuf},
sync::{
Arc, RwLock,
Arc, Mutex, RwLock,
atomic::{AtomicBool, Ordering},
},
thread::JoinHandle,
@@ -15,6 +15,7 @@ use std::{
#[cfg(unix)]
use tokio::sync::watch;
use super::link::OpenLink;
use super::protocol::{
CliError, Envelope, ErrorCode, FrameKind, HostInfo, PROTOCOL_VERSION, Reply, Request,
TransferCounters, TransferSummary, parse_json, read_frame, write_frame, write_json,
@@ -78,12 +79,19 @@ impl CliTarget {
host: host.address.to_string(),
port: host.port,
os: host.os.map(|os| os.as_str().to_string()),
temporary: false,
},
host: host.clone(),
login,
}
}
/// Marked as not saved.
fn temporary(mut self) -> Self {
self.info.temporary = true;
self
}
pub fn host(&self) -> &Host {
&self.host
}
@@ -93,23 +101,27 @@ impl CliTarget {
&self.login
}
/// Every host in `store`, with its group path.
/// 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> {
store
.hosts()
let saved = store.hosts().iter().map(|host| {
let names = host
.group
.map(|id| store.group_names(id))
.unwrap_or_default();
Self::new(
host,
store.login_of(host),
(!names.is_empty()).then(|| names.join("/")),
)
});
let temporary = store
.temporary_hosts()
.iter()
.map(|host| {
let names = host
.group
.map(|id| store.group_names(id))
.unwrap_or_default();
Self::new(
host,
store.login_of(host),
(!names.is_empty()).then(|| names.join("/")),
)
})
.collect()
.map(|host| Self::new(host, store.login_of(host), None).temporary());
saved.chain(temporary).collect()
}
/// The host tree's search, plus the group path and the ID.
@@ -130,9 +142,10 @@ struct Shared {
enabled: AtomicBool,
targets: RwLock<Vec<CliTarget>>,
backend: Arc<dyn CliBackend>,
/// Set when ShellRS was opened again while this one runs, until the
/// app has brought its window forward.
activation: AtomicBool,
/// Set when ShellRS was opened again while this one runs, with the
/// links it was opened with, until the app has come forward and opened
/// them.
activation: Mutex<Option<Vec<OpenLink>>>,
}
/// Listens on the CLI socket until dropped. Always listening, even with
@@ -152,7 +165,7 @@ impl CliServer {
enabled: AtomicBool::new(false),
targets: RwLock::new(Vec::new()),
backend,
activation: AtomicBool::new(false),
activation: Mutex::new(None),
});
let listener = listen(endpoint, shared.clone())?;
Ok(Self {
@@ -173,11 +186,16 @@ impl CliServer {
.unwrap_or_else(|error| error.into_inner()) = targets;
}
/// Whether ShellRS was opened again since this was last asked. The
/// request threads cannot reach the window, so the app asks here on a
/// timer and brings its window forward when the answer is yes.
pub fn take_activation(&self) -> bool {
self.shared.activation.swap(false, Ordering::AcqRel)
/// Whether ShellRS was opened again since this was last asked, and the
/// links it was opened with, oldest first: `Some` and empty for a plain
/// re-open. The request threads cannot reach the window, so the app asks
/// here on a timer, then comes forward and opens them.
pub fn take_activation(&self) -> Option<Vec<OpenLink>> {
self.shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.take()
}
}
@@ -422,8 +440,13 @@ fn respond(
.map_err(|error| CliError::new(ErrorCode::BadRequest, error.to_string()))?;
// Before the checks below: coming forward means the same in every
// version, and it is ShellRS being opened again, not the external CLI.
if envelope.request == Request::Activate {
shared.activation.store(true, Ordering::Release);
if let Request::Activate { open } = &envelope.request {
shared
.activation
.lock()
.unwrap_or_else(|error| error.into_inner())
.get_or_insert_with(Vec::new)
.extend(open.clone());
return Ok(Reply::Activated);
}
if envelope.version != PROTOCOL_VERSION {
@@ -498,7 +521,7 @@ fn respond(
.map(Reply::TransferDone)
}
// Answered above, before anything was checked.
Request::Activate => Ok(Reply::Activated),
Request::Activate { .. } => Ok(Reply::Activated),
}
}
+98 -8
View File
@@ -10,12 +10,14 @@ use std::{
use clap::Parser as _;
use super::client::{self, Console};
use super::link::OpenLink;
use super::protocol::{CliError, ErrorCode, HostInfo, Request, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliServer, CliTarget};
use super::{Cli, Command};
use super::{ConsoleText, normalize_command};
use crate::app::cli_endpoint;
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin};
use crate::host::{AuthKind, GroupId, Host, HostDraft, HostId, HostLogin, HostStore, SshLink};
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
/// Records what it was asked and answers from a script.
@@ -328,12 +330,12 @@ fn opening_shellrs_again_brings_the_running_one_forward() {
let fixture = fixture();
// Whatever 启用外部 CLI says: this is not the external CLI.
fixture.server.set_enabled(false);
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
assert!(client::activate_running_app(&fixture.socket));
assert!(fixture.server.take_activation());
assert!(client::activate_running_app(&fixture.socket, None));
assert_eq!(fixture.server.take_activation(), Some(Vec::new()));
// Heard once, acted on once.
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
assert!(fixture.backend.calls.lock().unwrap().is_empty());
// The switch still guards everything else.
@@ -344,13 +346,101 @@ fn opening_shellrs_again_brings_the_running_one_forward() {
.unwrap()
.starts_with("shellrs: [not_enabled]")
);
assert!(!fixture.server.take_activation());
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn opening_shellrs_with_links_hands_them_to_the_running_one_in_order() {
let fixture = fixture();
fixture.server.set_enabled(false);
let link = |url: &str, tab: Option<&str>| OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
};
let first = link("ssh://token:p%40ss@172.16.0.28:12024", Some("堡垒机"));
let second = link("ssh://root@10.0.0.9", None);
assert!(client::activate_running_app(
&fixture.socket,
Some(first.clone())
));
assert!(client::activate_running_app(&fixture.socket, None));
assert!(client::activate_running_app(
&fixture.socket,
Some(second.clone())
));
assert_eq!(fixture.server.take_activation(), Some(vec![first, second]));
assert_eq!(fixture.server.take_activation(), None);
}
#[test]
fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() {
let mut store = HostStore::empty();
let saved = store.insert_unnotified(HostDraft::new(
"web",
"10.0.1.12",
22,
"root",
AuthKind::Password,
None,
));
let link = store.insert_external_unnotified(
SshLink::parse("ssh://token:secret@10.0.0.9:2222", Some("堡垒机")).unwrap(),
);
let targets = CliTarget::all(&store);
let ids: Vec<HostId> = targets.iter().map(|target| target.host().id).collect();
assert_eq!(ids, [saved, link]);
// With the link's password, which only memory holds.
assert_eq!(targets[1].login().password, SecretRef::temporary(link.0));
let fixture = fixture();
fixture.server.set_targets(targets);
let (code, stdout, _) = run(&fixture.socket, Request::List { query: None }, true);
assert_eq!(code, 0);
let hosts: Vec<HostInfo> = serde_json::from_str(&stdout).unwrap();
let listed: Vec<(&str, bool)> = hosts
.iter()
.map(|host| (host.name.as_str(), host.temporary))
.collect();
assert_eq!(listed, [("web", false), ("堡垒机", true)]);
let temporary = store.host(link).unwrap().public_id.to_string();
assert_eq!(hosts[1].id, temporary);
assert_eq!(hosts[1].address(), "token@10.0.0.9:2222");
// Reached by that ID like a saved host.
let (code, _, _) = run(
&fixture.socket,
Request::Exec {
host: temporary,
command: "uptime".into(),
},
false,
);
assert_eq!(code, 3);
// 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}"
);
}
#[test]
fn an_older_apps_list_reads_as_saved_hosts() {
let host: HostInfo = serde_json::from_str(
r#"{"id":"VmLkf1snMOuPKJ07","name":"web","group":null,"user":"root","host":"10.0.1.12","port":22,"os":null}"#,
)
.unwrap();
assert!(!host.temporary);
}
#[test]
fn with_nothing_running_shellrs_is_free_to_start() {
let dir = tempfile::tempdir().unwrap();
assert!(!client::activate_running_app(&cli_endpoint(dir.path())));
assert!(!client::activate_running_app(
&cli_endpoint(dir.path()),
None
));
// Nor does a socket left behind by a crash count as a running app.
#[cfg(unix)]
@@ -359,7 +449,7 @@ fn with_nothing_running_shellrs_is_free_to_start() {
// No process may start meanwhile and keep the socket open.
let _forks = crate::testing::no_forks();
drop(std::os::unix::net::UnixListener::bind(&stale).unwrap());
assert!(!client::activate_running_app(&stale));
assert!(!client::activate_running_app(&stale, None));
}
}
+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
}
+3 -3
View File
@@ -50,7 +50,7 @@ pub struct DockerPanel {
loading: bool,
tab: DockerTab,
/// Compose projects folded or unfolded by hand, by name. The rest are
/// unfolded when something in them runs.
/// as [`Project::unfolded_at_first`] says.
unfolded: HashMap<String, bool>,
/// The list's lines differ in height (a project with its containers,
/// a heading, a container), so it is a list that measures them.
@@ -158,7 +158,7 @@ impl DockerPanel {
self.unfolded
.get(&project.name)
.copied()
.unwrap_or(project.running > 0)
.unwrap_or_else(|| project.unfolded_at_first())
}
fn show(&mut self, tab: DockerTab, cx: &mut Context<Self>) {
@@ -369,7 +369,7 @@ impl DockerPanel {
DockerRow::Image(index) => {
let image = &table.images()[*index];
render_object(
format!("docker-image:{}", image.id).into(),
format!("docker-image:{}", image.key).into(),
image.summary(),
&dispatch,
cx,
+66 -4
View File
@@ -41,14 +41,15 @@ pub fn command() -> String {
}
/// Whether `id` is an ID or a name Docker gave or allows, and nothing else,
/// so that it can go into a command.
/// so that it can go into a command. An image's `repository:tag` may name
/// a registry, so `/` is allowed too (`registry.example.com/app:1.0`).
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 255
&& id.starts_with(|character: char| character.is_ascii_alphanumeric())
&& id
.chars()
.all(|character| character.is_ascii_alphanumeric() || "_.:-".contains(character))
.all(|character| character.is_ascii_alphanumeric() || "_.:-/".contains(character))
}
/// `script` with `$d` for `docker`, saying how it went in an `@@status`
@@ -274,10 +275,20 @@ fn volume(object: &Value) -> Volume {
fn image(object: &Value) -> Image {
let (repository, tag) = (text(object, "Repository"), text(object, "Tag"));
let id = text(object, "ID").trim_start_matches("sha256:").to_owned();
let tagged = repository != "<none>" && !matches!(tag, "<none>" | "");
Image {
id: text(object, "ID").trim_start_matches("sha256:").to_owned(),
// 「nginx」 alone would be read as nginx:latest, another image.
key: if tagged {
format!("{repository}:{tag}")
} else {
id.clone()
},
id,
// 「nginx:<none>」 as `docker images` puts it: plain 「nginx」 would
// say nginx:latest, and the containers of that would count as its.
reference: (repository != "<none>").then(|| match tag {
"<none>" | "" => repository.to_owned(),
"<none>" | "" => format!("{repository}:<none>"),
tag => format!("{repository}:{tag}"),
}),
size: format_size(text(object, "Size")),
@@ -714,6 +725,54 @@ v2.25.0
assert!(table.volumes()[0].used_by.is_empty());
}
#[test]
fn an_image_with_several_tags_is_a_line_for_each_named_by_its_tag() {
let table = table(
"@@version\n26.0.0\n@@containers\n@@volumes\n@@images\n\
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"latest\",\"Size\":\"187MB\"}\n\
{\"ID\":\"aaa111\",\"Repository\":\"nginx\",\"Tag\":\"1.27\",\"Size\":\"187MB\"}\n\
{\"ID\":\"aaa111\",\"Repository\":\"registry.example.com/web/nginx\",\"Tag\":\"prod\",\"Size\":\"187MB\"}\n\
{\"ID\":\"bbb222\",\"Repository\":\"nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
{\"ID\":\"bbb222\",\"Repository\":\"mirror/nginx\",\"Tag\":\"<none>\",\"Size\":\"180MB\"}\n\
@@networks\n",
);
let keys: Vec<&str> = table
.images()
.iter()
.map(|image| image.key.as_str())
.collect();
// 「nginx」 with no tag is not nginx:latest: it goes by its ID, and
// once, though two repositories hold it.
assert_eq!(
keys,
[
"nginx:1.27",
"nginx:latest",
"registry.example.com/web/nginx:prod",
"bbb222"
]
);
assert_eq!(
table.images()[3].reference.as_deref(),
Some("mirror/nginx:<none>")
);
let summary = table
.summary_of(DockerObject::Image, "nginx:1.27")
.expect("by its tag");
assert_eq!(summary.name, "nginx:1.27");
assert_eq!(summary.id, "nginx:1.27");
// Removing a line removes that tag, wherever the registry is.
assert_eq!(
remove_command(DockerObject::Image, "registry.example.com/web/nginx:prod").as_deref(),
Some(
"sh -c 'export LC_ALL=C PATH=$PATH:/usr/local/bin:/opt/homebrew/bin; \
if test \"$(id -u)\" = 0 || docker version >/dev/null 2>&1; then d=docker; \
else d=\"sudo -n docker\"; fi; $d rmi \"registry.example.com/web/nginx:prod\" 2>&1; \
echo @@status $?'"
)
);
}
#[test]
fn a_docker_that_will_not_answer_says_why() {
assert_eq!(
@@ -853,7 +912,10 @@ v2.25.0
assert!(valid_id("f8664a4a9b8d"));
assert!(valid_id("vw-data"));
assert!(valid_id("sha256:4a3b5c"));
assert!(valid_id("registry.example.com:5000/team/app:1.0"));
assert!(!valid_id("-rf"));
assert!(!valid_id("/etc"));
assert!(!valid_id("a\"b"));
assert!(!valid_id("a b"));
assert!(!valid_id("$(reboot)"));
assert_eq!(remove_command(DockerObject::Volume, "a;b"), None);
+61 -9
View File
@@ -88,8 +88,13 @@ pub struct Volume {
#[derive(Clone, Debug, PartialEq)]
pub struct Image {
/// 「4a3b5c6d7e8f」.
/// 「4a3b5c6d7e8f」. Not unique in the list: an image tagged twice is
/// two of its lines, one per tag.
pub id: String,
/// What a command names this line by, unique in the list: 「nginx:1.27」,
/// or the ID for an image without a tag. Removing a tag removes the
/// image only with its last one, as `docker rmi nginx:1.27` does.
pub key: String,
/// 「nginx:1.27」, `None` for a dangling image (「<none>」).
pub reference: Option<String>,
/// 「187.69 MB」.
@@ -127,6 +132,16 @@ pub struct Project {
pub running: usize,
}
impl Project {
/// Whether the list shows its containers until folded or unfolded by
/// hand: only a project of one container, running. A project of
/// several would push the rest of the list down; the card already says
/// how many of them run.
pub fn unfolded_at_first(&self) -> bool {
self.running > 0 && self.containers.len() == 1
}
}
/// What can be done to containers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ContainerCommand {
@@ -296,14 +311,23 @@ impl DockerTable {
}) || (by_id && (id.starts_with(&image.id) || image.id.starts_with(id)))
});
}
// Named ones first, by name; the dangling after.
// Tagged ones first, by name; then those without a tag, the ones
// without a repository either last.
images.sort_by(|a, b| {
(a.reference.is_none(), &a.reference, &a.id).cmp(&(
b.reference.is_none(),
&b.reference,
&b.id,
))
let order = |image: &Image| {
(
image.key == image.id,
image.reference.is_none(),
image.reference.clone(),
image.id.clone(),
)
};
order(a).cmp(&order(b))
});
// One card per key: an image without a tag in two repositories is
// one image.
let mut seen = std::collections::HashSet::new();
images.retain(|image| seen.insert(image.key.clone()));
for network in &mut networks {
network.used_by = using(&|container| container.networks.contains(&network.name));
@@ -342,7 +366,7 @@ impl DockerTable {
DockerObject::Image => self
.images
.iter()
.find(|image| image.id == id)
.find(|image| image.key == id)
.map(Image::summary),
DockerObject::Network => self
.networks
@@ -486,7 +510,7 @@ impl Image {
let in_use = !self.used_by.is_empty();
ObjectSummary {
object: DockerObject::Image,
id: self.id.clone(),
id: self.key.clone(),
name: self
.reference
.clone()
@@ -708,6 +732,33 @@ mod tests {
assert_eq!(table.counts(), [4, 0, 0, 0]);
}
#[test]
fn only_a_running_project_of_one_container_starts_unfolded() {
let table = DockerTable::new(
None,
None,
vec![
container("php56", ContainerState::Running, Some("php-56")),
container("vaultwarden", ContainerState::Exited, Some("vaultwarden")),
container("db", ContainerState::Running, Some("shop")),
container("web", ContainerState::Running, Some("shop")),
container("worker", ContainerState::Exited, Some("shop")),
],
Vec::new(),
Vec::new(),
Vec::new(),
);
let unfolded: Vec<(&str, bool)> = table
.projects()
.iter()
.map(|project| (project.name.as_str(), project.unfolded_at_first()))
.collect();
assert_eq!(
unfolded,
[("php-56", true), ("shop", false), ("vaultwarden", false)]
);
}
#[test]
fn what_the_containers_use_is_marked() {
let mut web = container("web", ContainerState::Running, None);
@@ -722,6 +773,7 @@ mod tests {
};
let image = |id: &str, reference: Option<&str>| Image {
id: id.into(),
key: reference.unwrap_or(id).into(),
reference: reference.map(str::to_owned),
size: String::new(),
created: String::new(),
+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 {
+8
View File
@@ -44,6 +44,12 @@ pub struct HostLogin {
pub password: SecretRef,
/// How the connection reaches the host.
pub route: LoginRoute,
/// The connection carries the terminal and nothing beside it: no
/// channel of its own for the system probe or the right sidebar's
/// commands. A bastion host that opened ShellRS with a link serves one
/// session per connection and ends it when another channel comes and
/// goes, as Xshell and OpenSSH never make it do.
pub shell_only: bool,
}
/// How a connection reaches a host, with every jump host's login looked up.
@@ -149,6 +155,7 @@ impl HostLogin {
method,
key_path: None,
route: LoginRoute::Direct,
shell_only: false,
}
}
@@ -177,6 +184,7 @@ impl HostLogin {
key_path,
password,
route: LoginRoute::Direct,
shell_only: false,
}
}
+8 -1
View File
@@ -11,20 +11,27 @@ mod login;
mod model;
mod outline;
mod private_key;
mod quick_connect;
mod secret_fields;
mod snippet;
mod ssh_link;
mod store;
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};
pub use store::{HostStore, HostStoreEvent};
+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)]);
}
}
+269
View File
@@ -0,0 +1,269 @@
//! `ssh://user[:password]@host[:port]`: how a bastion host opens Xshell, and
//! so ShellRS, on a host the user has not saved; `sftp://…` the same way is
//! how it opens WinSCP. ShellRS connects to it as an 外部连接: not saved,
//! gone with its tabs, a terminal or an SFTP tab alone.
use std::fmt;
use percent_encoding::percent_decode_str;
use url::{Host as UrlHost, ParseError, Url};
use zeroize::Zeroizing;
use super::{AuthKind, HostDraft};
/// The port when the link names none.
const SSH_PORT: u16 = 22;
/// What a link opens, by its scheme.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LinkKind {
/// `ssh://`, as for Xshell: a terminal.
Ssh,
/// `sftp://`, as for WinSCP: an SFTP tab.
Sftp,
}
/// What a link says to connect to.
pub struct SshLink {
pub kind: LinkKind,
/// The tab's name: what the bastion host called it (Xshell's
/// `-newtab`), or else the address.
pub name: String,
/// Without brackets, for an IPv6 address.
pub address: String,
pub port: u16,
pub user: String,
/// Kept in memory only, never in the keychain or the database.
pub password: Option<Zeroizing<String>>,
}
impl SshLink {
/// Read `link`, named `tab` when the caller gave a name. The error says
/// what is wrong without repeating the link, which may hold a password.
pub fn parse(link: &str, tab: Option<&str>) -> Result<Self, String> {
let link = link.trim();
if link.is_empty() {
return Err(
"启动参数里没有要连接的地址,应为 ssh://用户@地址[:端口](打开 SFTP 用 sftp://)。"
.into(),
);
}
let malformed = || {
"链接的格式不对,应为 ssh://用户[:密码]@地址[:端口](打开 SFTP 用 sftp://)。"
.to_string()
};
// Without `://`, `user:password@host` would read as the scheme
// `user`, and the error would show it.
if !link.contains("://") {
return Err(malformed());
}
let url = Url::parse(link).map_err(|error| match error {
ParseError::InvalidPort => "链接里的端口无效。".to_string(),
ParseError::EmptyHost => "链接里没有地址。".to_string(),
_ => malformed(),
})?;
let kind = match url.scheme() {
"ssh" => LinkKind::Ssh,
"sftp" => LinkKind::Sftp,
scheme => {
return Err(format!(
"只支持 ssh:// 和 sftp:// 链接,不支持 {scheme}://。"
));
}
};
// `ssh` is not one of the schemes the URL standard knows, so an IPv4
// address and a domain both come back as an opaque domain, which is
// percent-encoded when it is not ASCII.
let address = match url.host() {
Some(UrlHost::Domain(domain)) => decode(domain)?,
Some(UrlHost::Ipv4(address)) => address.to_string(),
Some(UrlHost::Ipv6(address)) => address.to_string(),
None => String::new(),
};
if address.is_empty() {
return Err("链接里没有地址。".into());
}
let port = match url.port() {
Some(0) => return Err("链接里的端口无效。".into()),
Some(port) => port,
None => SSH_PORT,
};
let user = decode(url.username())?;
if user.is_empty() {
return Err(format!(
"链接里没有用户名,应为 {}://用户@地址[:端口]。",
url.scheme()
));
}
let password = match url.password() {
Some(password) => Some(Zeroizing::new(decode(password)?)),
None => None,
}
.filter(|password| !password.is_empty());
let name = tab
.map(str::trim)
.filter(|tab| !tab.is_empty())
.map_or_else(|| address.clone(), str::to_string);
Ok(Self {
kind,
name,
address,
port,
user,
password,
})
}
/// The host to connect to. It logs in by password: the link's, or else
/// asked for when the server wants one.
pub fn draft(&self) -> HostDraft {
HostDraft::new(
self.name.clone(),
self.address.clone(),
self.port,
self.user.clone(),
AuthKind::Password,
None,
)
}
}
impl fmt::Debug for SshLink {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SshLink")
.field("kind", &self.kind)
.field("name", &self.name)
.field("address", &self.address)
.field("port", &self.port)
.field("user", &self.user)
.field("password", &self.password.as_ref().map(|_| "***"))
.finish()
}
}
/// The text a link percent-encodes: `p%40ss` is `p@ss`.
fn decode(text: &str) -> Result<String, String> {
percent_decode_str(text)
.decode_utf8()
.map(|text| text.into_owned())
.map_err(|_| "链接里有无法识别的字符。".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(link: &str) -> SshLink {
SshLink::parse(link, None).unwrap()
}
#[test]
fn a_bastion_token_logs_in_as_the_user() {
let link = parse("ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024");
assert_eq!(link.user, "b478e26f-811b-4a90-81c3-74929127898a");
assert_eq!(link.address, "172.16.0.28");
assert_eq!(link.port, 12024);
assert!(link.password.is_none());
assert_eq!(link.name, "172.16.0.28");
assert_eq!(link.kind, LinkKind::Ssh);
let draft = link.draft();
assert_eq!(draft.address.as_ref(), "172.16.0.28");
assert_eq!(draft.user.as_ref(), "b478e26f-811b-4a90-81c3-74929127898a");
assert_eq!(draft.port, 12024);
assert_eq!(draft.auth, AuthKind::Password);
assert_eq!(draft.group, None);
}
#[test]
fn the_tab_name_and_an_encoded_password_come_through() {
let link = SshLink::parse(
"SSH://deploy:p%40ss%3Aword@db.example.com",
Some(" 生产库 "),
)
.unwrap();
assert_eq!(link.name, "生产库");
assert_eq!(link.user, "deploy");
assert_eq!(link.address, "db.example.com");
assert_eq!(link.port, 22);
assert_eq!(
link.password.as_deref().map(String::as_str),
Some("p@ss:word")
);
// An empty password is none.
assert!(parse("ssh://deploy:@db.example.com").password.is_none());
// An empty tab name is no name.
assert_eq!(
SshLink::parse("ssh://deploy@db", Some(" ")).unwrap().name,
"db"
);
}
#[test]
fn an_sftp_link_is_read_alike_and_opens_sftp() {
// How JumpServer opens WinSCP.
let link = parse("sftp://b478e26f:p%40ss@172.16.0.28:12024");
assert_eq!(link.kind, LinkKind::Sftp);
assert_eq!(link.user, "b478e26f");
assert_eq!(link.address, "172.16.0.28");
assert_eq!(link.port, 12024);
assert_eq!(link.password.as_deref().map(String::as_str), Some("p@ss"));
assert_eq!(link.draft().auth, AuthKind::Password);
// The port is SSH's when not given, and a path is passed over.
let link = parse("SFTP://root@files.example/srv/data/");
assert_eq!(link.kind, LinkKind::Sftp);
assert_eq!((link.address.as_str(), link.port), ("files.example", 22));
}
#[test]
fn the_user_ends_at_the_last_at_sign() {
// How JumpServer names the account and the asset in one user name.
let link = parse("ssh://admin@root@10.1.1.1@jms.example.com:2222");
assert_eq!(link.user, "admin@root@10.1.1.1");
assert_eq!(link.address, "jms.example.com");
assert_eq!(link.port, 2222);
}
#[test]
fn an_ipv6_address_loses_its_brackets() {
let link = parse("ssh://root@[::1]:2200/");
assert_eq!(link.address, "::1");
assert_eq!(link.port, 2200);
assert_eq!(link.name, "::1");
}
#[test]
fn a_non_ascii_domain_is_decoded() {
assert_eq!(parse("ssh://root@测试.example").address, "测试.example");
}
#[test]
fn what_is_wrong_is_said_without_repeating_the_link() {
for (link, expected) in [
("", "没有要连接的地址"),
(" ", "没有要连接的地址"),
("scp://root:secret@h", "只支持 ssh:// 和 sftp://"),
("ftp://root:secret@h", "只支持 ssh:// 和 sftp://"),
("ssh://h:22", "没有用户名"),
("sftp://h:22", "应为 sftp://用户@地址"),
("ssh://:secret@h", "没有用户名"),
("ssh://root:secret@", "没有地址"),
("ssh://root:secret@h:0", "端口无效"),
("ssh://root:secret@h:65536", "端口无效"),
("ssh://root:secret@h:port", "端口无效"),
("root:secret@h", "格式不对"),
] {
let error = SshLink::parse(link, None).unwrap_err();
assert!(error.contains(expected), "{link:?}: {error}");
assert!(!error.contains("secret"), "{link:?}: {error}");
}
}
#[test]
fn debug_never_shows_the_password() {
let link = parse("ssh://root:hunter2@h");
let shown = format!("{link:?}");
assert!(!shown.contains("hunter2"), "{shown}");
assert!(shown.contains("***"), "{shown}");
}
}
+362 -35
View File
@@ -1,11 +1,12 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gpui_kit::{Context, EventEmitter, SharedString};
use zeroize::Zeroizing;
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore};
use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore};
use super::database::now_seconds;
use super::private_key::{is_kept_in, write_key_file};
@@ -13,7 +14,7 @@ use super::{
AuthKind, BookmarkSide, ConnectionState, Credential, CredentialDraft, CredentialId,
CredentialKind, ForwardDraft, ForwardId, ForwardRule, GroupDraft, GroupId, Host, HostDatabase,
HostDraft, HostGroup, HostId, HostLogin, HostNode, HostOs, JumpLogin, LoginRoute, NodeDrop,
ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, StoredData,
ProxyLogin, PublicId, Route, Snippet, SnippetCategory, SnippetScope, SshLink, StoredData,
};
mod snippets;
@@ -42,9 +43,23 @@ mod snippets;
/// Passwords and private-key passphrases never reach the database. They go to
/// the system keychain through `secrets`, on a background thread, and report
/// failures through the same event.
///
/// 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. 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.
pub struct HostStore {
groups: Vec<HostGroup>,
hosts: Vec<Host>,
/// Hosts connected to without saving them: not listed. Their ids come
/// from `next_host_id` like a saved host's, so they never meet.
temporary: Vec<Host>,
/// The temporary hosts a link from outside opened (外部连接).
external: HashSet<HostId>,
next_host_id: u64,
next_group_id: u64,
active: Option<HostId>,
@@ -65,9 +80,10 @@ pub struct HostStore {
next_snippet_category_id: u64,
/// `None` for a memory-only store, as used by tests.
database: Option<HostDatabase>,
/// Where passwords go. Defaults to a store that keeps nothing, so unit
/// tests never touch the machine's keychain.
secrets: SharedSecretStore,
/// Where passwords go: the keychain given to `with_secrets`, under a
/// layer that keeps the links' passwords in memory. Defaults to a store
/// that keeps nothing, so unit tests never touch the machine's keychain.
secrets: Arc<TemporarySecretStore>,
/// Where the private keys pasted into or generated by the credential
/// form are kept. `None` without a database to remember them, which is
/// also how most tests run: the form then offers key files only.
@@ -99,6 +115,8 @@ impl HostStore {
Self {
groups: Vec::new(),
hosts: Vec::new(),
temporary: Vec::new(),
external: HashSet::new(),
next_host_id: 1,
next_group_id: 1,
active: None,
@@ -113,7 +131,7 @@ impl HostStore {
snippet_categories: Vec::new(),
next_snippet_category_id: 1,
database: None,
secrets: Arc::new(NoSecretStore),
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
}
}
@@ -121,7 +139,7 @@ impl HostStore {
/// Attach the system keychain. `main` does this once at startup; the UI
/// tests attach an in-memory store instead.
pub fn with_secrets(mut self, secrets: SharedSecretStore) -> Self {
self.secrets = secrets;
self.secrets = Arc::new(TemporarySecretStore::new(secrets));
self
}
@@ -176,11 +194,13 @@ impl HostStore {
snippet_categories,
groups,
hosts,
temporary: Vec::new(),
external: HashSet::new(),
active: None,
recent,
bookmarks,
database: Some(database),
secrets: Arc::new(NoSecretStore),
secrets: Arc::new(TemporarySecretStore::new(Arc::new(NoSecretStore))),
key_dir: None,
})
}
@@ -258,12 +278,117 @@ impl HostStore {
&self.groups
}
/// The saved hosts. Never the temporary ones.
pub fn hosts(&self) -> &[Host] {
&self.hosts
}
/// A saved host, or a temporary one.
pub fn host(&self, id: HostId) -> Option<&Host> {
self.hosts.iter().find(|s| s.id == id)
self.saved_host(id)
.or_else(|| self.temporary.iter().find(|host| host.id == id))
}
/// A saved host only: what the database's foreign keys may point at.
fn saved_host(&self, id: HostId) -> Option<&Host> {
self.hosts.iter().find(|host| host.id == id)
}
fn host_mut(&mut self, id: HostId) -> Option<&mut Host> {
self.hosts
.iter_mut()
.chain(self.temporary.iter_mut())
.find(|host| host.id == id)
}
/// 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
}
/// Whether `id` is connected to without being saved.
pub fn is_temporary(&self, id: HostId) -> bool {
self.temporary.iter().any(|host| host.id == id)
}
/// Whether `id` is a temporary host a link from outside opened (外部连接),
/// whose terminals get their own channel alone.
pub fn is_external(&self, id: HostId) -> bool {
self.external.contains(&id)
}
/// Connect to what an `ssh://` or `sftp://` link from outside names
/// without saving it (外部连接): a temporary host the tabs find by id,
/// kept in memory until [`Self::remove_temporary`], with the link's
/// password in memory too.
pub fn insert_external(&mut self, link: SshLink, cx: &mut Context<Self>) -> HostId {
let id = self.insert_external_unnotified(link);
cx.notify();
id
}
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, draft);
// What the external CLI knows it by while it lasts.
host.public_id = self.unused_public_id();
self.temporary.push(host);
if let Some(password) = password.filter(|password| !password.is_empty()) {
self.secrets.remember(id.0, password);
}
id
}
/// Forget a temporary host, with its password and the bookmarks its SFTP
/// tabs made. Its tabs are gone by now.
pub fn remove_temporary(&mut self, id: HostId, cx: &mut Context<Self>) {
if self.remove_temporary_unnotified(id) {
cx.notify();
}
}
pub fn remove_temporary_unnotified(&mut self, id: HostId) -> bool {
let Some(index) = self.temporary.iter().position(|host| host.id == id) else {
return false;
};
self.temporary.remove(index);
self.external.remove(&id);
self.secrets.forget(id.0);
self.bookmarks.retain(|(host, _), _| *host != id);
if self.active == Some(id) {
self.active = None;
}
true
}
pub fn group(&self, id: GroupId) -> Option<&HostGroup> {
@@ -368,7 +493,12 @@ impl HostStore {
/// A [`PublicId`] no host in the store has yet.
fn unused_public_id(&self) -> PublicId {
PublicId::generate_unused(|id| self.hosts.iter().any(|s| &s.public_id == id))
PublicId::generate_unused(|id| {
self.hosts
.iter()
.chain(&self.temporary)
.any(|host| &host.public_id == id)
})
}
/// Replace the editable fields of a host; connection state is kept.
@@ -572,7 +702,11 @@ impl HostStore {
if !self.add_bookmark_unnotified(id, side, path) {
return false;
}
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
// A link's host is not in the database: its bookmarks live as long
// as it does.
if !self.is_temporary(id) {
self.persist("添加书签", cx, |db| db.insert_bookmark(id, side, path));
}
cx.notify();
true
}
@@ -599,7 +733,9 @@ impl HostStore {
if !self.remove_bookmark_unnotified(id, side, path) {
return false;
}
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
if !self.is_temporary(id) {
self.persist("删除书签", cx, |db| db.remove_bookmark(id, side, path));
}
cx.notify();
true
}
@@ -631,9 +767,11 @@ impl HostStore {
if !self.move_bookmark_unnotified(id, side, path, to) {
return false;
}
self.persist("调整书签顺序", cx, |db| {
db.set_bookmark_order(id, side, self.bookmarks(id, side))
});
if !self.is_temporary(id) {
self.persist("调整书签顺序", cx, |db| {
db.set_bookmark_order(id, side, self.bookmarks(id, side))
});
}
cx.notify();
true
}
@@ -690,7 +828,7 @@ impl HostStore {
}
pub fn insert_forward_unnotified(&mut self, draft: ForwardDraft) -> Option<ForwardId> {
self.host(draft.host)?;
self.saved_host(draft.host)?;
let sort_order = self
.forwards
.iter()
@@ -730,7 +868,7 @@ impl HostStore {
}
pub fn update_forward_unnotified(&mut self, id: ForwardId, draft: ForwardDraft) -> bool {
if self.host(draft.host).is_none() {
if self.saved_host(draft.host).is_none() {
return false;
}
let Some(rule) = self.forwards.iter_mut().find(|rule| rule.id == id) else {
@@ -782,8 +920,18 @@ impl HostStore {
}
pub fn login_of(&self, host: &Host) -> HostLogin {
self.direct_login(host)
.with_route(self.route_login(&host.route))
let mut login = self
.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` (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);
login
}
/// How `host` logs in once the connection has reached it.
@@ -798,7 +946,7 @@ impl HostStore {
Route::Direct => LoginRoute::Direct,
Route::Jump(hops) => LoginRoute::Jump(
hops.iter()
.map(|hop| match hop.and_then(|id| self.host(id)) {
.map(|hop| match hop.and_then(|id| self.saved_host(id)) {
Some(host) => JumpLogin::Host {
name: host.name.to_string(),
login: Box::new(self.direct_login(host)),
@@ -822,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()
}
@@ -935,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();
}
@@ -973,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;
@@ -1082,7 +1234,7 @@ impl HostStore {
}
}
HostNode::Host(id) => {
if self.host(id).is_none() {
if self.saved_host(id).is_none() {
return false;
}
let target = match target {
@@ -1115,7 +1267,7 @@ impl HostStore {
fn node_parent(&self, node: HostNode) -> Option<Option<GroupId>> {
match node {
HostNode::Group(id) => self.group(id).map(|g| g.parent),
HostNode::Host(id) => self.host(id).map(|s| s.group),
HostNode::Host(id) => self.saved_host(id).map(|s| s.group),
}
}
@@ -1160,7 +1312,7 @@ impl HostStore {
if !self.set_state_unnotified(id, state) {
return;
}
if state.is_connected() {
if state.is_connected() && !self.is_temporary(id) {
self.persist("记录最近连接", cx, |db| {
db.touch_connected(id, now_seconds())
});
@@ -1168,17 +1320,19 @@ impl HostStore {
cx.notify();
}
/// Returns whether the state changed. Becoming connected also moves the
/// host to the front of the recent list.
/// Returns whether the state changed. Becoming connected also moves a
/// saved host to the front of the recent list; a link's host never
/// joins it.
pub fn set_state_unnotified(&mut self, id: HostId, state: ConnectionState) -> bool {
let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else {
let temporary = self.is_temporary(id);
let Some(host) = self.host_mut(id) else {
return false;
};
if host.state == state {
return false;
}
host.state = state;
if state.is_connected() {
if state.is_connected() && !temporary {
self.recent.retain(|recent| *recent != id);
self.recent.insert(0, id);
self.recent.truncate(MAX_RECENT);
@@ -1192,13 +1346,15 @@ impl HostStore {
if !self.set_host_os_unnotified(id, os) {
return;
}
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
if !self.is_temporary(id) {
self.persist("记录主机系统", cx, |db| db.set_host_os(id, os));
}
cx.notify();
}
/// Returns whether the recorded system changed.
pub fn set_host_os_unnotified(&mut self, id: HostId, os: Option<HostOs>) -> bool {
let Some(host) = self.hosts.iter_mut().find(|s| s.id == id) else {
let Some(host) = self.host_mut(id) else {
return false;
};
if host.os == os {
@@ -1300,7 +1456,7 @@ impl HostStore {
value: Option<String>,
cx: &mut Context<Self>,
) {
let secrets = self.secrets.clone();
let secrets = self.secrets();
let failure = match (&secret, value.is_some()) {
(SecretRef::Password { .. }, true) => "密码未能写入系统钥匙串",
(SecretRef::Password { .. }, false) => "密码未能从系统钥匙串删除",
@@ -1310,6 +1466,8 @@ impl HostStore {
(SecretRef::Credential { .. }, false) => "凭据的密码未能从系统钥匙串删除",
(SecretRef::Proxy { .. }, true) => "代理的密码未能写入系统钥匙串",
(SecretRef::Proxy { .. }, false) => "代理的密码未能从系统钥匙串删除",
// Kept in memory, which does not fail.
(SecretRef::Temporary { .. }, _) => "这次连接的密码未能记下",
};
cx.spawn(async move |this, cx| {
let result = cx
@@ -1460,7 +1618,7 @@ impl HostStore {
let hops: Vec<_> = hops
.into_iter()
.filter(|hop| hop.is_none() || *hop != own)
.map(|hop| hop.filter(|id| self.host(*id).is_some()))
.map(|hop| hop.filter(|id| self.saved_host(*id).is_some()))
.collect();
if hops.is_empty() {
Route::Direct
@@ -2453,4 +2611,173 @@ mod tests {
);
assert_eq!(store.insert_unnotified(draft("new", None)), HostId(10));
}
fn link(text: &str) -> SshLink {
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());
let mut store = HostStore::empty().with_secrets(keychain.clone());
let saved = store.insert_unnotified(draft("web", None));
let id = store.insert_external_unnotified(link("ssh://token:p%40ss@10.0.0.9:2222"));
assert_ne!(id, saved);
assert!(store.is_temporary(id));
assert!(store.is_external(id));
assert!(!store.is_temporary(saved));
assert!(!store.is_external(saved));
assert_eq!(store.hosts().len(), 1);
assert_eq!(store.host(id).unwrap().name.as_ref(), "堡垒机");
// It logs in with the link's password, which only memory holds.
let login = store.login(id).unwrap();
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "token")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(id.0));
assert!(login.shell_only);
assert!(!store.login(saved).unwrap().shell_only);
assert_eq!(
store
.secrets()
.get(&login.password)
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
assert!(keychain.is_empty());
// Connecting is not remembered as a recent connection.
assert!(store.set_state_unnotified(id, ConnectionState::Connected));
assert_eq!(store.host(id).unwrap().state, ConnectionState::Connected);
assert!(store.recent_hosts().next().is_none());
assert!(store.set_host_os_unnotified(id, Some(HostOs::Ubuntu)));
assert!(store.add_bookmark_unnotified(id, BookmarkSide::Remote, "/tmp"));
store.active = Some(id);
assert!(store.remove_temporary_unnotified(id));
assert!(!store.remove_temporary_unnotified(id));
assert!(store.host(id).is_none());
assert!(!store.is_external(id));
assert!(store.active().is_none());
assert!(store.bookmarks(id, BookmarkSide::Remote).is_empty());
assert!(store.secrets().get(&login.password).unwrap().is_none());
// Only memory gave it an id: the next one still moves on.
assert!(store.insert_unnotified(draft("db", None)).0 > id.0);
}
#[test]
fn a_saved_host_cannot_lean_on_a_link_host() {
let mut store = HostStore::empty();
let id = store.insert_external_unnotified(link("ssh://token@10.0.0.9"));
let behind = store.insert_unnotified(
HostDraft::new("db", "10.0.0.8", 22, "root", AuthKind::Password, None)
.with_route(jump_through(&[id])),
);
// Saved as a jump host that is not there, like a deleted one.
assert_eq!(store.host(behind).unwrap().route, Route::Jump(vec![None]));
assert!(
store
.insert_forward_unnotified(forward_draft(id, 8080))
.is_none()
);
// Nor is it moved into the tree.
assert!(!store.move_node_unnotified(HostNode::Host(id), NodeDrop::Root));
}
#[gpui_kit::test]
fn a_link_host_never_reaches_the_database(cx: &mut gpui_kit::TestAppContext) {
use gpui_kit::AppContext as _;
use std::{cell::RefCell, rc::Rc};
let data = tempfile::tempdir().unwrap();
let path = data.path().join("shellrs.db");
let store = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
let store = cx.new(|_| store);
let events = Rc::new(RefCell::new(Vec::new()));
let _subscription = cx.update(|cx| {
let events = events.clone();
cx.subscribe(&store, move |_, event: &HostStoreEvent, _| {
events.borrow_mut().push(event.clone());
})
});
let id = store.update(cx, |store, cx| {
let id = store.insert_external(link("ssh://token@10.0.0.9"), cx);
store.set_state(id, ConnectionState::Connected, cx);
store.set_host_os(id, Some(HostOs::Debian), cx);
store.add_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
store.add_bookmark(id, BookmarkSide::Remote, "/tmp", cx);
store.move_bookmark(id, BookmarkSide::Remote, "/tmp", 0, cx);
store.remove_bookmark(id, BookmarkSide::Remote, "/var/log", cx);
id
});
cx.run_until_parked();
assert!(events.borrow().is_empty(), "{:?}", events.borrow());
assert!(store.read_with(cx, |store, _| store.host(id).is_some()));
let reopened = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap();
assert!(reopened.hosts().is_empty());
assert!(reopened.host(id).is_none());
assert!(reopened.recent_hosts().next().is_none());
assert!(reopened.bookmarks(id, BookmarkSide::Remote).is_empty());
}
}
+1 -1
View File
@@ -195,7 +195,7 @@ impl HostStore {
let gone = match draft.scope {
SnippetScope::All => false,
SnippetScope::Group(group) => self.group(group).is_none(),
SnippetScope::Host(host) => self.host(host).is_none(),
SnippetScope::Host(host) => self.saved_host(host).is_none(),
};
if gone {
draft.scope = SnippetScope::All;
+12 -3
View File
@@ -9,17 +9,23 @@ use shellrs::host::{HostDatabase, HostStore};
use shellrs::settings::SettingsStore;
fn main() {
let args = shellrs::cli::command_line_arguments().unwrap_or_default();
// `ShellRS ssh://user@host:port` or `ShellRS -url … -newtab 名称`: a
// bastion host opening ShellRS the way it opens Xshell. On every build,
// a Windows release build above all, where bastion hosts run it.
let link = shellrs::cli::link_arguments(&args);
// `shellrs list`, `shellrs exec …`: the command, not the app. Not in a
// Windows release build, which has nowhere to print: there the command
// is `shellrs-cli.exe`, which 设置 → 外部 CLI puts on the PATH.
#[cfg(any(not(windows), debug_assertions))]
if let Some(args) = shellrs::cli::command_line_arguments() {
if link.is_none() && !args.is_empty() {
std::process::exit(shellrs::cli::main(args));
}
// One ShellRS per data directory. Each keeps the hosts in memory and
// writes its changes through, so a second one would write over the
// first one's; opened again, ShellRS brings the running one forward.
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) {
// first one's; opened again, ShellRS brings the running one forward
// and hands it the link.
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path(), link.clone()) {
return;
}
let app = gpui_kit::application().with_assets(shellrs::app::AppAssets);
@@ -53,6 +59,9 @@ fn main() {
for problem in [problem, settings_problem].into_iter().flatten() {
shellrs::workspace::notify_once_open(Notification::error(problem), window, cx);
}
if let Some(link) = link {
shellrs::workspace::open_link_once_open(&workspace, link, window, cx);
}
cx.new(|cx| Root::new(workspace, window, cx))
})
.expect("failed to open window");
+11
View File
@@ -5,6 +5,7 @@
mod keychain;
mod memory;
mod temporary;
use std::path::Path;
use std::sync::Arc;
@@ -14,6 +15,7 @@ use zeroize::Zeroizing;
pub use keychain::KeychainSecretStore;
pub use memory::{InMemorySecretStore, NoSecretStore};
pub use temporary::TemporarySecretStore;
/// 钥匙串里的服务名,所有条目共用。改了会让已保存的秘密全部失联。
pub const SERVICE: &str = "shellrs";
@@ -42,6 +44,9 @@ pub enum SecretRef {
host: String,
port: u16,
},
/// 不保存的连接(外部连接,以后还有临时连接)的密码,按这台临时主机的 id
/// 归属。只在 [`TemporarySecretStore`] 的内存里,从不进钥匙串。
Temporary { host: u64 },
}
impl SecretRef {
@@ -73,6 +78,10 @@ impl SecretRef {
}
}
pub fn temporary(host: u64) -> Self {
Self::Temporary { host }
}
/// 钥匙串条目的账户名。前缀区分种类,在 Keychain Access 里直接可读。
pub fn account(&self) -> String {
match self {
@@ -80,6 +89,7 @@ impl SecretRef {
Self::Passphrase { key_path } => format!("passphrase:{key_path}"),
Self::Credential { keychain_id } => format!("credential:{keychain_id}"),
Self::Proxy { user, host, port } => format!("proxy:{user}@{host}:{port}"),
Self::Temporary { host } => format!("temporary:{host}"),
}
}
}
@@ -146,6 +156,7 @@ mod tests {
SecretRef::passphrase("root@10.0.1.12:22"),
SecretRef::credential("root@10.0.1.12:22"),
SecretRef::proxy("root", "10.0.1.12", 22),
SecretRef::temporary(22),
]
.map(|secret| secret.account());
for (ix, account) in accounts.iter().enumerate() {
+123
View File
@@ -0,0 +1,123 @@
use std::collections::HashMap;
use std::sync::Mutex;
use anyhow::Result;
use zeroize::Zeroizing;
use super::{SecretRef, SecretStore, SharedSecretStore};
/// 钥匙串外面包的一层:不保存的连接(外部连接)的密码([`SecretRef::Temporary`])
/// 只记在这层内存里,其余的原样交给里面的钥匙串。
///
/// `HostStore` 把注入的秘密存储包成这样,连接器读到的也是它,所以终端和 SFTP
/// 用得上链接里的密码,而这个密码不会落进钥匙串;没有钥匙串的机器上照样能用。
pub struct TemporarySecretStore {
keychain: SharedSecretStore,
passwords: Mutex<HashMap<u64, Zeroizing<String>>>,
}
impl TemporarySecretStore {
pub fn new(keychain: SharedSecretStore) -> Self {
Self {
keychain,
passwords: Mutex::new(HashMap::new()),
}
}
fn passwords(&self) -> std::sync::MutexGuard<'_, HashMap<u64, Zeroizing<String>>> {
self.passwords
.lock()
.unwrap_or_else(|error| error.into_inner())
}
/// 记下临时主机 `host` 的密码。只碰内存,可以在 UI 线程上调用。
pub fn remember(&self, host: u64, password: Zeroizing<String>) {
self.passwords().insert(host, password);
}
/// 忘掉临时主机 `host` 的密码。只碰内存。
pub fn forget(&self, host: u64) {
self.passwords().remove(&host);
}
}
impl SecretStore for TemporarySecretStore {
fn get(&self, secret: &SecretRef) -> Result<Option<Zeroizing<String>>> {
match secret {
SecretRef::Temporary { host } => Ok(self.passwords().get(host).cloned()),
_ => self.keychain.get(secret),
}
}
fn set(&self, secret: &SecretRef, value: &str) -> Result<()> {
match secret {
SecretRef::Temporary { host } => {
self.remember(*host, Zeroizing::new(value.to_string()));
Ok(())
}
_ => self.keychain.set(secret, value),
}
}
fn delete(&self, secret: &SecretRef) -> Result<()> {
match secret {
SecretRef::Temporary { host } => {
self.forget(*host);
Ok(())
}
_ => self.keychain.delete(secret),
}
}
fn is_available(&self) -> bool {
self.keychain.is_available()
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::secrets::{InMemorySecretStore, NoSecretStore};
#[test]
fn link_passwords_stay_in_memory_and_the_rest_reaches_the_keychain() {
let keychain = Arc::new(InMemorySecretStore::default());
let store = TemporarySecretStore::new(keychain.clone());
let link = SecretRef::temporary(7);
store.remember(7, Zeroizing::new("token".into()));
assert_eq!(
store.get(&link).unwrap().as_deref().map(String::as_str),
Some("token")
);
assert!(keychain.is_empty());
let saved = SecretRef::password("root", "10.0.1.12", 22);
store.set(&saved, "hunter2").unwrap();
assert_eq!(keychain.len(), 1);
assert_eq!(
store.get(&saved).unwrap().as_deref().map(String::as_str),
Some("hunter2")
);
// Another host's password is not the link's.
assert!(store.get(&SecretRef::temporary(8)).unwrap().is_none());
store.forget(7);
assert!(store.get(&link).unwrap().is_none());
assert_eq!(keychain.len(), 1);
}
#[test]
fn without_a_keychain_link_passwords_still_work() {
let store = TemporarySecretStore::new(Arc::new(NoSecretStore));
assert!(!store.is_available());
store.set(&SecretRef::temporary(1), "token").unwrap();
assert!(store.get(&SecretRef::temporary(1)).unwrap().is_some());
assert!(
store
.set(&SecretRef::password("root", "h", 22), "x")
.is_err()
);
}
}
+5 -2
View File
@@ -627,7 +627,10 @@ fn skill_actions_item(installable: bool) -> SettingItem {
/// One row per agent, each with where its skill goes and whether it is
/// there.
fn skills_item(files: Option<[String; 4]>, status: Option<IntegrationStatus>) -> SettingItem {
fn skills_item(
files: Option<[String; AgentKind::ALL.len()]>,
status: Option<IntegrationStatus>,
) -> SettingItem {
SettingItem::render(move |_, _, cx| {
v_flex()
.w_full()
@@ -641,7 +644,7 @@ fn skills_item(files: Option<[String; 4]>, status: Option<IntegrationStatus>) ->
.child(skill_row(*agent, file, skill, cx))
}))
})
.keywords(["skill", "Agent", "Codex", "Claude", "OpenCode"])
.keywords(["skill", "Agent", "Codex", "Claude", "OpenCode", "WorkBuddy"])
}
fn skill_row(
+70 -11
View File
@@ -2,8 +2,8 @@
use super::*;
use crate::{
connection::{ConnectionPromptKind, ConnectionPromptReply, ConnectionSecret, Latency},
host::{AuthKind, Host, HostDraft, HostId, HostLogin},
secrets::InMemorySecretStore,
host::{AuthKind, Host, HostDraft, HostId, HostLogin, HostStore, SshLink},
secrets::{InMemorySecretStore, SharedSecretStore},
ssh::SshConnector,
};
use russh::{
@@ -61,8 +61,19 @@ impl Drop for Handler {
self.ended.fetch_add(1, Ordering::SeqCst);
}
}
/// Who the server lets in without asking anything: a bastion host's token,
/// which is the user name alone.
const TOKEN_USER: &str = "b478e26f-811b-4a90-81c3-74929127898a";
impl server::Handler for Handler {
type Error = anyhow::Error;
async fn auth_none(&mut self, user: &str) -> Result<server::Auth, Self::Error> {
Ok(if user == TOKEN_USER {
server::Auth::Accept
} else {
server::Auth::reject()
})
}
async fn auth_password(
&mut self,
user: &str,
@@ -244,14 +255,6 @@ impl Drop for Worker {
}
}
fn worker(port: u16, data: &std::path::Path) -> Worker {
let provider = SshSftpTransportProvider::new(
SshConnector::new(
data.join("known_hosts"),
Arc::new(InMemorySecretStore::default()),
),
data.join("upload-resume"),
data.join("download-resume"),
);
let host = Host::new(
HostId(1),
HostDraft::new(
@@ -263,7 +266,19 @@ fn worker(port: u16, data: &std::path::Path) -> Worker {
None,
),
);
let transport = provider.create(&HostLogin::of(&host, None));
worker_for(
&HostLogin::of(&host, None),
Arc::new(InMemorySecretStore::default()),
data,
)
}
fn worker_for(login: &HostLogin, secrets: SharedSecretStore, data: &std::path::Path) -> Worker {
let provider = SshSftpTransportProvider::new(
SshConnector::new(data.join("known_hosts"), secrets),
data.join("upload-resume"),
data.join("download-resume"),
);
let transport = provider.create(login);
let (commands, rx) = async_channel::unbounded();
let (tx, events) = async_channel::unbounded();
let thread = std::thread::spawn(move || transport.run(rx, tx).unwrap());
@@ -438,6 +453,50 @@ fn a_connection_reports_its_round_trip_right_after_connecting() {
});
}
/// A bastion host lets WinSCP, and so ShellRS, in on its token alone: an
/// `sftp://token@…` link without a password connects without asking for
/// one, as Xshell's `ssh://` does.
#[test]
fn a_link_without_a_password_connects_where_the_server_asks_for_none() {
runtime().block_on(async {
let temp = tempfile::tempdir().unwrap();
let Some(server) = server(temp.path().into(), 0, false).await else {
return;
};
let mut store = HostStore::empty();
let link = format!("sftp://{TOKEN_USER}@127.0.0.1:{}", server.port);
let host = store.insert_external_unnotified(SshLink::parse(&link, None).unwrap());
let worker = worker_for(&store.login(host).unwrap(), store.secrets(), temp.path());
loop {
let event = tokio::time::timeout(Duration::from_secs(25), worker.events.recv())
.await
.expect("worker timed out")
.unwrap();
match event {
SftpEvent::Prompt(prompt) => {
assert!(
matches!(prompt.kind(), ConnectionPromptKind::UnknownHost(_)),
"asked for more than trusting the host key"
);
worker
.commands
.send(SftpCommand::PromptReply {
request_id: prompt.request_id(),
reply: ConnectionPromptReply::TrustAndSave,
})
.await
.unwrap();
}
SftpEvent::Connected { .. } => break,
SftpEvent::Disconnected(error) => panic!("connect failed: {error}"),
_ => {}
}
}
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
assert_eq!(server.shells.load(Ordering::SeqCst), 0);
});
}
#[test]
fn unsupported_sftp_does_not_launch_shell_or_loop_authentication() {
runtime().block_on(async {
+3 -3
View File
@@ -13,7 +13,7 @@ use super::{
};
use crate::{
host::HostLogin,
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts},
ssh::{LATENCY_INTERVAL, SshConnectionConfig, SshConnector, SshPrompts, describe_login_error},
};
use anyhow::{Result, anyhow};
use async_channel::{Receiver, Sender};
@@ -261,7 +261,7 @@ impl SshSftpTransport {
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
let _ = events
.send(SftpEvent::Disconnected(error.to_string()))
.send(SftpEvent::Disconnected(describe_login_error(&error)))
.await;
}
}
@@ -302,7 +302,7 @@ impl SshSftpTransport {
Ok(connected) => *client.write().await = Some(connected),
Err(error) => {
events
.send(SftpEvent::Disconnected(error.to_string()))
.send(SftpEvent::Disconnected(describe_login_error(&error)))
.await?;
events.send(SftpEvent::Idle).await?;
busy.store(false, Ordering::Release);
+61 -1
View File
@@ -57,6 +57,13 @@ impl From<&HostLogin> for SshConnectionConfig {
}
}
impl SshConnectionConfig {
/// See [`HostLogin::shell_only`].
pub(crate) fn shell_only(&self) -> bool {
self.login.shell_only
}
}
/// Where the SSH agent listens.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum AgentLocation {
@@ -314,11 +321,50 @@ trait TunnelStream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> TunnelStream for T {}
/// The key exchanges ShellRS offers, in OpenSSH's order: russh's own list
/// leaves out the NIST curves, which servers without curve25519 (Apache
/// SSHD, as many bastion hosts run) settle on with OpenSSH.
const KEX_ORDER: &[russh::kex::Name] = &[
russh::kex::MLKEM768X25519_SHA256,
russh::kex::CURVE25519,
russh::kex::CURVE25519_PRE_RFC_8731,
russh::kex::ECDH_SHA2_NISTP256,
russh::kex::ECDH_SHA2_NISTP384,
russh::kex::ECDH_SHA2_NISTP521,
russh::kex::DH_GEX_SHA256,
russh::kex::DH_G16_SHA512,
russh::kex::DH_G18_SHA512,
russh::kex::DH_G17_SHA512,
russh::kex::DH_G15_SHA512,
russh::kex::DH_G14_SHA256,
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
russh::kex::EXTENSION_SUPPORT_AS_SERVER,
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
];
/// The smallest group a group exchange accepts, as OpenSSH's: russh asks
/// for 3072 bits, which Apache SSHD on an older Java cannot give (its
/// largest is 2048) and so ends the connection.
const MIN_DH_GROUP_BITS: usize = 2048;
/// Connects wherever OpenSSH with its default settings does.
fn ssh_config() -> Arc<client::Config> {
let defaults = client::GexParams::default();
Arc::new(client::Config {
keepalive_interval: Some(KEEPALIVE_INTERVAL),
keepalive_max: 3,
nodelay: true,
preferred: russh::Preferred {
kex: KEX_ORDER.into(),
..russh::Preferred::default()
},
gex: client::GexParams::new(
MIN_DH_GROUP_BITS,
defaults.preferred_group_size(),
defaults.max_group_size(),
)
.expect("2048 bits is the smallest group russh allows"),
..Default::default()
})
}
@@ -460,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>,
@@ -856,7 +916,7 @@ async fn authenticate(
if methods.contains(&MethodKind::Password)
|| methods.contains(&MethodKind::KeyboardInteractive)
{
bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」");
return Err(PasswordWanted.into());
}
bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥");
}
+68
View File
@@ -1,6 +1,7 @@
//! Loopback-only tests of reaching a host through jump hosts and proxies,
//! against in-process SSH servers and proxies. No user hosts, no keychain.
use std::{
borrow::Cow,
path::Path,
sync::{Arc, Mutex, OnceLock, Weak},
time::Duration,
@@ -49,6 +50,9 @@ struct Accepting {
user: &'static str,
password: &'static str,
state: Arc<Mutex<RouteState>>,
/// Groups for group exchange no larger than 2048 bits, as Apache SSHD
/// has on an older Java: asked for larger, it has none to give.
small_groups: bool,
}
struct RouteHandler {
@@ -73,6 +77,25 @@ impl server::Server for Accepting {
impl server::Handler for RouteHandler {
type Error = anyhow::Error;
async fn lookup_dh_gex_group(
&mut self,
asked: &russh::client::GexParams,
) -> Result<Option<russh::kex::dh::groups::DhGroup>> {
use russh::kex::dh::groups::{DH_GROUP14, DH_GROUP16};
let groups: &[_] = if self.accepting.small_groups {
&[DH_GROUP14]
} else {
&[DH_GROUP14, DH_GROUP16]
};
Ok(groups
.iter()
.rev()
.find(|group| {
(asked.min_group_size()..=asked.max_group_size()).contains(&group.bit_size())
})
.cloned())
}
async fn auth_password(&mut self, user: &str, password: &str) -> Result<server::Auth> {
Ok(
if user == self.accepting.user && password == self.accepting.password {
@@ -134,6 +157,15 @@ fn listen(runtime: &tokio::runtime::Runtime) -> Option<TcpListener> {
}
fn start_server(user: &'static str, password: &'static str) -> Option<RouteServer> {
start_server_offering(user, password, None)
}
/// A server that only knows the key exchanges `kex`, when given.
fn start_server_offering(
user: &'static str,
password: &'static str,
kex: Option<&'static [russh::kex::Name]>,
) -> Option<RouteServer> {
let runtime = runtime();
let listener = listen(&runtime)?;
let port = listener.local_addr().unwrap().port();
@@ -142,6 +174,7 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
user,
password,
state: state.clone(),
small_groups: kex.is_some(),
};
let mut rng = russh::keys::key::safe_rng();
let key = russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
@@ -150,6 +183,10 @@ fn start_server(user: &'static str, password: &'static str) -> Option<RouteServe
auth_rejection_time: Duration::ZERO,
auth_rejection_time_initial: Some(Duration::ZERO),
keys: vec![key],
preferred: russh::Preferred {
kex: kex.map_or(russh::Preferred::DEFAULT.kex, Cow::Borrowed),
..russh::Preferred::default()
},
..server::Config::default()
});
runtime.spawn(async move {
@@ -604,6 +641,37 @@ fn a_host_behind_an_http_proxy_is_reached_through_it() {
);
}
/// What Apache SSHD on an older Java offers, as the user's bastion host
/// does: group exchange whose largest group is 2048 bits, or NIST curves.
/// OpenSSH reaches both; ShellRS must too.
#[test]
fn a_server_with_only_2048_bit_groups_or_nist_curves_is_reached() {
const GROUP_EXCHANGE: &[russh::kex::Name] = &[russh::kex::DH_GEX_SHA256];
const NIST_CURVES: &[russh::kex::Name] = &[
russh::kex::ECDH_SHA2_NISTP521,
russh::kex::ECDH_SHA2_NISTP384,
russh::kex::ECDH_SHA2_NISTP256,
];
for kex in [GROUP_EXCHANGE, NIST_CURVES] {
let Some(server) = start_server_offering(TARGET_USER, TARGET_PASSWORD, Some(kex)) else {
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
return;
};
let directory = tempfile::tempdir().unwrap();
let request = LoginTest::typed(manual("127.0.0.1", server.port, TARGET_USER))
.with_password(TARGET_PASSWORD);
assert_eq!(
test_login(
&directory.path().join("known_hosts"),
Arc::new(InMemorySecretStore::default()),
request
),
Ok(()),
"{kex:?}"
);
}
}
/// A loopback port nothing listens on.
fn closed_port() -> u16 {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
+64 -7
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))),
}
}
@@ -156,8 +174,9 @@ fn describe_test_failure(error: &anyhow::Error, host_trust: HostTrust) -> String
}
/// Why a login failed, from the error alone: where on the way to the host
/// it failed, a credential that was missing, the network's own answer, or
/// whatever the error says.
/// it failed, a credential that was missing, the network's own answer, the
/// algorithms the server would not agree on, or whatever the error says,
/// down to its cause: 「无法建立 SSH 连接」 alone does not say what to fix.
pub fn describe_login_error(error: &anyhow::Error) -> String {
if let Some(failure) = error.downcast_ref::<RouteFailure>() {
return failure.to_string();
@@ -173,10 +192,27 @@ pub fn describe_login_error(error: &anyhow::Error) -> String {
// russh wraps the socket's own error rather than chaining it.
Some(russh::Error::IO(io)) => return describe_connect_error(io),
Some(russh::Error::ConnectionTimeout) => return "连接超时".into(),
Some(russh::Error::NoCommonAlgo { kind, theirs, .. }) => {
return format!(
"无法建立 SSH 连接:和服务器没有共同的{}算法,服务器支持的是 {}",
algorithm_kind(kind),
theirs.join("、")
);
}
_ => {}
}
}
error.to_string()
format!("{error:#}")
}
fn algorithm_kind(kind: &russh::AlgorithmKind) -> &'static str {
match kind {
russh::AlgorithmKind::Kex => "密钥交换",
russh::AlgorithmKind::Key => "主机密钥",
russh::AlgorithmKind::Cipher => "加密",
russh::AlgorithmKind::Mac => "消息校验(MAC)",
russh::AlgorithmKind::Compression => "压缩",
}
}
/// A connect error in plain words. The common kinds get an explanation;
@@ -321,5 +357,26 @@ mod tests {
reason(anyhow!("服务器未接受指定的私钥")),
"服务器未接受指定的私钥"
);
// With what lies under it, which says what to fix.
let kex = anyhow::Error::from(russh::Error::Kex)
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
assert_eq!(
reason(kex),
"无法建立 SSH 连接,请检查地址、端口和主机密钥: Key exchange failed"
);
}
#[test]
fn a_server_of_other_algorithms_says_which_it_has() {
let mac = anyhow::Error::from(russh::Error::NoCommonAlgo {
kind: russh::AlgorithmKind::Mac,
ours: vec!["hmac-sha2-256".into()],
theirs: vec!["hmac-sha1".into(), "hmac-md5".into()],
})
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
assert_eq!(
reason(mac),
"无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 hmac-sha1、hmac-md5"
);
}
}
+97 -2
View File
@@ -2,6 +2,7 @@ use super::{
connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts},
latency::{LATENCY_INTERVAL, round_trip},
probe::{HostOsProbe, ProbeOutcome},
tester::describe_login_error,
};
use crate::{
connection::Latency,
@@ -37,6 +38,9 @@ const EXEC_TIMEOUT: Duration = Duration::from_secs(10);
/// dropped.
const EXEC_OUTPUT_LIMIT: usize = 1 << 20;
/// The answer to a command for a terminal that carries nothing beside it.
const SHELL_ONLY: &str = "这个终端经由堡垒机打开,只能使用终端本身,不能另外执行命令";
/// Production remote-terminal adapter for the shared SSH connector.
pub struct SshTerminalTransportProvider {
connector: SshConnector,
@@ -147,7 +151,12 @@ impl SshTerminalTransport {
}
});
let (handle, _) = self.connector.connect(&self.config, broker.clone()).await?;
// Said in the terminal: down to the cause, which says what to fix.
let (handle, _) = self
.connector
.connect(&self.config, broker.clone())
.await
.map_err(|error| anyhow!(describe_login_error(&error)))?;
let mut shutdown = broker.shutdown_receiver();
let mut channel = tokio::select! {
result = handle.channel_open_session() => {
@@ -183,8 +192,15 @@ impl SshTerminalTransport {
// it after `Started` keeps the terminal from waiting on a round trip,
// and a host that refuses or ignores the probe simply keeps whatever
// mark the host already had.
// Not through a bastion host that opened a link: another channel
// ends its session (see `HostLogin::shell_only`).
let shell_only = self.config.shell_only();
let mut probe = HostOsProbe::new();
let mut probe_channel = open_probe(&handle, probe.command()).await;
let mut probe_channel = if shell_only {
None
} else {
open_probe(&handle, probe.command()).await
};
// Round trips are measured with `keepalive@openssh.com`, which every
// server answers (a refusal is an answer too), so this is the SSH-level
@@ -236,6 +252,9 @@ impl SshTerminalTransport {
router.abort();
return Ok(());
}
Some(TerminalTransportCommand::Exec(request)) if shell_only => {
let _ = request.reply.send(Err(SHELL_ONLY.into()));
}
Some(TerminalTransportCommand::Exec(request)) => exec_queue.push_back(request),
Some(TerminalTransportCommand::PromptReply { .. }) => {}
},
@@ -1329,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 {
@@ -1685,6 +1744,42 @@ mod tests {
assert_eq!(execs, ["echo one", "echo two"]);
}
#[test]
fn a_terminal_a_bastion_host_opened_carries_nothing_beside_it() {
let Some(server) = start_server_replying(TestAuth::Password, tool_reply) else {
eprintln!(
"loopback sockets are unavailable in this sandbox; skipping integration body"
);
return;
};
let directory = tempfile::tempdir().unwrap();
let known_hosts = directory.path().join("known_hosts");
let mut login = HostLogin::of(&password_host(server.port), None);
login.shell_only = true;
let report = connect(
login,
&known_hosts,
Arc::new(NoSecretStore),
true,
&["echo one"],
trust_and_type_password,
);
// No probe, and a tool's command answered here without asking the
// server; the round trip is still measured.
assert_eq!(report.execs, vec![Err(SHELL_ONLY.to_string())]);
assert_eq!(report.host_os, None);
assert!(report.latency.is_some());
let execs = server
.state
.lock()
.unwrap_or_else(|error| error.into_inner())
.execs
.clone();
assert!(execs.is_empty(), "{execs:?}");
}
#[test]
fn a_host_that_has_no_uname_is_asked_again_the_windows_way() {
let Some(server) = start_server_replying(TestAuth::Password, windows_probe_reply) else {
+12 -8
View File
@@ -122,14 +122,14 @@ impl TerminalPanel {
}
fn tab_menu(&self, cx: &Context<Self>) -> TabMenu {
let store = self.store.read(cx);
TabMenu {
id: self.id,
host_id: self.host_id,
host_is_ip: self
.store
.read(cx)
host_is_ip: store
.host(self.host_id)
.is_some_and(|host| host.address_is_ip()),
editable: !store.is_temporary(self.host_id),
group: self.tab_group.clone(),
panel: cx.entity_id(),
}
@@ -317,6 +317,8 @@ struct TabMenu {
id: RemoteTerminalId,
host_id: HostId,
host_is_ip: bool,
/// A saved host; a temporary one has nothing to edit.
editable: bool,
group: Option<WeakEntity<TabGroup>>,
panel: EntityId,
}
@@ -350,7 +352,7 @@ impl TabMenu {
} else {
"复制主机名"
};
let menu = menu
let mut menu = menu
.menu_with_icon(
"重命名标签…",
Icon::new(CatalogIcon::Pencil),
@@ -375,13 +377,15 @@ impl TabMenu {
"重新连接",
Icon::new(CatalogIcon::RefreshCw),
Box::new(ReconnectTerminal(id)),
)
.menu_with_icon(
);
if self.editable {
menu = menu.menu_with_icon(
"编辑主机…",
Icon::new(CatalogIcon::Pencil),
Box::new(EditHost(host_id)),
)
.separator();
);
}
let menu = menu.separator();
close_tab_items(
menu,
CenterTab::Terminal(id),
+3
View File
@@ -27,6 +27,9 @@ pub const MANIFEST_URL: &str = "https://dl.shellrs.com/update/v1/{channel}.json"
/// Where a person downloads ShellRS by hand.
pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download";
/// What each version changed.
pub const CHANGELOG_PAGE: &str = "https://shellrs.com/changelog";
/// The minisign public keys a manifest may be signed with: the key in use,
/// and a spare kept offline so the first can be replaced. Rotating means
/// shipping a release that knows the new key before signing with it.
+35 -44
View File
@@ -1,5 +1,6 @@
//! The update dialog: what the newer ShellRS brings, how far its download
//! is, and restarting into it.
//! The update dialog: how far the newer ShellRS's download is, and
//! restarting into it. What it changes is not listed here: a button opens
//! the website's changelog.
//!
//! Opened from the title bar button and from 设置 › 关于. What a restart
//! interrupts is written here, above the button, so restarting needs no
@@ -10,23 +11,20 @@ use std::rc::Rc;
use gpui_kit::component::{
ActiveTheme as _, Sizable as _, WindowExt as _,
button::{Button, ButtonVariants as _},
dialog::{DialogClose, DialogFooter},
dialog::DialogFooter,
progress::Progress,
text::TextView,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{DownloadUpdate, OpenDownloadPage, RestartToUpdate};
use crate::app::{CatalogIcon, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate};
use super::status::{RestartImpact, UpdateStep, percent, restart_note};
use super::updater::{Phase, Stage, UpdateSnapshot, Updater};
/// The dialog's width, in rems.
const DIALOG_WIDTH: f32 = 32.;
/// The notes scroll beyond this height, in rems.
const NOTES_HEIGHT: f32 = 18.;
/// Counts what a restart would interrupt, at the time the dialog draws.
pub type ImpactCounter = Rc<dyn Fn(&App) -> RestartImpact>;
@@ -54,46 +52,31 @@ impl Render for UpdateDialog {
let snapshot = self.updater.read(cx).snapshot();
let impact = (self.impact)(cx);
let muted = cx.theme().muted_foreground;
let release = snapshot.release.clone();
let versions = match snapshot
.release
.as_ref()
.and_then(|release| release.published_on())
{
Some(day) => format!("当前版本 {} · 新版本发布于 {day}", snapshot.current),
None => format!("当前版本 {}", snapshot.current),
};
v_flex()
.id("update-dialog")
.test_support()
.gap_3()
.text_sm()
.child(
div()
.text_color(muted)
.child(match (&release, &snapshot.manual) {
(Some(release), _) => match release.published_on() {
Some(day) => format!("当前版本 {} · 发布于 {day}", snapshot.current),
None => format!("当前版本 {}", snapshot.current),
},
_ => format!("当前版本 {}", snapshot.current),
}),
)
.children(
release
.as_ref()
.filter(|release| !release.notes.trim().is_empty())
.map(|release| {
div()
.id("update-notes")
.test_support()
.aria_label(release.notes.clone())
.max_h(rems(NOTES_HEIGHT))
.overflow_y_scroll()
.rounded(cx.theme().radius)
.border_1()
.border_color(cx.theme().border)
.p_3()
.child(
TextView::markdown(
SharedString::from(format!("update-notes-{}", release.version)),
release.notes.clone(),
)
.selectable(true),
)
}),
v_flex()
.gap_1()
.when(snapshot.phase == Phase::Ready, |lines| {
lines.child(
div()
.id("update-ready")
.test_support()
.child("新版本已下载,重启 ShellRS 即可完成更新。"),
)
})
.child(div().text_color(muted).child(versions)),
)
.when_some(progress(&snapshot), |body, (label, value)| {
body.child(
@@ -193,7 +176,8 @@ pub fn open_update_dialog(
});
}
/// 稍后, and the one step that applies now.
/// Where to read what changed, and the one step that applies now. The
/// dialog closes by its close button or Escape.
fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
let action: Option<(&'static str, &'static str, Box<dyn Action>)> =
snapshot.step().map(|step| -> (_, _, Box<dyn Action>) {
@@ -213,9 +197,16 @@ fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
),
}
});
let close = if action.is_some() { "稍后" } else { "关闭" };
let changelog = dispatch.clone();
DialogFooter::new()
.child(DialogClose::new().trigger(move |button| button.label(close)))
.child(
Button::new("open-changelog")
.icon(CatalogIcon::ExternalLink)
.label("查看更新内容")
.on_click(move |_, window, cx| {
changelog.dispatch_action(&OpenChangelog, window, cx);
}),
)
.children(action.map(|(id, label, action)| {
let dispatch = dispatch.clone();
Button::new(id)
+76
View File
@@ -0,0 +1,76 @@
//! The workspace's share of the links ShellRS is opened with (外部连接):
//! a bastion host runs `ShellRS ssh://user@host:port`, the way it runs
//! Xshell, or `ShellRS sftp://…`, the way it runs WinSCP, and ShellRS
//! connects without saving the host. The host goes with its last tab.
use gpui_kit::component::{WindowExt as _, dock::DockPlacement, notification::Notification};
use gpui_kit::*;
use crate::cli::OpenLink;
use crate::host::{HostId, LinkKind, SshLink};
use super::workspace_view::Workspace;
impl Workspace {
/// Connect to what `link` names in a tab of its own across the window,
/// a terminal or an SFTP tab by the link's scheme, or say what is wrong
/// with it.
pub fn open_link(&mut self, link: OpenLink, window: &mut Window, cx: &mut Context<Self>) {
match SshLink::parse(&link.url, link.tab.as_deref()) {
Ok(link) => {
let kind = link.kind;
let host = self
.store
.update(cx, |store, cx| store.insert_external(link, cx));
match kind {
LinkKind::Ssh => self.connect_host(host, window, cx),
LinkKind::Sftp => self.open_explorer(host, window, cx),
}
// The tab is what the bastion host opened ShellRS for: the
// hosts make way, as with ⌘B, which brings them back.
if self.dock_area.read(cx).is_dock_open(DockPlacement::Left) {
self.dock_area.update(cx, |area, cx| {
area.toggle_dock(DockPlacement::Left, window, cx);
});
}
}
Err(problem) => {
window.push_notification(Notification::error(problem).title("无法打开链接"), cx)
}
}
}
/// Forget a temporary host once its last tab has closed, with whatever
/// it was still asking.
pub(super) fn forget_unused_temporary_host(
&mut self,
host: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.store.read(cx).is_temporary(host) || self.has_tabs(host, cx) {
return;
}
self.cancel_prompts_for_host(host, window, cx);
self.store
.update(cx, |store, cx| store.remove_temporary(host, cx));
}
}
/// Open the link ShellRS was started with, once the window is there: an
/// error notification needs the window's `Root`, which is only in place
/// after the closure building the window returns (see
/// [`super::notify_once_open`]).
pub fn open_link_once_open(
workspace: &Entity<Workspace>,
link: OpenLink,
window: &mut Window,
cx: &mut App,
) {
let workspace = workspace.downgrade();
window.defer(cx, move |window, cx| {
workspace
.update(cx, |workspace, cx| workspace.open_link(link, window, cx))
.ok();
});
}
+2
View File
@@ -5,6 +5,7 @@
mod credentials;
mod dock_skin;
mod forwards;
mod links;
mod recent_hosts;
mod sidebar;
mod snippets;
@@ -15,4 +16,5 @@ mod tools;
mod updates;
mod workspace_view;
pub use links::open_link_once_open;
pub use workspace_view::{Workspace, notify_once_open, window_options};
+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)
}),
),
)
}
+28 -26
View File
@@ -1,6 +1,5 @@
use gpui_kit::component::{
ActiveTheme as _, IconName, Selectable as _, Sizable as _, TitleBar,
badge::Badge,
button::{Button, ButtonGroup, ButtonVariants as _},
h_flex,
};
@@ -8,9 +7,10 @@ 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;
use super::sidebar::SidebarMode;
@@ -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(
@@ -146,25 +149,24 @@ pub fn render_title_bar(
}),
)
.children(update.map(|update| {
// A dot on the icon, in the colour of what it says.
Badge::new()
.dot()
.color(if update.trouble {
cx.theme().warning
} else {
cx.theme().success
// The one coloured button up here, in the colour of
// what it says: the only sign that an update waits.
Button::new("update-available")
.custom(tinted(
if update.trouble {
cx.theme().warning
} else {
cx.theme().success
},
cx,
))
.small()
.icon(CatalogIcon::CircleArrowUp)
.tooltip(update.label.clone())
.accessibility_label(update.label)
.on_click(move |_, window, cx| {
show_update.dispatch_action(&ShowUpdate, window, cx)
})
.child(
Button::new("update-available")
.ghost()
.small()
.icon(CatalogIcon::CircleArrowUp)
.tooltip(update.label.clone())
.accessibility_label(update.label)
.on_click(move |_, window, cx| {
show_update.dispatch_action(&ShowUpdate, window, cx)
}),
)
}))
.child(
Button::new("theme-toggle")
+7
View File
@@ -45,6 +45,13 @@ impl ToolKind {
})
}
/// Whether the tool runs commands on a channel of its own beside the
/// terminal, which a terminal a bastion host opened cannot have. Command
/// snippets only type into the terminal.
pub fn runs_commands(self) -> bool {
!matches!(self, ToolKind::Snippets)
}
/// Whether the tool works on a host running `os`. A host not identified
/// yet gets every tool, and is told if one cannot work there.
pub fn works_on(self, os: Option<HostOs>) -> bool {
+6 -7
View File
@@ -56,19 +56,18 @@ impl Workspace {
}
/// The tools the switch offers for the SSH terminal in front; none
/// without one.
/// without one. An external connection (a bastion host's link) gets only
/// those that run nothing beside the terminal.
pub(super) fn offered_tools(&self, cx: &App) -> Vec<ToolKind> {
let Some(terminal) = self.tool_terminal(cx) else {
return Vec::new();
};
let os = self
.store
.read(cx)
.host(terminal.host)
.and_then(|host| host.os);
let store = self.store.read(cx);
let os = store.host(terminal.host).and_then(|host| host.os);
let shell_only = store.is_external(terminal.host);
ToolKind::ALL
.into_iter()
.filter(|tool| tool.works_on(os))
.filter(|tool| tool.works_on(os) && !(shell_only && tool.runs_commands()))
.collect()
}
+14 -1
View File
@@ -6,9 +6,12 @@ use std::rc::Rc;
use gpui_kit::component::{WindowExt as _, notification::Notification};
use gpui_kit::*;
use crate::app::{CheckForUpdates, DownloadUpdate, OpenDownloadPage, RestartToUpdate, ShowUpdate};
use crate::app::{
CheckForUpdates, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate, ShowUpdate,
};
use crate::host::ConnectionState;
use crate::terminal::TerminalLifecycle;
use crate::update::build_info::CHANGELOG_PAGE;
use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog};
use super::workspace_view::Workspace;
@@ -76,6 +79,16 @@ impl Workspace {
cx.open_url(&page);
}
/// The whole changelog, not one version's section.
pub(super) fn on_open_changelog(
&mut self,
_: &OpenChangelog,
_: &mut Window,
cx: &mut Context<Self>,
) {
cx.open_url(CHANGELOG_PAGE);
}
/// The title bar shows the update button only in some phases; the
/// version after an update is said once the new one runs.
pub(super) fn on_updater_event(
+73 -14
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,
@@ -259,12 +261,16 @@ impl Workspace {
loop {
cx.background_executor().timer(ACTIVATION_POLL).await;
let open = this.update_in(cx, |this, window, cx| {
let asked = this
let Some(links) = this
.cli_server
.as_ref()
.is_some_and(|server| server.take_activation());
if asked {
crate::app::bring_forward(window, cx);
.and_then(|server| server.take_activation())
else {
return;
};
crate::app::bring_forward(window, cx);
for link in links {
this.open_link(link, window, cx);
}
});
if open.is_err() {
@@ -563,7 +569,7 @@ impl Workspace {
}
/// Whether a host has any tab open in the center.
fn has_tabs(&self, host: HostId, cx: &App) -> bool {
pub(super) fn has_tabs(&self, host: HostId, cx: &App) -> bool {
self.terminals_of(host, cx).next().is_some() || !self.explorers_of(host, cx).is_empty()
}
@@ -931,7 +937,7 @@ impl Workspace {
) {
self.cancel_prompts_for_owner(PromptOwner::Terminal(terminal_id), window, cx);
}
fn cancel_prompts_for_host(
pub(super) fn cancel_prompts_for_host(
&mut self,
host_id: HostId,
window: &mut Window,
@@ -1002,7 +1008,12 @@ impl Workspace {
}
}
fn connect_host(&mut self, host_id: HostId, window: &mut Window, cx: &mut Context<Self>) {
pub(super) fn connect_host(
&mut self,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).host(host_id).is_none() {
return;
}
@@ -1030,7 +1041,15 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
let host_id = action.0;
self.open_explorer(action.0, window, cx);
}
pub(super) fn open_explorer(
&mut self,
host_id: HostId,
window: &mut Window,
cx: &mut Context<Self>,
) {
if self.store.read(cx).host(host_id).is_none() {
return;
}
@@ -1665,8 +1684,35 @@ 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>) {
if self.store.read(cx).host(action.0).is_some() {
// A host opened from a link is not saved, so there is nothing to
// edit.
if self
.store
.read(cx)
.hosts()
.iter()
.any(|host| host.id == action.0)
{
open_host_dialog(
Some(action.0),
None,
@@ -1705,7 +1751,15 @@ impl Workspace {
fn on_delete_host(&mut self, action: &DeleteHost, window: &mut Window, cx: &mut Context<Self>) {
let id = action.0;
let Some(host) = self.store.read(cx).host(id).cloned() else {
// Saved hosts only: one opened from a link goes with its tabs.
let Some(host) = self
.store
.read(cx)
.hosts()
.iter()
.find(|host| host.id == id)
.cloned()
else {
return;
};
let workspace = cx.entity().downgrade();
@@ -1975,6 +2029,7 @@ fn new_terminal_panel(
this.store
.update(cx, |store, cx| store.set_active(None, cx));
}
this.forget_unused_temporary_host(*host_id, window, cx);
}
TerminalPanelEvent::StatusChanged(_, host_id) => {
this.refresh_host_connection_state(*host_id, cx);
@@ -2084,6 +2139,7 @@ fn new_explorer_panel(
this.set_active_tab(None, window, cx);
}
this.refresh_host_connection_state(*host_id, cx);
this.forget_unused_temporary_host(*host_id, window, cx);
}
ExplorerPanelEvent::StateChanged(id, host_id) => {
if let Some(panel) = this.explorers.get(id) {
@@ -2180,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))
@@ -2215,6 +2273,7 @@ impl Render for Workspace {
.on_action(cx.listener(Self::on_show_update))
.on_action(cx.listener(Self::on_restart_to_update))
.on_action(cx.listener(Self::on_open_download_page))
.on_action(cx.listener(Self::on_open_changelog))
.on_action(cx.listener(Self::on_copy_agent_skill))
.on_action(cx.listener(Self::on_close_settings))
.on_action(cx.listener(Self::on_close_active_tab))
+75 -1
View File
@@ -2,17 +2,20 @@
//! with piped stdio, finding the app through `SHELLRS_DATA_DIR`. A fake
//! backend stands in for SSH, so this runs on every platform, including the
//! Windows CI where it is the one test of the named pipe between processes.
//! Also ShellRS itself opened with a link, as a bastion host opens it.
use std::{
io::{self, Write as _},
path::Path,
process::{Command, Output, Stdio},
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use shellrs::app::cli_endpoint;
use shellrs::cli::{
CliBackend, CliError, CliServer, CliTarget, HostInfo, TransferCounters, TransferSummary,
CliBackend, CliError, CliServer, CliTarget, HostInfo, OpenLink, TransferCounters,
TransferSummary,
};
use shellrs::host::{AuthKind, Host, HostDraft, HostId, HostLogin};
use shellrs::ssh::ExecStream;
@@ -144,3 +147,74 @@ fn the_command_finds_the_app_through_its_data_directory() {
.starts_with("shellrs: [not_enabled]")
);
}
/// Open ShellRS itself, as a bastion host does, beside the app whose data
/// lives in `data_dir`; its exit code. Handing its link over, it exits at
/// once. One that does not is killed rather than left running as a second
/// app with a window of its own.
fn open_shellrs(data_dir: &Path, args: &[&str]) -> Option<i32> {
let mut child = Command::new(env!("CARGO_BIN_EXE_shellrs"))
.args(args)
.env("SHELLRS_DATA_DIR", data_dir)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Some(status) = child.try_wait().unwrap() {
return status.code();
}
if Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait();
panic!("ShellRS opened with {args:?} did not hand its link over");
}
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn shellrs_opened_with_a_link_hands_it_to_the_running_app() {
let data_dir = tempfile::tempdir().unwrap();
let server = CliServer::start(
cli_endpoint(data_dir.path()),
Arc::new(FakeBackend::default()),
)
.unwrap();
// 启用外部 CLI is off: being opened again is not the external CLI.
let xshell = "ssh://deploy:p%40ss@10.0.0.9:2222";
let token = "ssh://b478e26f-811b-4a90-81c3-74929127898a@172.16.0.28:12024";
assert_eq!(
open_shellrs(
data_dir.path(),
&["-url", xshell, "-newtab", "跳板机", "-newwin"]
),
Some(0)
);
assert_eq!(open_shellrs(data_dir.path(), &[token]), Some(0));
// As for WinSCP.
let winscp = "sftp://deploy:p%40ss@10.0.0.9:2222";
assert_eq!(
open_shellrs(data_dir.path(), &["/sessionname=文件", winscp]),
Some(0)
);
assert_eq!(
server.take_activation(),
Some(vec![
OpenLink {
url: xshell.into(),
tab: Some("跳板机".into()),
},
OpenLink {
url: token.into(),
tab: None,
},
OpenLink {
url: winscp.into(),
tab: Some("文件".into()),
},
])
);
}
+359
View File
@@ -553,3 +553,362 @@ async fn the_tab_bar_shows_the_connection_latency_while_it_runs(cx: &mut TestApp
})
.unwrap();
}
/// The first terminal and the first host of an empty store, which a link
/// opens.
const LINK_TERMINAL: u64 = 1;
const LINK_HOST: HostId = HostId(1);
fn open_link(
cx: &mut TestAppContext,
handle: WindowHandle<Root>,
workspace: &Entity<Workspace>,
url: &str,
tab: Option<&str>,
) {
let link = shellrs::cli::OpenLink {
url: url.into(),
tab: tab.map(str::to_string),
};
cx.update_window(handle.into(), |_, window, cx| {
workspace.update(cx, |workspace, cx| workspace.open_link(link, window, cx));
})
.unwrap();
cx.run_until_parked();
}
#[gpui_kit::test]
async fn a_link_opens_a_terminal_to_a_host_that_goes_with_its_last_tab(cx: &mut TestAppContext) {
use shellrs::app::{CloseExplorer, CloseTerminal};
use shellrs::host::BookmarkSide;
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 (handle, workspace) = open_workspace_with_credentials(
cx,
store,
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
open_link(
cx,
handle,
&workspace,
"ssh://token:p%40ss@10.0.0.9:2222",
Some("db-prod"),
);
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
window.render_frame(cx);
window.find("status-connection").label() == Some("已连接 db-prod")
})
.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, "token")
);
assert_eq!(login.method, LoginMethod::Password);
assert_eq!(login.route, LoginRoute::Direct);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("terminal-tab", LINK_TERMINAL)).label(),
Some("db-prod")
);
// Not saved, and across the window: the hosts made way.
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find("host-search").is_none());
// The bastion host gets the terminal alone: of the tools, only the
// snippets, which type into it.
assert!(window.try_find("tool-snippets").is_some());
for tool in ["tool-history", "tool-docker", "tool-monitor"] {
assert!(window.try_find(tool).is_none(), "{tool}");
}
})
.unwrap();
assert!(login.shell_only);
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_temporary(LINK_HOST));
assert!(store.hosts().is_empty());
assert!(store.recent_hosts().next().is_none());
// The link's password is in memory, not in the keychain.
assert_eq!(
store
.secrets()
.get(&SecretRef::temporary(LINK_HOST.0))
.unwrap()
.as_deref()
.map(String::as_str),
Some("p@ss")
);
});
assert!(keychain.is_empty());
// There is nothing to edit.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(EditHost(LINK_HOST)), cx);
})
.unwrap();
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.try_find("host-name").is_none());
})
.unwrap();
// Its SFTP tab works, bookmarks and all, without writing to disk.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(OpenExplorer(LINK_HOST)), cx);
})
.unwrap();
cx.run_until_parked();
store.update(cx, |store, cx| {
assert!(store.add_bookmark(LINK_HOST, BookmarkSide::Remote, "/var/log", cx));
});
cx.run_until_parked();
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert!(window.notifications(cx).is_empty());
})
.unwrap();
// The host stays while a tab of it does.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx);
})
.unwrap();
cx.run_until_parked();
assert!(store.read_with(cx, |store, _| store.host(LINK_HOST).is_some()));
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), 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());
assert!(saved.recent_hosts().next().is_none());
assert!(saved.bookmarks(LINK_HOST, BookmarkSide::Remote).is_empty());
}
#[gpui_kit::test]
async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContext) {
use shellrs::app::CloseExplorer;
let remote = Arc::new(RecordingRemoteProvider::default());
let (handle, workspace) = open_workspace_with_credentials(
cx,
HostStore::empty(),
remote.clone(),
Arc::new(FakeConnectionTester::default()),
);
// How JumpServer opens WinSCP: the link alone.
open_link(
cx,
handle,
&workspace,
"sftp://token:p%40ss@10.0.0.9:2222",
None,
);
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;
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(
window.find(("explorer-tab", SFTP_TAB)).label(),
Some("10.0.0.9 · SFTP")
);
// An SFTP tab alone, across the window.
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
assert_eq!(window.find("show-hosts").checked(), Some(false));
assert!(window.try_find(("host-row", LINK_HOST.0)).is_none());
})
.unwrap();
assert!(remote.logins().is_empty());
let store = workspace.read_with(cx, |workspace, _| workspace.store().clone());
store.read_with(cx, |store, _| {
assert!(store.is_external(LINK_HOST));
let login = store.login(LINK_HOST).unwrap();
assert_eq!(
(login.host.as_str(), login.port, login.user.as_str()),
("10.0.0.9", 2222, "token")
);
assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0));
});
// It goes with its tab.
cx.update_window(handle.into(), |_, window, cx| {
window.dispatch_action(Box::new(CloseExplorer(ExplorerId(SFTP_TAB))), cx);
})
.unwrap();
cx.run_until_parked();
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(
cx,
HostStore::empty(),
Arc::new(RecordingRemoteProvider::default()),
Arc::new(FakeConnectionTester::default()),
);
open_link(cx, handle, &workspace, "ssh://10.0.0.9:2222", None);
cx.update_window(handle.into(), |_, window, cx| {
window.render_frame(cx);
assert_eq!(window.notifications(cx).len(), 1);
assert!(window.try_find(("terminal", LINK_TERMINAL)).is_none());
// Nothing opened, so the hosts stay.
assert_eq!(window.find("show-hosts").checked(), Some(true));
})
.unwrap();
workspace.read_with(cx, |workspace, cx| {
assert!(workspace.store().read(cx).host(LINK_HOST).is_none());
});
}
+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);
+61 -4
View File
@@ -101,7 +101,7 @@ async fn containers_show_by_project_and_the_rest_on_their_own(cx: &mut TestAppCo
window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(),
Some("Docker")
);
// The project with something running is unfolded; the stopped one
// The running project of one container is unfolded; the stopped one
// is not.
assert_eq!(
listed(window, &LINES),
@@ -193,7 +193,7 @@ async fn the_other_tabs_list_volumes_images_and_networks(cx: &mut TestAppContext
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-image:4a3b5c6d7e8f").label(),
window.find("docker-image:hello-world:latest").label(),
Some("hello-world:latest · 4a3b5c6d7e8f · 13.3 kB · 2026-09-07 · 使用中")
);
});
@@ -326,6 +326,63 @@ const NETWORK: &str = r#"[{"Name":"bridge","Id":"n1","Driver":"bridge","Scope":"
"IPAM":{"Config":[{"Subnet":"172.17.0.0/16","Gateway":"172.17.0.1"}]},
"Containers":{"ccc333":{"Name":"forex-web","IPv4Address":"172.17.0.2/16"}}}]"#;
/// One image under two tags, and one without a tag.
const TAGGED_TWICE: &str = r#"@@version
26.0.0
@@compose
v2.25.0
@@containers
@@volumes
@@images
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"latest"}
{"CreatedAt":"2026-09-07 10:59:16 +0800 CST","ID":"aaa111bbb222","Repository":"nginx","Size":"187MB","Tag":"1.27"}
{"CreatedAt":"2026-08-01 08:00:00 +0800 CST","ID":"ccc333ddd444","Repository":"nginx","Size":"180MB","Tag":"<none>"}
@@networks
"#;
#[gpui_kit::test]
async fn an_image_under_two_tags_is_two_cards_each_removed_by_its_tag(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
TAGGED_TWICE,
"Untagged: nginx:1.27\n@@status 0\n",
TAGGED_TWICE,
]));
let handle = open_docker(cx, factory.clone());
wait_for_reading(cx, handle).await;
in_frame(cx, handle, |window, cx| {
window.within("docker-tabs").click(2usize, cx)
});
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("docker-image:nginx:1.27").label(),
Some("nginx:1.27 · aaa111bbb222 · 187 MB · 2026-09-07")
);
assert_eq!(
window.find("docker-image:nginx:latest").label(),
Some("nginx:latest · aaa111bbb222 · 187 MB · 2026-09-07")
);
// Without a tag, by its ID.
assert_eq!(
window.find("docker-image:ccc333ddd444").label(),
Some("nginx:<none> · ccc333ddd444 · 180 MB · 2026-08-01")
);
});
in_frame(cx, handle, |window, cx| {
window.click("docker-image:nginx:1.27-remove", cx)
});
in_frame(cx, handle, |window, cx| window.click("ok", cx));
cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| {
factory.exec_count() >= 2
})
.await;
assert!(
factory.exec_commands()[1].contains("$d rmi \"nginx:1.27\""),
"{:?}",
factory.exec_commands()
);
}
#[gpui_kit::test]
async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::answering(&[
@@ -360,7 +417,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
window.within("docker-tabs").click(2usize, cx)
});
in_frame(cx, handle, |window, cx| {
window.click("docker-image:4a3b5c6d7e8f", cx)
window.click("docker-image:hello-world:latest", cx)
});
wait_for_label(cx, handle, "image-basics:平台", "linux/amd64").await;
in_frame(cx, handle, |window, _| {
@@ -371,7 +428,7 @@ async fn a_click_on_a_volume_an_image_or_a_network_shows_its_details(cx: &mut Te
Some("PATH=/usr/bin")
);
});
assert!(factory.exec_commands()[2].contains("$d image inspect \"4a3b5c6d7e8f\""));
assert!(factory.exec_commands()[2].contains("$d image inspect \"hello-world:latest\""));
in_frame(cx, handle, |window, cx| window.press("escape", cx));
in_frame(cx, handle, |window, 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");
+12 -7
View File
@@ -376,7 +376,7 @@ async fn with_automatic_updates_off_a_found_update_waits_for_download(cx: &mut T
}
#[gpui_kit::test]
async fn the_update_dialog_shows_the_notes_and_restarts_into_the_new_version(
async fn the_update_dialog_links_the_changelog_and_restarts_into_the_new_version(
cx: &mut TestAppContext,
) {
let (handle, workspace) = open_workspace(cx);
@@ -393,15 +393,20 @@ async fn the_update_dialog_shows_the_notes_and_restarts_into_the_new_version(
window.click("update-available", cx)
});
in_frame(cx, handle, |window, _| {
let notes = window
.find("update-notes")
.label()
.unwrap_or_default()
.to_string();
assert!(notes.contains("在线升级"), "{notes}");
assert!(window.try_find("update-ready").is_some());
assert!(window.try_find("restart-to-update").is_some());
});
// What changed is on the website's changelog; the dialog stays.
in_frame(cx, handle, |window, cx| window.click("open-changelog", cx));
assert_eq!(
cx.opened_url().as_deref(),
Some("https://shellrs.com/changelog")
);
in_frame(cx, handle, |window, _| {
assert!(window.try_find("update-dialog").is_some());
});
let restart = cx.expect_restart();
in_frame(cx, handle, |window, cx| {
window.click("restart-to-update", cx)
+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", "请输入名称"),