diff --git a/CLAUDE.md b/CLAUDE.md index bdbab94..6dff783 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP **保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。 -主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 +主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 `ssh://` 链接、像调 WinSCP 一样用 `sftp://` 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。**命名(用户定的)**:命令行链接打开的叫「外部连接」;标题栏右上角「临时连接…」打开的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」(见下面「临时连接不保存,功能齐全」)。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、`HostInfo::temporary`),「只开终端」只给外部连接(`HostStore::is_external`),不绑在「不保存」上。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标(`success` 色的浅色按钮,出错时 `warning` 色;原来是普通图标加小绿点,用户嫌不显眼),点开是更新对话框(不列更新内容,用户要的:页脚两个按钮「查看更新内容」打开官网的 `https://shellrs.com/changelog`(`build_info::CHANGELOG_PAGE`,用户要的:整页,不带版本锚点,也不用清单的 `notes_url`)和「重启并安装」,没有「稍后」,用关闭按钮或 Esc 关;正文是一句状态、当前版本和发布日期、重启会中断什么,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);**只跟着 SSH 远程终端出现**:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 `lo` 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 `/proc/net/route`,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 `df` 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 `/dev` `/run` `/sys` `/proc` 下的挂载)五张卡片;只支持 Linux(读 `/proc` 和 `df -kPl`,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)**不显示系统监控、网络连接、进程管理和系统服务的图标**(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(`sh` 能跑 `docker`、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 `ss` 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。**列表不自己刷新**:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 `ss -tuanpe`,不是 root 时只看得到自己的进程,没有 `ss` 的主机读 `/proc/net`、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 `TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 `count_tabs` 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(`com.docker.compose.project` 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(`…project.working_dir`),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 `docker stop/start/restart`,不靠 compose 文件);只有一个容器且在跑的项目默认展开,多个容器的(用户要的,展开会把下面的挤下去,卡片上已写着「运行中/总数」)和都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(`0.0.0.0:` 省掉,`->` 写成 `→`)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 `docker volume|image|network inspect` 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 `docker logs --tail 200 --timestamps`,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。**不是 root 又不在 docker 组时用 `sudo -n docker`**。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 `~/.bash_history` 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(`invisible` 加卡片的 `group_hover`),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。 @@ -42,10 +42,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui/` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 - `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `HostId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明;右侧栏的工具 `ToolKind` 和 `CenterTab` 一样定义在这里,名称、图标在 `workspace/tool_sidebar.rs`);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`BRAND_ICONS` 嵌入 `assets/icons/brand/*.svg`(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 `DOCKER_ICON`,同样取自 Simple Icons,CC0,用户要的),所以 `ToolKind::icon` 返回 `Icon` 而不是 `CatalogIcon`;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 -- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), 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:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`host_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`host_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 +- `host/` — `model.rs`(Host / HostGroup / HostDraft / GroupDraft / `HostOs` / `PublicId`;`Host.group` 与 `HostGroup.parent` 都是 `Option`,`Host.os` 是 `Option`,`Host.credential` 是 `Option`,`Host.route` 是连接方式 `Route::{Direct, Jump(Vec>), 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:` / `s:`;`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`)、`ssh_link.rs`(`SshLink::parse`:读 `ssh://` 或 `sftp://用户[:密码]@地址[:端口]`,`kind` 是 `LinkKind::{Ssh, Sftp}`,`url` crate 加 `percent-encoding` 解码,密码是 `Zeroizing`、`Debug` 打码,报错不带链接原文;`draft()` 给临时主机用,见下面「临时连接不保存」)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。**算法按 OpenSSH 的默认来**(`ssh_config`:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 `ecdh-sha2-nistp256` 连上了。所以 `KEX_ORDER` 照 OpenSSH 的顺序加上 `ecdh-sha2-nistp*`,`MIN_DH_GROUP_BITS` 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。`route_tests` 的 `a_server_with_only_2048_bit_groups_or_nist_curves_is_reached` 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 `describe_login_error`(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、`NoCommonAlgo` 时写明服务器支持哪些算法),找不到时给出整条错误链(`{error:#}`),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 `/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`,并且每 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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 +- **主机对话框的「测试连接」是真登录。** 主机模块不依赖 `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 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`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 写锁。 @@ -134,8 +134,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 `Shared::activation`(`Mutex>>`);请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,是 `Some` 就 `bring_forward`,再按顺序 `open_link`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。 +- **临时连接不保存,功能齐全。** 标题栏右上角原来的「新建主机…」换成了「临时连接…」(用户要的;新建主机还在主机列表的「+」、右键菜单和 ⌘N),派发 `NewTemporaryConnection`。 + - 对话框是 `HostForm::temporary`(`open_temporary_connection_dialog`):新建主机的地址、端口、认证方式(密码 / 使用凭据 / 无密码)和密码,没有分组、连接方式和备注,「测试连接」照旧(用户要的),名称可以不填(用地址),下面一行说明「仅当前使用的临时会话,不会保存到主机列表。」(用户给的文案)。页脚 `form_footer` 三个主机对话框共用。 + - `commit_temporary` 调 `HostStore::insert_temporary`:分组和连接方式一律丢掉(直连),填的密码只 `remember` 在 `TemporarySecretStore` 的内存里(用户定的:不进钥匙串)。然后在对话框关掉之后(`window.defer`)经工作区的 focus handle 派发 `ConnectHost`,新标签才留得住焦点。 + - 它不记进 `external`:不是 shell_only,右侧栏工具全有,也不收起左侧栏。其余和外部连接一样:不进主机树、最近连接和数据库,最后一个标签关掉就 `remove_temporary`,标签菜单没有「编辑主机…」,外部 CLI 照样列出。 + - `login_of` 只把端点自己的密码(`SecretRef::Password`)换成 `Temporary`,用密码凭据时照用凭据的。改凭据(用户名跟着变、重连)和删凭据(改回自己登录)也照顾临时主机:`logins`、`update_credential_unnotified`、`remove_credential_unnotified` 都把 `temporary` 算进去;凭据的用量和删除确认只数保存的主机。`a_temporary_connection_goes_directly_and_may_use_a_credential` 和 UI 的 `a_temporary_connection_has_every_feature_and_saves_nothing` 守着。 - **外部连接不保存,只开一个标签。** 堡垒机按 Xshell 的写法拉起 ShellRS(`ShellRS ssh://user[:password]@host[:port]` 或 `-url … -newtab 名称`),开一个终端标签连过去;按 WinSCP 的写法(`ShellRS sftp://…`,JumpServer 的模板就是单独一个链接,前面可以有 `/sessionname=名称`)则开一个 SFTP 标签(`open_link` 按 `SshLink::kind` 调 `connect_host` 或 `open_explorer`),其余规则两种一样(用户定的:和 Xshell 的 `-url` 一样是临时的;只认命令行参数,不注册系统的 `ssh://` / `sftp://` 链接、不改安装包;`sftp://` 链接里的路径暂不认,`scp://` 不做)。 - - `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(外部连接)」(做临时连接时要让 `HostInfo` 分得出两种);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。 + - `HostStore` 把它放在单独的 `temporary`(临时主机)里,并记进 `external`:`host(id)` / `login(id)` / `active()` 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;`hosts()` 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、`password_in_use` 都看不到。外部 CLI 例外(用户定的):`CliTarget::all` 在保存的主机后面接上 `temporary_hosts()`,`HostInfo::temporary` 为真(`#[serde(default)]`,旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(未保存)」(用户定的:临时连接和外部连接在外部 CLI 里不区分,agent 用不着,ID 失效时 CLI 自己报 `host_not_found`);它有自己的 `public_id`(`unused_public_id` 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 `next_host_id`,和保存的主机永不相撞。`set_state` / `set_host_os` / 书签只改内存(不进 `recent`、不写库:书签表的外键会失败)。会写外键的检查(`normalized_route`、`route_login`、转发、片段的范围、拖动排序)用私有的 `saved_host`,不用 `host`。 - **终端只开一个通道**(`HostLogin::shell_only`,`login_of` 只对 `is_external` 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 `ssh/transport.rs` 对 `shell_only` 不做系统探测、`Exec` 直接答 `SHELL_ONLY` 不发给服务器(`a_terminal_a_bastion_host_opened_carries_nothing_beside_it` 守着),右侧栏只给不执行命令的工具(`ToolKind::runs_commands` 为假的只有命令片段,`offered_tools` 看 `is_external`)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(`sftp/client.rs` 的 sftp 子系统,延迟同样是全局请求),所以 `sftp://` 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 `is_external` 关掉。 - 登录用「密码」方式,`login_of` 把临时主机的密码引用换成 `SecretRef::Temporary { host }`:链接里的密码只在 `TemporarySecretStore` 的内存里(钥匙串、数据库都不进),也不读同一 `user@host:port` 的已保存密码;没带密码就照常 none → 弹框。 - 终端和 SFTP 的 `Closed` 处理器最后调 `forget_unused_temporary_host`:主机是临时的、而且没有标签了,就取消它的问答并 `remove_temporary`(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(`TabMenu::editable`),`on_edit_host`、`on_delete_host` 只认保存的主机。 @@ -203,4 +208,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 ## 测试约定 -UI 测试(`tests/ui/`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。它们是**一个**测试程序(`tests/ui/main.rs`,`cargo test --test ui`),按功能分模块:`host_dialog`(主机对话框、连接方式、备注、钥匙串里的密码、测试连接)、`host_tree`(搜索、分组、拖动、行的 tooltip 和右键命令)、`connection`(连接主机、认证问答、连接状态、系统和延迟、链接打开的外部连接)、`terminal`(本地终端、输入、选择、查找、清屏、字体、尺寸)、`workspace`(标签页和关闭、开始页、标题栏、右侧栏、对话框的共性)、`monitor`(系统监控)、`snippets`(命令片段)、`history`(历史命令)、`netstat`(网络连接)、`processes`(进程管理)、`services`(系统服务)、`docker`(Docker)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。单元测试起子进程一律经 `crate::testing`(`sh`、`sh_accepts`,别的程序先拿 `no_forks()` 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——`cli::tests` 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 `no_forks()`。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `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-` 与 `skill-status-`(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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +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`:生产环境由 `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-` 与 `skill-status-`(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-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host-panel`(主机列表标题栏的「+」,打开新建主机对话框;标题栏右上角的按钮是临时连接 `temporary-connection`,它的对话框字段 id 同主机对话框,另有说明 `temporary-note`)、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-services` / `tool-processes` / `tool-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 `tool-switch` 也不存在。命令片段的有:面板 `snippets`、汇总 `snippets-summary`(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 `snippets-new` / `snippets-new-category`、`snippets-search`、列表 `snippets-list`、一个片段都没有时的 `snippets-empty` 与其中的 `snippets-empty-new`、搜不到时的 `snippets-no-match`、分类标题 `snippet-category:<分类 id>` 与未分类的 `snippet-category:none`(`aria_label` 形如「Docker 2」,搜索时数的是匹配的),卡片 `("snippet", 片段 id)`(`aria_label` 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 `("snippet-run", 片段 id)`(指针不在卡片上时 `visible()` 为假、点不到:先把指针移到卡片上再点,`snippets.rs` 的 `point_at`);对话框的 `snippet-name`、`snippet-category`(包着下拉的 `div`)、`snippet-command`(包着 `Textarea` 的 `div`,点它再输入)、勾选框 `snippet-run-on-click`、分类对话框的 `snippet-category-name`。对话框关掉后焦点是空的,接着 `window.dispatch_action` 之前先点一下面板(`snippets.rs` 的 `dispatch_from_panel`);假终端的 shell 要先跑起来(`remote_lifecycle(..).accepts_input()`),点片段才有输入。历史命令的有:面板 `history`、汇总 `history-summary`(「共 3 条 · ~/.bash_history」)、`history-refresh`、说明 `history-note`、`history-truncated`、`history-search`、列表 `history-list`、`history-empty`、`history-loading`、问题说明 `history-message`(没连接、没有历史文件、不支持、读取失败)、卡片 `history-entry:<命令>`(`aria_label` 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 `history-run:<命令>`;点了之后查 `FakeTerminalFactory::written_text()` 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 `\r`)。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 `netstat`、汇总 `netstat-summary`(`aria_label` 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、`netstat-refresh`、`netstat-search`、下拉 `netstat-protocol` / `netstat-state`(UI 测试不点下拉,筛选由 `model.rs` 的单元测试覆盖)、有搜索或筛选时的 `netstat-matches`(「筛选出 2 条」)、列表 `netstat-list`、一行都没有时的 `netstat-empty`、读到第一次之前的 `netstat-loading`、问题说明 `netstat-message`(没连接、不支持、读取失败)、`netstat-truncated`、看不全进程时的 `netstat-processes`(不是 root,或主机没有 `ss`)、卡片 `netstat-socket:<协议> <本地> <远端>`(如 `netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*`,`aria_label` 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 `uniform_list`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。Docker 的有:面板 `docker`、汇总 `docker-summary`(「Docker 26.0.0 · Compose v2.25.0」)、`docker-refresh`、标签栏外包的 `docker-tabs`(序号 0–3 是容器、卷、镜像、网络,`aria_label` 形如「容器 4」)、列表 `docker-list`、项目卡片 `docker-project:<项目>`(`aria_label` 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 `docker-project-line:<项目>`、容器行 `docker-container:<名字>`(`aria_label` 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 `docker-stop:<名字>` / `docker-start:<名字>` / `docker-restart:<名字>`(项目的是 `docker-stop:project:<项目>`)/ `docker-more:<名字>`、卡片 `docker-volume:<名字>` / `docker-image:<键>`(`仓库:标签`,没有标签的是镜像 ID)/ `docker-network:<名字>` 与它们的删除按钮 `<卡片 id>-remove`、`docker-empty`、`docker-loading`、`docker-message`;详情的标签栏 `container-tabs`、正文 `container-details`、各行 `container-basics:<标签>` / `container-ports:<端口>` / `container-mounts:<来源>` / `container-labels:<键>`、`container-environment`、日志 `container-output`、底部 `container-dialog-stop` / `-start` / `-restart` / `-remove`;卷、镜像、网络的详情正文 `docker-details`(读取中 `docker-details-reading`、读不到 `docker-details-unknown`),各行同上换前缀 `volume-` / `image-` / `network-`,底部 `docker-dialog-remove`。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-ready`(已下载好时的那句)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `open-changelog`(点了查 `cx.opened_url()`)/ `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/docs/manual.md b/docs/manual.md index aa8ef51..898beba 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -20,7 +20,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 - **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。 - **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。 -无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。 +无论选哪种,连接时都先问服务器是否允许免认证登录,允许就直接登录。「测试连接」时选了「密码」却没填,就像「无密码」那样试一遍(免认证、SSH Agent、默认私钥),连不上就说明原因。 主机对话框「分组」下面的「连接方式」决定怎么连到这台主机,终端、SFTP、端口转发、外部 CLI 和「测试连接」都照它走: @@ -38,6 +38,17 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 拖动分组或主机到同类项目的上侧 / 下侧可调整同级顺序;主机拖到分组会移入该分组,分组拖到另一个分组的中部会成为子分组。拖到列表空白处可移回根层级。分组始终排在同级主机之前,顺序与分组的展开状态会保存到本地数据库。搜索时暂停拖动排序。 +## 临时连接 + +标题栏右上角的「临时连接…」连一台主机而不保存它,适合只登一次的机器。对话框和新建主机一样填地址、端口和认证方式(密码、使用凭据或无密码),没有分组、连接方式和备注,名称可以不填(用地址);可以先「测试连接」。点「连接」打开一个终端标签: + +- **功能齐全**:在新标签页中连接、打开 SFTP、重新连接和右侧栏的全部工具都能用。连接方式是直接连接。 +- **不保存**:不进主机列表、开始页的最近连接和数据库;它的终端和 SFTP 标签都关掉后就没了。标签的菜单里没有「编辑主机…」。 +- **密码只在内存里**:填的密码不进钥匙串,重新连接时照样用它;没填时服务器要密码就弹框问。选「使用凭据」时用凭据保存的密码或私钥。 +- 外部 CLI 的 `shellrs list` 也列出它(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时可以用它的 ID。 + +要保存的主机在主机列表标题栏的「+」、右键菜单或 ⌘N(其他平台 Ctrl+N)新建。 + ## 端口转发 点标题栏的端口转发图标,左侧栏换成转发列表。每条转发规则经由一台已保存的主机,启动时为自己建立一条 SSH 连接,不依赖该主机的终端或 SFTP 标签:关掉标签、对主机「断开连接」都不影响它,主机的连接状态也不把它算在内。有转发在运行时,标题栏的端口转发图标旁显示运行数量。 @@ -118,7 +129,7 @@ shellrs upload <本地路径> <远程路径> shellrs download <远程路径> <本地路径> ``` -- `` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的外部连接(`"temporary": true`),见「从堡垒机打开(外部连接)」。 +- `` 是 16 位的主机 ID,即 `shellrs list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。 - `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。 - 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。 - 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`version_mismatch`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。 @@ -145,7 +156,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口] - Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。 - macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。 - Linux:AppImage 本身。 -- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(外部连接)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 +- 外部 CLI 的 `shellrs list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。 - 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。 ## 标签页 diff --git a/src/app/actions.rs b/src/app/actions.rs index 20aa69d..f115036 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -21,6 +21,8 @@ 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 the new-group dialog for a top-level group. NewGroup, /// Open a new local login-shell terminal. diff --git a/src/cli/client.rs b/src/cli/client.rs index f932ddd..382a932 100644 --- a/src/cli/client.rs +++ b/src/cli/client.rs @@ -253,10 +253,10 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> { [ host.id.clone(), host.name.clone(), - // Every temporary host is an external one so far; a 临时连接 - // typed into ShellRS, when it comes, needs telling apart here. + // 临时连接 and 外部连接 alike: an agent needs no telling + // them apart, only that the host is not saved. if host.temporary { - "(外部连接)".to_string() + "(未保存)".to_string() } else { host.group.clone().unwrap_or_default() }, diff --git a/src/cli/protocol.rs b/src/cli/protocol.rs index 17d8948..ae68b38 100644 --- a/src/cli/protocol.rs +++ b/src/cli/protocol.rs @@ -172,10 +172,10 @@ pub struct HostInfo { pub port: u16, /// The detected operating system, as stored (`ubuntu`, `macos`, …). pub os: Option, - /// Connected to without being saved (so far always an 外部连接, which a - /// bastion host's link opened): listed while its tab is open, under one - /// ID for as long; opened again, it is another host with another ID. - /// Missing from an older app's answer, which listed saved hosts only. + /// Connected to without being saved (a 临时连接, or an 外部连接 a bastion + /// host's link opened): listed while its tab is open, under one ID for + /// as long; opened again, it is another host with another ID. Missing + /// from an older app's answer, which listed saved hosts only. #[serde(default)] pub temporary: bool, } diff --git a/src/cli/server.rs b/src/cli/server.rs index be1bba8..7f7550f 100644 --- a/src/cli/server.rs +++ b/src/cli/server.rs @@ -86,7 +86,7 @@ impl CliTarget { } } - /// Marked as opened from a link, not saved. + /// Marked as not saved. fn temporary(mut self) -> Self { self.info.temporary = true; self @@ -101,10 +101,10 @@ impl CliTarget { &self.login } - /// Every host in `store`, with its group path, then the ones opened - /// from a link. A request logs in to those afresh too, with the link's - /// user and password; a bastion host may refuse a login it gave out for - /// one use. + /// Every host in `store`, with its group path, then the ones connected + /// to without saving them. A request logs in to those afresh too, with + /// the password kept in memory; a bastion host may refuse a login it + /// gave out for one use. pub fn all(store: &HostStore) -> Vec { let saved = store.hosts().iter().map(|host| { let names = host diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 53f6255..277a5d3 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -420,7 +420,7 @@ fn a_host_opened_from_a_link_is_listed_as_temporary_and_logged_in_to_afresh() { // A table says so where the group goes. let (_, table, _) = run(&fixture.socket, Request::List { query: None }, false); assert!( - table.lines().nth(2).unwrap().contains("(外部连接)"), + table.lines().nth(2).unwrap().contains("(未保存)"), "{table}" ); } diff --git a/src/connection.rs b/src/connection.rs index dab0826..92e083c 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -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 } diff --git a/src/host/host_dialog.rs b/src/host/host_dialog.rs index 0e5fc87..8eb4ef8 100644 --- a/src/host/host_dialog.rs +++ b/src/host/host_dialog.rs @@ -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>; pub struct HostForm { store: Entity, editing: Option, + /// A 临时连接's form: no group, route or notes, a name it may leave out, + /// and a commit that saves nothing. + temporary: bool, name: Entity, address: Entity, port: Entity, @@ -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, + tester: SharedConnectionTester, + window: &mut Window, + cx: &mut Context, + ) -> 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) { 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, + ) -> Option { + 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, 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, + 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 { diff --git a/src/host/mod.rs b/src/host/mod.rs index 6615b1f..f8b799b 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -20,7 +20,10 @@ 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::*; diff --git a/src/host/store.rs b/src/host/store.rs index 032c601..23c1a43 100644 --- a/src/host/store.rs +++ b/src/host/store.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use gpui_kit::{Context, EventEmitter, SharedString}; +use zeroize::Zeroizing; use crate::secrets::{NoSecretStore, SecretRef, SharedSecretStore, TemporarySecretStore}; @@ -46,12 +47,11 @@ mod snippets; /// A temporary host is one connected to without saving it, kept apart from /// the saved ones: found by id like any host, so its tabs work as a saved /// host's do, but never listed, never written to the database and gone with -/// its last tab; its password stays in `secrets`' memory. The only kind so -/// far is the external one, which a bastion host opened with an `ssh://` or +/// its last tab; its password stays in `secrets`' memory. There are two +/// kinds: a 临时连接 typed into ShellRS's own dialog, with every feature, and +/// an external one, which a bastion host opened with an `ssh://` or /// `sftp://` link (外部连接, see [`SshLink`]) and whose terminals get one -/// channel alone. A -/// 临时连接 typed into ShellRS, to come, will be temporary with every -/// feature. +/// channel alone. pub struct HostStore { groups: Vec, hosts: Vec, @@ -301,8 +301,8 @@ impl HostStore { .find(|host| host.id == id) } - /// The temporary hosts, oldest first. Only the external CLI lists them; - /// nothing else that lists hosts does. + /// The temporary hosts (临时连接 and 外部连接), oldest first. Only the + /// external CLI lists them; nothing else that lists hosts does. pub fn temporary_hosts(&self) -> &[Host] { &self.temporary } @@ -329,14 +329,41 @@ impl HostStore { } pub fn insert_external_unnotified(&mut self, link: SshLink) -> HostId { + let id = self.insert_temporary_unnotified(link.draft(), link.password); + self.external.insert(id); + id + } + + /// Connect to `draft` without saving it (临时连接): a temporary host the + /// tabs find by id, kept in memory until [`Self::remove_temporary`], with + /// `password` in memory too. It may log in with a saved credential; its + /// group, route and notes are not looked at, it goes directly. + pub fn insert_temporary( + &mut self, + draft: HostDraft, + password: Option>, + cx: &mut Context, + ) -> HostId { + let id = self.insert_temporary_unnotified(draft, password); + cx.notify(); + id + } + + pub fn insert_temporary_unnotified( + &mut self, + draft: HostDraft, + password: Option>, + ) -> HostId { + let mut draft = normalized(&self.credentials, draft); + draft.group = None; + draft.route = Route::Direct; let id = HostId(self.next_host_id); self.next_host_id += 1; - let mut host = Host::new(id, link.draft()); + let mut host = Host::new(id, draft); // What the external CLI knows it by while it lasts. host.public_id = self.unused_public_id(); self.temporary.push(host); - self.external.insert(id); - if let Some(password) = link.password { + if let Some(password) = password.filter(|password| !password.is_empty()) { self.secrets.remember(id.0, password); } id @@ -897,9 +924,10 @@ impl HostStore { .direct_login(host) .with_route(self.route_login(&host.route)); // A temporary host's password is its own, never a saved host's for - // the same `user@host:port`; and a bastion host that opened a link - // gets the terminal alone. - if self.is_temporary(host.id) { + // the same `user@host:port` (a password credential's is still the + // credential's); and a bastion host that opened a link gets the + // terminal alone. + if self.is_temporary(host.id) && matches!(login.password, SecretRef::Password { .. }) { login.password = SecretRef::temporary(host.id.0); } login.shell_only = self.is_external(host.id); @@ -942,10 +970,12 @@ impl HostStore { .count() } - /// Every host's login, to tell afterwards whose changed. + /// Every host's login, temporary ones included, to tell afterwards + /// whose changed. fn logins(&self) -> Vec<(HostId, HostLogin)> { self.hosts .iter() + .chain(&self.temporary) .map(|host| (host.id, self.login_of(host))) .collect() } @@ -1055,7 +1085,7 @@ impl HostStore { credential.keychain_id = keychain_id; credential.sort_order = sort_order; let user = credential.user.clone(); - for host in &mut self.hosts { + for host in self.hosts.iter_mut().chain(&mut self.temporary) { if host.credential == Some(id) { host.user = user.clone(); } @@ -1093,7 +1123,9 @@ impl HostStore { }; let auth = self.credentials.remove(index).kind.without_credential(); let mut released = Vec::new(); - for host in &mut self.hosts { + // A temporary host using it goes on as its saved ones do, though + // the database never hears of it. + for host in self.hosts.iter_mut().chain(&mut self.temporary) { if host.credential == Some(id) { host.credential = None; host.auth = auth; @@ -2584,6 +2616,59 @@ mod tests { SshLink::parse(text, Some("堡垒机")).unwrap() } + #[test] + fn a_temporary_connection_goes_directly_and_may_use_a_credential() { + let mut store = HostStore::empty(); + let saved = store.insert_unnotified(draft("web", None)); + let ops = store.insert_credential_unnotified(CredentialDraft::new( + "运维", + CredentialKind::Password, + "ops", + )); + let deploy = store.insert_credential_unnotified( + CredentialDraft::new("部署", CredentialKind::Key, "deploy").with_key_path("/tmp/id"), + ); + // A group and a route are a saved host's, and are not looked at. + let typed = store.insert_temporary_unnotified( + draft("typed", Some(GroupId(9))).with_route(Route::Jump(vec![Some(saved)])), + Some(Zeroizing::new("p@ss".into())), + ); + let with_ops = + store.insert_temporary_unnotified(draft("with-ops", None).with_credential(ops), None); + let with_key = store + .insert_temporary_unnotified(draft("with-key", None).with_credential(deploy), None); + assert_eq!(store.hosts().len(), 1); + let host = store.host(typed).unwrap(); + assert_eq!((host.group, &host.route), (None, &Route::Direct)); + assert!(!store.is_external(typed)); + + // Its own password is in memory; a password credential's is the + // credential's, as for a saved host. + let login = store.login(typed).unwrap(); + assert_eq!(login.password, SecretRef::temporary(typed.0)); + assert!(!login.shell_only); + let login = store.login(with_ops).unwrap(); + assert_eq!(login.user, "ops"); + assert_eq!( + login.password, + store.credential(ops).unwrap().password_secret() + ); + assert_eq!( + store.login(with_key).unwrap().password, + SecretRef::temporary(with_key.0) + ); + + // It follows the credential, and logs in on its own once that goes. + store.update_credential_unnotified( + ops, + CredentialDraft::new("运维", CredentialKind::Password, "admin"), + ); + assert_eq!(store.host(with_ops).unwrap().user.as_ref(), "admin"); + assert_eq!(store.remove_credential_unnotified(deploy), [with_key]); + let host = store.host(with_key).unwrap(); + assert_eq!((host.credential, host.user.as_ref()), (None, "deploy")); + } + #[test] fn a_link_host_is_found_by_id_but_never_listed() { let keychain = Arc::new(crate::secrets::InMemorySecretStore::default()); diff --git a/src/ssh/connection.rs b/src/ssh/connection.rs index 74c9fd5..59193d9 100644 --- a/src/ssh/connection.rs +++ b/src/ssh/connection.rs @@ -506,6 +506,20 @@ impl std::fmt::Display for MissingCredential { impl std::error::Error for MissingCredential {} +/// A login without a password (「无密码」) that the server wanted a password +/// from. Typed, so that a connection test with the password left empty can +/// say that rather than point at 「无密码」. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PasswordWanted; + +impl std::fmt::Display for PasswordWanted { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」") + } +} + +impl std::error::Error for PasswordWanted {} + impl SshPrompts { pub fn new( events: Arc bool + Send + Sync>, @@ -902,7 +916,7 @@ async fn authenticate( if methods.contains(&MethodKind::Password) || methods.contains(&MethodKind::KeyboardInteractive) { - bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」"); + return Err(PasswordWanted.into()); } bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥"); } diff --git a/src/ssh/tester.rs b/src/ssh/tester.rs index 3befd52..82ecafb 100644 --- a/src/ssh/tester.rs +++ b/src/ssh/tester.rs @@ -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::().is_none() + && error.chain().any(|cause| cause.is::()) => + { + Err(MissingCredential::Password { rejected: false }.to_string()) + } Err(error) => Err(describe_test_failure(&error, *lock(&host_trust))), } } diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index d696a0c..9738ea1 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -1348,6 +1348,46 @@ mod tests { ); } + /// 测试连接 with the password left empty goes without one, as 「无密码」 + /// does: the agent gets its turn, and a server that wants a password + /// hears that none was typed. + #[cfg(unix)] + #[test] + fn a_test_with_the_password_left_empty_goes_without_one() { + let key = random_key(); + let Some(server) = start_server(TestAuth::PublicKey(key.public_key().clone())) else { + eprintln!("loopback sockets are unavailable in this sandbox; skipping"); + return; + }; + let Some(password_server) = start_server(TestAuth::Password) else { + panic!("loopback became unavailable during the test") + }; + let directory = tempfile::tempdir().unwrap(); + let agent = start_agent(&[key]); + let connector = || { + SshConnector::new( + directory.path().join("known_hosts"), + Arc::new(NoSecretStore), + ) + .with_agent(crate::ssh::AgentLocation::At(agent.path.clone())) + }; + let by_password = |port| HostLogin::manual("127.0.0.1", port, "tester", AuthKind::Password); + assert_eq!( + test_through( + connector(), + crate::connection::LoginTest::typed(by_password(server.port)) + ), + Ok(()) + ); + assert_eq!( + test_through( + connector(), + crate::connection::LoginTest::typed(by_password(password_server.port)) + ), + Err("未填写密码".to_string()) + ); + } + #[test] fn a_login_without_a_password_refuses_a_server_that_wants_one() { let Some(server) = start_server(TestAuth::Password) else { diff --git a/src/workspace/title_bar.rs b/src/workspace/title_bar.rs index 59a7ff1..ea79e74 100644 --- a/src/workspace/title_bar.rs +++ b/src/workspace/title_bar.rs @@ -7,8 +7,8 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CatalogIcon, NewHost, NewLocalTerminal, ShowCredentials, ShowForwards, ShowHosts, ShowUpdate, - ToggleHostPanel, ToggleTheme, + CatalogIcon, NewLocalTerminal, NewTemporaryConnection, ShowCredentials, ShowForwards, + ShowHosts, ShowUpdate, ToggleHostPanel, ToggleTheme, }; use crate::shared::tinted; use crate::update::UpdateBadge; @@ -31,7 +31,7 @@ pub fn render_title_bar( cx: &App, ) -> TitleBar { let dark = cx.theme().is_dark(); - let (mode, toggle, new_host, new_local, theme, show_update) = ( + let (mode, toggle, temporary, new_local, theme, show_update) = ( target.clone(), target.clone(), target.clone(), @@ -121,13 +121,16 @@ pub fn render_title_bar( }), ) .child( - Button::new("new-host") + // Saved hosts are made in the host list; up here is the + // quick way in, to a host that is not saved. + Button::new("temporary-connection") .ghost() .small() - .icon(IconName::Plus) - .label("新建主机…") + .icon(CatalogIcon::Plug) + .label("临时连接…") + .tooltip("连接一台主机,不保存") .on_click(move |_, window, cx| { - new_host.dispatch_action(&NewHost, window, cx) + temporary.dispatch_action(&NewTemporaryConnection, window, cx) }), ) .child( diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 98d23cc..f56e522 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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, 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_temporary_connection_dialog, }; use crate::settings::{ Appearance, SettingsPanel, SettingsPanelEvent, SettingsStore, SettingsStoreEvent, @@ -1682,6 +1684,21 @@ impl Workspace { ); } + fn on_new_temporary_connection( + &mut self, + _: &NewTemporaryConnection, + window: &mut Window, + cx: &mut Context, + ) { + open_temporary_connection_dialog( + self.store.clone(), + self.connection_tester.clone(), + self.focus_handle.clone(), + window, + cx, + ); + } + fn on_edit_host(&mut self, action: &EditHost, window: &mut Window, cx: &mut Context) { // A host opened from a link is not saved, so there is nothing to // edit. @@ -2215,6 +2232,7 @@ 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_new_local_terminal)) .on_action(cx.listener(Self::on_edit_host)) .on_action(cx.listener(Self::on_duplicate_host)) diff --git a/tests/ui/connection.rs b/tests/ui/connection.rs index 246f0d8..8dbc95d 100644 --- a/tests/ui/connection.rs +++ b/tests/ui/connection.rs @@ -768,6 +768,129 @@ async fn an_sftp_link_opens_an_sftp_tab_across_the_window(cx: &mut TestAppContex store.read_with(cx, |store, _| assert!(store.host(LINK_HOST).is_none())); } +/// The title bar's 临时连接: a host's login without its group, route or +/// notes, connected to with every feature and saved nowhere. +#[gpui_kit::test] +async fn a_temporary_connection_has_every_feature_and_saves_nothing(cx: &mut TestAppContext) { + use shellrs::app::CloseTerminal; + + let data = tempfile::tempdir().unwrap(); + let path = data.path().join("shellrs.db"); + let keychain = Arc::new(InMemorySecretStore::default()); + let store = HostStore::load(HostDatabase::open(&path).unwrap()) + .unwrap() + .with_secrets(keychain.clone()); + let remote = Arc::new(RecordingRemoteProvider::default()); + let tester = Arc::new(FakeConnectionTester::default()); + let (handle, workspace) = + open_workspace_with_credentials(cx, store, remote.clone(), tester.clone()); + + in_frame(cx, handle, |window, cx| { + window.click("temporary-connection", cx) + }); + in_frame(cx, handle, |window, cx| { + // Only what a login needs. + assert!(window.try_find("host-route").is_none()); + assert!(window.try_find("host-notes").is_none()); + assert!(window.try_find("test-connection").is_some()); + assert_eq!( + window.find("temporary-note").label(), + Some("仅当前使用的临时会话,不会保存到主机列表。") + ); + // The name may be left out. + window.click("host-address", cx); + window.input("10.0.0.9", cx); + window.click("host-port", cx); + window.press("cmd-a", cx); + window.input("2222", cx); + window.click("host-user", cx); + window.press("cmd-a", cx); + window.input("deploy", cx); + window.click("host-password", cx); + window.input("p@ss", cx); + window.click("test-connection", cx); + }); + // 测试连接 tries it as it would connect: directly. + cx.wait_for(handle.into(), Duration::from_secs(3), |window, cx| { + window.render_frame(cx); + window.notifications(cx).len() == 1 + }) + .await; + assert_eq!(tester.routes(), [(LoginRoute::Direct, None)]); + in_frame(cx, handle, |window, cx| window.click("commit", cx)); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.render_frame(cx); + window.find("status-connection").label() == Some("已连接 10.0.0.9") + }) + .await; + + let login = remote.logins().pop().expect("connected"); + assert_eq!( + (login.host.as_str(), login.port, login.user.as_str()), + ("10.0.0.9", 2222, "deploy") + ); + assert_eq!(login.method, LoginMethod::Password); + assert_eq!(login.route, LoginRoute::Direct); + assert_eq!(login.password, SecretRef::temporary(LINK_HOST.0)); + // Every channel it likes, unlike a bastion host's link. + assert!(!login.shell_only); + cx.update_window(handle.into(), |_, window, cx| { + window.render_frame(cx); + assert_eq!( + window.find(("terminal-tab", LINK_TERMINAL)).label(), + Some("10.0.0.9") + ); + assert!(window.try_find(("host-row", LINK_HOST.0)).is_none()); + // The hosts stay where they were. + assert_eq!(window.find("show-hosts").checked(), Some(true)); + for tool in [ + "tool-snippets", + "tool-history", + "tool-docker", + "tool-monitor", + ] { + assert!(window.try_find(tool).is_some(), "{tool}"); + } + }) + .unwrap(); + let store = workspace.read_with(cx, |workspace, _| workspace.store().clone()); + store.read_with(cx, |store, _| { + assert!(store.is_temporary(LINK_HOST)); + assert!(!store.is_external(LINK_HOST)); + assert!(store.hosts().is_empty()); + assert!(store.recent_hosts().next().is_none()); + assert_eq!( + store + .secrets() + .get(&SecretRef::temporary(LINK_HOST.0)) + .unwrap() + .as_deref() + .map(String::as_str), + Some("p@ss") + ); + }); + assert!(keychain.is_empty()); + + // It goes with its tab, password and all. + cx.update_window(handle.into(), |_, window, cx| { + window.dispatch_action(Box::new(CloseTerminal(RemoteTerminalId(LINK_TERMINAL))), cx); + }) + .unwrap(); + cx.run_until_parked(); + store.read_with(cx, |store, _| { + assert!(store.host(LINK_HOST).is_none()); + assert!( + store + .secrets() + .get(&SecretRef::temporary(LINK_HOST.0)) + .unwrap() + .is_none() + ); + }); + let saved = HostStore::load(HostDatabase::open(&path).unwrap()).unwrap(); + assert!(saved.hosts().is_empty()); +} + #[gpui_kit::test] async fn a_link_that_cannot_be_read_says_why_and_opens_nothing(cx: &mut TestAppContext) { let (handle, workspace) = open_workspace_with_credentials( diff --git a/tests/ui/credential.rs b/tests/ui/credential.rs index ee6f82f..68122b2 100644 --- a/tests/ui/credential.rs +++ b/tests/ui/credential.rs @@ -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); diff --git a/tests/ui/host_dialog.rs b/tests/ui/host_dialog.rs index f82e5e1..0d3a77c 100644 --- a/tests/ui/host_dialog.rs +++ b/tests/ui/host_dialog.rs @@ -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 { 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"); diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index cb2f039..e6a7cf0 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -628,7 +628,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 +682,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", "请输入名称"),