diff --git a/CLAUDE.md b/CLAUDE.md index bff3d59..7fa52fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。功能的详细说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 -已确认的产品决定: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)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `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 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 `/proc` 文件,除了跑它的 `sh` 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。 +已确认的产品决定: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)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `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 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;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 条。」,计数仍是主机上的全部。 做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/.md` 为准。 @@ -43,7 +43,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `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())` 渲染;`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 线程解析」)、`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`)。 -- `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`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。 +- `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)、`exec.rs`(右侧栏的工具共用的 `ExecTarget`:在哪个远程终端的连接上执行命令,和轮询回答的 `exec_answer`)。远程主机的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),连接的往返延迟 `Latency` 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `HostLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `/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;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。 - `secrets/` — 系统钥匙串(`keyring` 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。`SecretRef` 决定归属:密码按 `user@host:port`(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 `keychain_id`(`credential:`;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。`SecretStore` 的三个方法全是阻塞的,**只能**在后台执行器或传输层工作线程上调用。`InMemorySecretStore` 给测试,`NoSecretStore` 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 `SERVICE` = `shellrs`。 @@ -57,7 +57,8 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 - `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 -- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel` 与 `MonitorTarget`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`),不依赖 `workspace`。 +- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。 +- `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。 - `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 @@ -109,9 +110,10 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **本地 / 动态转发先监听再登录,监听器跨重连保留。** 端口被占用在任何问答之前就失败(`a_taken_local_port_fails_before_anyone_is_asked_anything`);重连期间端口不会被别人占走,这段时间进来的连接由 `turning_away` 直接关掉。停止时要 `handle.disconnect`:只丢句柄不够,每个通道流都攥着一个发送端,连接会一直开着;`JoinSet::shutdown` 在 runtime 里等连接任务结束,因为 `ChannelStream` 在 drop 时 `tokio::spawn`。 - **首次登录可以问,重连不问。** 第一次用交互式 `SshPrompts`;断线后的重连用 `.non_interactive()` 而且陌生主机直接答否,后台的转发因此不会自己弹框。重连只对网络类错误重试(`ssh::is_network_error`,要显式认 `russh::Error::IO`,它是 transparent 的、链上没有单独的 `io::Error`),缺密码、主机密钥问题立即以「连接中断,自动重连失败:…」停止。远程转发在重连时 `tcpip_forward` 被拒也算可重试:服务器可能还替刚死的那条连接占着端口。 - **停止中的运行占着自己的位置。** 本地监听端口要等旧的工作线程真的结束才空出来,所以 `ForwardManager` 的 `Link::stopping` 在发出 `Stop` 后保留这条链接直到收到终结事件:`Some(false)` 对外显示为已停止,`Some(true)` 表示结束后立刻再启动(重启,或停止途中又点了启动)。直接另起一个工作线程会撞上「端口已被占用」。 -- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。 -- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。 +- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`、`NetstatPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。 +- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms,`terminal::exec_answer`,两个工具共用;它们的目标是 `terminal::ExecTarget`),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。 - **系统监控(`monitor/`)是纯解析加一个面板。** `linux.rs` 是命令 `command(Parts { system, disks })`(每部分以 `@@名称` 行开头:`SYSTEM` 是 `uname`、`/etc/os-release` 和 CPU 型号,每次连接一次;`LOAD` 每次都有,只用 `read` / `case` / `echo` 这些内建命令读 `/proc/uptime`、`/proc/stat`(读到第一行不是 `cpu` 就停,后面很长的 `intr` 行不读)、`/proc/meminfo`(读到 `SwapFree` 就停)、`/proc/net/dev` 和 `/proc/net/route`(找默认路由的网卡);`DISKS` 是 `df`)和纯函数 `parse`(→ `Parsed::Reading(Box)` 或 `Unsupported(uname -s)`;没有 `/proc/stat` 的读数就算不支持,cmd.exe 什么都不打印也落在这里;`system` 和 `disks` 只在这次读数要了时才有)。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`、把路径换过去,用本机的 `sh` 真跑脚本再解析,守着内建命令读到的和 `cat` / `grep` 一样;`every_command_is_valid_sh` 对四种组合跑 `sh -n`。`model.rs` 是读数 `Reading`(CPU 的 busy / idle 节拍、内存、网卡的累计字节,要了时才有的系统信息和磁盘)、`Tracker`(和上一次读数比出 `Snapshot`:CPU 百分比、每核百分比、网速;第一次、相隔超过 10 秒、计数变小(重启)都不出比率)和格式化(`format_bytes` 按 1024、两位小数,`format_rate`、`format_percent`、`format_uptime`)、`Load::of`(70 / 90 分档)。`MonitorPanel` 按终端 id 存每台的 `Tracker`、最近一次 `Snapshot`、系统信息(连同读它时终端的连接代次 `TerminalView::generation`,重连可能连到另一台机器,代次变了就重读)和磁盘(连同读它的时刻,满 30 秒 `DISK_INTERVAL` 再读),切回来马上有数;每次读数前 `HostState::parts_due` 决定要哪几部分;`set_target(target, active)` 变了就重启读数任务(丢掉任务就停);时间用 `cx.background_executor().now()`,所以测试时钟下比率也对。卡片是 gpui-kit 的 `GroupBox::fill()`,环是 `ProgressCircle`,条是 `Progress`,会变的数字用等宽字体(`numeric`)免得跳动。一行放几根竖条按上一帧量到的宽度算(同路径标签:竖条那一行里的 `canvas` 在 prepaint 记下宽度,变了就 `on_next_frame` 通知面板重画;`bars_per_row` 是纯函数,竖条 0.375 rem、间隔 0.125 rem),还没量到之前那一行限高一行、`overflow_hidden`,不会先闪出几行。展开 / 折叠按钮(`unfold_button`:ghost xsmall,文字后面跟 ⌄ / ⌃,`toggled`)派发 `ToggleMonitorDetail(MonitorDetail::{Cores, Interfaces})`,工作区经 `ToolSidebar::toggle_monitor_detail` 转给 `MonitorPanel::toggle`(一个命令一个处理器,同 SFTP 队列的展开);按钮派发在工作区的 focus handle 上(`MonitorPanel::new(dispatch)`),终端没焦点也能用。主网卡的挑选是纯函数 `Snapshot::main_interface`(`busy_interfaces` 是有过流量、不是 `lo` 的那些)。面板的滚动用 `overflow_y_scrollbar()`(见下面「滚动条挂在不滚动的外层上」)。 +- **网络连接(`netstat/`)读一次,按需刷新。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:先 `export PATH=$PATH:/usr/sbin:/sbin`(Debian 上普通用户的 PATH 里没有 sbin,有的发行版把 `ss` 放在那里),`@@uid` 是 `id -u`(是不是 root),`@@users` 用内建命令读 `/etc/passwd` 得到「uid 名称」,然后有 `ss` 就 `ss -tuanpe | awk …`:awk 跳过表头、只印前 `LIMIT`(3000)行、在 `@@counts` 下按「协议 状态」数**全部**的,所以列表被截断时计数仍然准确(`ss` 先列 UDP 和监听的,截掉的只会是连接);没有 `ss` 就对 `/proc/net/{tcp,tcp6,udp,udp6}` 中存在的每个文件跑一个 awk(`-v p=$f`),印出地址、对端、状态、uid、inode,同样截断和计数。awk 程序按原样写在常量里,由 `double_quoted` 转义成 sh 的双引号串(`$`、`"`);不能用 `!`(csh 会展开,所以写 `$1 == "Netid" { next }` 而不是 `!=`),也不能出现 `\\`(fish 在单引号里把它读成一个反斜杠),`the_command_survives_any_login_shell` 守着。`ss` 省略 `uid:0`,所以有 inode、没有 uid 的是 root 的;`ino:0` 的没有主人(TIME_WAIT),用户留空。进程名可能带空格,所以 `parse_ss` 按位置跳过前 6 个字段再找 `users:((…))`,老版本 `ss` 的 `("name",pid,fd)` 也认。`/proc` 的地址是内存里的 32 位字(小端),IPv6 是四个这样的字,`proc_address` 照 `ss` 的写法输出(端口 0 写 `*`,IPv6 加方括号)。`the_script_reads_ss_or_else_proc` 在临时目录里摆好假的 `/proc/net`、`/etc/passwd` 和一个假 `ss` 脚本,用本机的 `sh` 和 awk 真跑两条路。面板:`set_target` 变了(换终端、显示 / 隐藏、换工具)就重读,之外只有刷新按钮读(派发 `RefreshConnections`,工作区经 `ToolSidebar::refresh_connections` 转给 `NetstatPanel::refresh`);搜索框和两个下拉是面板自己的状态(同左侧列表的搜索,不是动作),改了就滚回顶上。列表用 `uniform_list`,它按第一项的高度排所有项,所以**每张卡片的行数必须一样**(没有进程也画「进程 —」那一行),改卡片时别加只有部分卡片才有的行;滚动条挂在不滚动的 `netstat-list` 上。 - **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。 - **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。 @@ -183,4 +185,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`(系统监控)、`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`)。**不要在 `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`,状态行的 `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-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在)与占位说明 `tool-placeholder`(`aria_label` 是说明文字);当前标签不是远程终端时 `tool-switch` 也不存在。系统监控的有:面板 `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」)。假终端 `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-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `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`(系统监控)、`netstat`(网络连接)、`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`)。**不要在 `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`,状态行的 `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-connections` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在)与占位说明 `tool-placeholder`(`aria_label` 是说明文字);当前标签不是远程终端时 `tool-switch` 也不存在。系统监控的有:面板 `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`,只有看得见的卡片在元素树里)。假终端 `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-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `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 fe1ba16..258a3b6 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -135,9 +135,13 @@ shellrs download <远程路径> <本地路径> - **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。 - **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。 -## 右侧栏与系统监控 +## 右侧栏:网络连接与系统监控 -当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前只有系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示系统监控的图标。 +当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前网络连接和系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示这两个工具的图标。 + +**网络连接**列出远程 Linux 主机上所有的 TCP、UDP 连接和监听端口,每条一张卡片:协议、状态、本地和远端地址,占用它的进程和 PID,以及所属用户。右上角写明这是「监听端口」「入站连接」(连到本机监听的端口)还是「出站连接」。顶部是总数、监听端口数和已连接数;可以按地址、端口、状态、PID、进程名或用户搜索,也可以按协议和状态筛选。监听的排在最前,然后是已连接的,TIME_WAIT 等正在关闭的排在最后;同一个端口被多个进程共同监听(如 nginx 的多个 worker)时合成一张卡片。 + +列表在打开网络连接或切换终端时读取一次,之后不会自己变化,点右上角的刷新按钮重新读取。数据通过这个终端已有的 SSH 连接读取,不需要再次登录,用的是主机上的 `ss` 命令;没有 `ss` 的主机(如 BusyBox)改为读取 `/proc/net`,这时看不到进程;不是以 root 登录时只能看到自己的进程。这两种情况列表顶部都会写明。连接超过 3000 条时只列出前 3000 条(监听端口总在其中),总数仍然准确。 **系统监控**显示远程 Linux 主机的: diff --git a/src/app/actions.rs b/src/app/actions.rs index 74377d6..6fcf78a 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -39,6 +39,8 @@ gpui_kit::actions!( ToggleHostPanel, /// Show or hide the right sidebar, whichever tool it is showing. ToggleToolSidebar, + /// Read the network connections of the SSH terminal's host again. + RefreshConnections, /// Show the host list in the left dock. ShowHosts, /// Show the port-forwarding list in the left dock. @@ -231,14 +233,16 @@ pub enum ToolKind { Snippets, History, Docker, + Connections, Monitor, } impl ToolKind { - pub const ALL: [ToolKind; 4] = [ + pub const ALL: [ToolKind; 5] = [ ToolKind::Snippets, ToolKind::History, ToolKind::Docker, + ToolKind::Connections, ToolKind::Monitor, ]; } diff --git a/src/app/assets.rs b/src/app/assets.rs index 70ba9b0..5bd4174 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -52,7 +52,11 @@ icon_assets!( RotateCcwClock, Container, Activity, - Monitor + Monitor, + Network, + RadioTower, + ArrowDownLeft, + ArrowUpRight ] ); diff --git a/src/lib.rs b/src/lib.rs index b40b023..0e3a3f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,9 @@ //! Modules are organized by capability so they can become crates later: //! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` -//! (凭据), `monitor` (系统监控), `secrets` (系统钥匙串), `settings` (设置), -//! `cli` (外部 CLI), `update` (在线升级), `connection` 与 `shared` (跨模块共用), -//! `workspace` (窗口壳), `app` (动作、快捷键、资源). +//! (凭据), `monitor` (系统监控), `netstat` (网络连接), `secrets` (系统钥匙串), +//! `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 +//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). pub mod app; pub mod cli; @@ -15,6 +15,7 @@ pub mod explorer; pub mod forward; pub mod host; pub mod monitor; +pub mod netstat; pub mod secrets; pub mod settings; pub mod sftp; diff --git a/src/monitor/mod.rs b/src/monitor/mod.rs index d735b1f..0305539 100644 --- a/src/monitor/mod.rs +++ b/src/monitor/mod.rs @@ -6,7 +6,7 @@ mod linux; mod model; mod monitor_panel; -pub use monitor_panel::{MonitorPanel, MonitorTarget}; +pub use monitor_panel::MonitorPanel; /// A part of the system monitor that is folded away until asked for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/monitor/monitor_panel.rs b/src/monitor/monitor_panel.rs index 19daa45..09adcc3 100644 --- a/src/monitor/monitor_panel.rs +++ b/src/monitor/monitor_panel.rs @@ -1,7 +1,6 @@ use std::cell::Cell; use std::collections::HashMap; use std::rc::Rc; -use std::sync::mpsc; use std::time::{Duration, Instant}; use gpui_kit::component::{ @@ -25,7 +24,7 @@ use super::model::{ format_bytes, format_percent, format_rate, format_uptime, }; use crate::app::{CatalogIcon, ToggleMonitorDetail}; -use crate::terminal::{ExecResult, RemoteTerminalId, TerminalView}; +use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; /// Time between readings of the load. The second comes sooner: the CPU's /// load and the network's rates need two, and the panel should not sit on @@ -35,18 +34,6 @@ const SECOND_READING: Duration = Duration::from_secs(1); /// Disks are read this seldom: they fill slowly, and `df` is the slowest /// part of a reading. const DISK_INTERVAL: Duration = Duration::from_secs(30); -/// How often a reading on its way is looked for. The answer comes from the -/// terminal's transport thread, which must not wake the UI itself. -const POLL: Duration = Duration::from_millis(50); - -/// The terminal whose host the monitor reads, over that terminal's own -/// connection. -#[derive(Clone, PartialEq)] -pub struct MonitorTarget { - pub terminal: RemoteTerminalId, - pub view: WeakEntity, -} - /// 系统监控: the CPU, memory, network and disks of the host of the SSH /// terminal in front, read every two seconds while the panel shows. /// @@ -58,7 +45,7 @@ pub struct MonitorTarget { /// each terminal's host showed last is kept, so coming back to a terminal /// shows its numbers at once. pub struct MonitorPanel { - target: Option, + target: Option, active: bool, hosts: HashMap, /// Every core's load, not only the first row of it. @@ -144,12 +131,7 @@ impl MonitorPanel { } /// Read `target`'s host while `active`, the panel showing it. - pub fn set_target( - &mut self, - target: Option, - active: bool, - cx: &mut Context, - ) { + pub fn set_target(&mut self, target: Option, active: bool, cx: &mut Context) { if self.target == target && self.active == active { return; } @@ -188,7 +170,12 @@ impl MonitorPanel { let Ok(request) = request else { break }; let (outcome, connection) = match request { None => (Outcome::NotConnected, 0), - Some((reply, connection)) => (wait_for(reply, cx).await, connection), + Some((reply, connection)) => ( + exec_answer(reply, cx) + .await + .map_or(Outcome::NotConnected, Outcome::Output), + connection, + ), }; if this .update(cx, |this, cx| { @@ -235,18 +222,6 @@ impl MonitorPanel { } } -/// Look for the answer until it comes, or until the transport drops the -/// request: the terminal went away. -async fn wait_for(reply: mpsc::Receiver, cx: &mut AsyncApp) -> Outcome { - loop { - match reply.try_recv() { - Ok(result) => return Outcome::Output(result), - Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await, - Err(mpsc::TryRecvError::Disconnected) => return Outcome::NotConnected, - } - } -} - impl Render for MonitorPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let host = self diff --git a/src/netstat/linux.rs b/src/netstat/linux.rs new file mode 100644 index 0000000..183c58a --- /dev/null +++ b/src/netstat/linux.rs @@ -0,0 +1,593 @@ +//! Reading a Linux host's sockets: one command, and the parser for what it +//! prints. + +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use super::model::{Counts, Processes, Protocol, Socket, SocketProcess, SocketState, Table}; + +/// The most sockets a reading lists; the host still counts them all. A busy +/// server has tens of thousands, mostly in TIME_WAIT, more than a reading's +/// output may hold (`ss` writes some 200 bytes a socket). `ss` lists UDP +/// and what listens before the connections, so those always make it. +pub const LIMIT: usize = 3000; + +/// `ss` lines through, the first `LIMIT` of them, and a count of every +/// protocol and state after them. +const SS_AWK: &str = "$1 == \"Netid\" { next } \ +{ n[$1 \" \" $2]++; if (++r <= LIMIT) print } \ +END { print \"@@counts\"; for (k in n) print n[k], k }"; + +/// A `/proc/net` file's address, peer, state, uid and inode, the first +/// `LIMIT` of them, and a count of every state after them. +const PROC_AWK: &str = "FNR > 1 { n[$4]++; if (++r <= LIMIT) print $2, $3, $4, $8, $10 } \ +END { print \"@@counts\"; for (k in n) print n[k], p, k }"; + +/// What 网络连接 runs on the host for a reading: who the login is, the +/// host's user names, and `ss -tuanpe` (every TCP and UDP socket, numeric, +/// with its processes, its uid and inode). A host without `ss` (BusyBox) +/// has `/proc/net` read instead, which names no processes. Each part under +/// an `@@` line of its own. +/// +/// One line, in single quotes for `sh -c`, so that whatever the login shell +/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the +/// script has no single quote, and no `!` for csh to expand. +pub fn command() -> String { + format!( + "sh -c 'export LC_ALL=C PATH=$PATH:/usr/sbin:/sbin; \ + if test -r /proc/net/tcp; then \ + echo @@uid; id -u; \ + echo @@users; while IFS=: read -r name x uid rest; do echo \"$uid $name\"; done < /etc/passwd; \ + if command -v ss >/dev/null 2>&1; then \ + echo @@ss; ss -tuanpe 2>/dev/null | awk {ss}; \ + else for f in tcp tcp6 udp udp6; do if test -r /proc/net/$f; then \ + echo @@$f; awk -v p=$f {proc} /proc/net/$f; fi; done; fi; \ + else echo @@unsupported; uname -s; fi'", + ss = double_quoted(&SS_AWK.replace("LIMIT", &LIMIT.to_string())), + proc = double_quoted(&PROC_AWK.replace("LIMIT", &LIMIT.to_string())), + ) +} + +/// `text` in double quotes for `sh`, which expands nothing in it. +fn double_quoted(text: &str) -> String { + let mut quoted = String::from("\""); + for character in text.chars() { + if matches!(character, '"' | '$' | '`' | '\\') { + quoted.push('\\'); + } + quoted.push(character); + } + quoted.push('"'); + quoted +} + +/// What the command's output said. +#[derive(Debug, PartialEq)] +pub enum Parsed { + Table(Table), + /// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」. + Unsupported(Option), +} + +pub fn parse(output: &str) -> Parsed { + let mut sections: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut current = None; + for line in output.lines() { + if let Some(name) = line.strip_prefix("@@") { + current = Some(name.trim()); + sections.entry(name.trim()).or_default(); + } else if let Some(name) = current { + sections.entry(name).or_default().push(line); + } + } + let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default(); + let first = |name: &str| { + section(name) + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + }; + + if !sections.contains_key("uid") { + // A shell other than sh's (cmd.exe) prints nothing we know. + return Parsed::Unsupported(first("unsupported").map(str::to_owned)); + } + let root = first("uid") == Some("0"); + let users: HashMap<&str, &str> = section("users") + .iter() + .filter_map(|line| line.trim().split_once(' ')) + .collect(); + let user = |uid: &str| users.get(uid).unwrap_or(&uid).to_string(); + + let mut counted = Counts::default(); + let mut sockets = Vec::new(); + let processes = if !sections.contains_key("ss") { + Processes::None + } else if root { + Processes::All + } else { + Processes::Own + }; + if sections.contains_key("ss") { + sockets.extend( + section("ss") + .iter() + .filter_map(|line| parse_ss(line, &user)), + ); + for line in section("counts") { + let mut fields = line.split_whitespace(); + let (Some(count), Some(netid), Some(state)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(netid)) + && let Some(state) = SocketState::from_ss(protocol, state) + { + counted.add(state, count); + } + } + } else { + for file in ["tcp", "tcp6", "udp", "udp6"] { + let Some(protocol) = protocol_of(file) else { + continue; + }; + sockets.extend( + section(file) + .iter() + .filter_map(|line| parse_proc(protocol, line, &user)), + ); + } + for line in section("counts") { + let mut fields = line.split_whitespace(); + let (Some(count), Some(file), Some(state)) = + (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + if let (Ok(count), Some(protocol)) = (count.parse(), protocol_of(file)) + && let Some(state) = SocketState::from_proc(protocol, state) + { + counted.add(state, count); + } + } + } + Parsed::Table(Table::new(sockets, Some(counted), processes)) +} + +/// 「tcp」「udp6」. +fn protocol_of(name: &str) -> Option { + match name { + "tcp" | "tcp6" => Some(Protocol::Tcp), + "udp" | "udp6" => Some(Protocol::Udp), + _ => None, + } +} + +/// A line of `ss -tuanpe`: +/// +/// ```text +/// tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=891,fd=3)) ino:21233 sk:5 <-> +/// ``` +/// +/// `ss` leaves `uid:0` out, so a socket with an inode and no uid is +/// root's; one with inode 0 has no owner (TIME_WAIT). +fn parse_ss(line: &str, user: &impl Fn(&str) -> String) -> Option { + let mut fields = line.split_whitespace(); + let protocol = protocol_of(fields.next()?)?; + let state = SocketState::from_ss(protocol, fields.next()?)?; + let (_received, _sent) = (fields.next()?, fields.next()?); + let (local, peer) = (fields.next()?, fields.next()?); + let mut socket = Socket::new(protocol, state, local.to_owned(), peer.to_owned()); + + // The rest: the processes, whose names may hold spaces, then the + // details. + let rest = after_fields(line, 6); + socket.processes = parse_users(rest); + let detail = |name: &str| { + rest.split_whitespace() + .find_map(|field| field.strip_prefix(name)) + }; + socket.user = match detail("ino:") { + Some("0") | None => None, + Some(_) => Some(user(detail("uid:").unwrap_or("0"))), + }; + Some(socket) +} + +/// `line` past its first `count` fields. +fn after_fields(line: &str, count: usize) -> &str { + let mut rest = line.trim_start(); + for _ in 0..count { + let end = rest.find(char::is_whitespace).unwrap_or(rest.len()); + rest = rest[end..].trim_start(); + } + rest +} + +/// `users:(("nginx",pid=913756,fd=6),("nginx",pid=913757,fd=6))`; an old +/// `ss` writes `("nginx",913756,6)`. +fn parse_users(rest: &str) -> Vec { + let Some(start) = rest.find("users:((") else { + return Vec::new(); + }; + let mut processes: Vec = Vec::new(); + let mut remaining = &rest[start + "users:(".len()..]; + while let Some(open) = remaining.strip_prefix("(\"") { + let Some((name, after)) = open.split_once("\",") else { + break; + }; + let Some((fields, after)) = after.split_once(')') else { + break; + }; + let pid = fields + .split(',') + .next() + .map(|pid| pid.trim_start_matches("pid=")) + .and_then(|pid| pid.parse().ok()); + if let Some(pid) = pid + && !processes.iter().any(|process| process.pid == pid) + { + processes.push(SocketProcess { + name: name.to_owned(), + pid, + }); + } + remaining = after.strip_prefix(',').unwrap_or(after); + } + processes +} + +/// A line of `/proc/net/tcp` cut to address, peer, state, uid and inode: +/// +/// ```text +/// 0100007F:0CEA 00000000:0000 0A 0 12345 +/// ``` +fn parse_proc(protocol: Protocol, line: &str, user: &impl Fn(&str) -> String) -> Option { + let fields: Vec<&str> = line.split_whitespace().collect(); + let [local, peer, state, uid, inode] = fields.as_slice() else { + return None; + }; + let state = SocketState::from_proc(protocol, state)?; + let mut socket = Socket::new(protocol, state, proc_address(local)?, proc_address(peer)?); + socket.user = (*inode != "0").then(|| user(uid)); + Some(socket) +} + +/// 「0100007F:0CEA」 to 「127.0.0.1:3306」, as `ss` writes it: the address +/// in the host's byte order, a 32-bit word at a time, the port in hex; port +/// 0 is 「*」, IPv6 in brackets. +fn proc_address(hex: &str) -> Option { + let (address, port) = hex.split_once(':')?; + let port = match u16::from_str_radix(port, 16).ok()? { + 0 => "*".to_string(), + port => port.to_string(), + }; + let words = (0..address.len() / 8) + .map(|word| { + let word = u32::from_str_radix(address.get(word * 8..word * 8 + 8)?, 16).ok()?; + // `/proc` prints the in-memory word, which is little-endian on + // every architecture Linux servers run on. + Some(word.swap_bytes().to_be_bytes()) + }) + .collect::>>()?; + match words.as_slice() { + [ipv4] => Some(format!("{}:{port}", Ipv4Addr::from(*ipv4))), + [a, b, c, d] => { + let bytes: Vec = [a, b, c, d].into_iter().flatten().copied().collect(); + let ipv6 = Ipv6Addr::from(<[u8; 16]>::try_from(bytes).ok()?); + Some(format!("[{ipv6}]:{port}")) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::netstat::model::Role; + + const SS: &str = "\ +@@uid +0 +@@users +0 root +33 www-data +101 systemd-resolve +@@ss +udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:((\"systemd-resolve\",pid=600,fd=13)) uid:101 ino:20123 sk:1 cgroup:/system.slice/systemd-resolved.service <-> +tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 cgroup:/system.slice/ssh.service <-> +tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913757,fd=6),(\"nginx\",pid=913756,fd=6)) ino:30001 sk:6 v6only:0 <-> +tcp LISTEN 0 511 *:80 *:* users:((\"nginx\",pid=913758,fd=6)) ino:30002 sk:7 v6only:0 <-> +tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4),(\"sshd\",pid=1200,fd=4)) timer:(keepalive,119min,0) ino:123456 sk:9 <-> +tcp ESTAB 0 0 10.0.0.5:80 198.51.100.2:6001 users:((\"Web Content\",pid=4242,fd=9)) uid:33 ino:4444 sk:b <-> +tcp TIME-WAIT 0 0 10.0.0.5:80 198.51.100.2:6000 timer:(timewait,50sec,0) ino:0 sk:a +tcp ESTAB 0 0 [::ffff:10.0.0.5]:3306 [::ffff:10.0.0.9]:39000 ino:777 sk:c uid:999 <-> +@@counts +2 tcp ESTAB +1 udp UNCONN +3 tcp LISTEN +1 tcp TIME-WAIT +1 tcp ESTAB +"; + + fn table(output: &str) -> Table { + match parse(output) { + Parsed::Table(table) => table, + other => panic!("not a table: {other:?}"), + } + } + + #[test] + fn ss_lines_give_the_sockets_their_processes_and_users() { + let table = table(SS); + assert_eq!(table.processes(), Processes::All); + assert!(!table.truncated()); + let line = |local: &str, peer: &str| { + table + .sockets() + .iter() + .find(|socket| socket.local == local && socket.peer == peer) + .unwrap_or_else(|| panic!("no {local} {peer}")) + }; + + let sshd = line("0.0.0.0:22", "0.0.0.0:*"); + assert_eq!(sshd.state, SocketState::Listen); + assert_eq!(sshd.process_label().as_deref(), Some("sshd (PID 891)")); + // No uid: root's. + assert_eq!(sshd.user.as_deref(), Some("root")); + + // Two lines on one port make one, with every worker. + let nginx = line("*:80", "*:*"); + assert_eq!( + nginx.process_label().as_deref(), + Some("nginx (PID 913757 等 3 个)") + ); + + let resolver = line("127.0.0.53%lo:53", "0.0.0.0:*"); + assert_eq!(resolver.protocol, Protocol::Udp); + assert_eq!(resolver.state, SocketState::Listen); + assert_eq!(resolver.user.as_deref(), Some("systemd-resolve")); + + let session = line("10.0.0.5:22", "203.0.113.7:51234"); + assert_eq!(session.role, Role::Inbound); + assert_eq!(session.processes.len(), 2); + + // A name with a space in it. + let browser = line("10.0.0.5:80", "198.51.100.2:6001"); + assert_eq!( + browser.process_label().as_deref(), + Some("Web Content (PID 4242)") + ); + assert_eq!(browser.user.as_deref(), Some("www-data")); + + // No process holds a socket in TIME_WAIT, and no one owns it. + let closing = line("10.0.0.5:80", "198.51.100.2:6000"); + assert_eq!(closing.state, SocketState::TimeWait); + assert!(closing.processes.is_empty()); + assert_eq!(closing.user, None); + + // Another user's socket, seen without root: no process; a uid the + // host has no name for stays a number. + let database = line("[::ffff:10.0.0.5]:3306", "[::ffff:10.0.0.9]:39000"); + assert!(database.processes.is_empty()); + assert_eq!(database.user.as_deref(), Some("999")); + + assert_eq!( + table.counts().summary(), + "共 7 条 · 3 个监听端口 · 3 条已连接" + ); + } + + #[test] + fn a_host_with_more_sockets_than_a_reading_lists_says_how_many() { + let output = SS.replace("1 tcp TIME-WAIT", "40000 tcp TIME-WAIT"); + let table = table(&output); + assert!(table.truncated()); + assert_eq!(table.sockets().len(), 7); + assert_eq!( + table.counts().summary(), + "共 40007 条 · 4 个监听端口 · 3 条已连接" + ); + } + + #[test] + fn proc_lines_are_read_where_there_is_no_ss() { + let output = "\ +@@uid +1000 +@@users +0 root +1000 deploy +@@tcp +0100007F:0CEA 00000000:0000 0A 999 12345 +0500000A:0016 0700A8C0:C350 01 1000 23456 +0500000A:0050 0200A8C0:1770 06 0 0 +@@counts +1 tcp 0A +1 tcp 01 +1 tcp 06 +@@tcp6 +00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 0 777 +@@counts +1 tcp6 0A +@@udp +00000000:0044 00000000:0000 07 0 888 +@@counts +1 udp 07 +"; + let table = table(output); + assert_eq!(table.processes(), Processes::None); + assert!(!table.truncated()); + let lines: Vec<(Protocol, &str, &str, Option<&str>)> = table + .sockets() + .iter() + .map(|socket| { + ( + socket.protocol, + socket.local.as_str(), + socket.peer.as_str(), + socket.user.as_deref(), + ) + }) + .collect(); + assert_eq!( + lines, + [ + (Protocol::Tcp, "[::]:22", "[::]:*", Some("root")), + (Protocol::Tcp, "127.0.0.1:3306", "0.0.0.0:*", Some("999")), + (Protocol::Udp, "0.0.0.0:68", "0.0.0.0:*", Some("root")), + ( + Protocol::Tcp, + "10.0.0.5:22", + "192.168.0.7:50000", + Some("deploy") + ), + (Protocol::Tcp, "10.0.0.5:80", "192.168.0.2:6000", None), + ] + ); + } + + #[test] + fn a_host_that_is_not_linux_says_what_it_is() { + assert_eq!( + parse("@@unsupported\nDarwin\n"), + Parsed::Unsupported(Some("Darwin".into())) + ); + // cmd.exe runs none of it. + assert_eq!(parse(""), Parsed::Unsupported(None)); + } + + #[test] + fn the_command_survives_any_login_shell() { + let command = command(); + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .expect("one sh -c in single quotes"); + assert!(!script.contains('\'')); + assert!(!script.contains('!')); + assert!(!script.contains('\n')); + // fish reads 「\\」 in single quotes as one backslash. + assert!(!script.contains("\\\\")); + } + + #[cfg(unix)] + fn run(script: &str, path: &str) -> String { + let output = std::process::Command::new("sh") + .arg("-c") + .arg(script) + .env("PATH", path) + .output() + .expect("sh runs"); + assert!(output.status.success(), "{output:?}"); + String::from_utf8(output.stdout).expect("utf-8") + } + + /// The script, run by this machine's `sh` with `/proc`, `/etc/passwd` + /// and `ss` stood in for, gives what the parser reads. + #[cfg(unix)] + #[test] + fn the_script_reads_ss_or_else_proc() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("temp dir"); + let at = |path: &str| root.path().join(path); + std::fs::create_dir_all(at("proc/net")).unwrap(); + std::fs::create_dir_all(at("etc")).unwrap(); + std::fs::create_dir_all(at("bin")).unwrap(); + std::fs::write(at("etc/passwd"), "root:x:0:0:root:/root:/bin/bash\n").unwrap(); + let header = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + std::fs::write( + at("proc/net/tcp"), + format!( + "{header} 0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21233 1 0000000000000000 100 0 0 10 0\n" + ), + ) + .unwrap(); + std::fs::write( + at("proc/net/udp"), + format!( + "{header} 1: 3500007F:0035 00000000:0000 07 00000000:00000000 00:00000000 00000000 101 0 20123 2 0000000000000000 0\n" + ), + ) + .unwrap(); + let ss = at("bin/ss"); + std::fs::write( + &ss, + "#!/bin/sh\necho 'Netid State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess'\n\ + echo 'tcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:1'\n\ + echo 'tcp TIME-WAIT 0 0 10.0.0.5:22 10.0.0.9:4000 timer:(timewait,1sec,0) ino:0 sk:2'\n", + ) + .unwrap(); + std::fs::set_permissions(&ss, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let command = command().replace("/proc/", &format!("{}/", at("proc").display())); + let command = command.replace("/etc/passwd", &at("etc/passwd").display().to_string()); + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap(); + let system = std::env::var("PATH").unwrap_or_default(); + + let with_ss = table(&run(script, &format!("{}:{system}", at("bin").display()))); + let lines: Vec<(&str, Option, Option<&str>)> = with_ss + .sockets() + .iter() + .map(|socket| { + ( + socket.local.as_str(), + socket.process_label(), + socket.user.as_deref(), + ) + }) + .collect(); + assert_eq!( + lines, + [ + ("0.0.0.0:22", Some("sshd (PID 891)".into()), Some("root")), + ("10.0.0.5:22", None, None), + ] + ); + assert!(!with_ss.truncated()); + + // Without `ss`, the files themselves; tcp6 and udp6 are missing, + // as on a host with IPv6 turned off. + let script = script.replace("command -v ss ", "command -v no-such-ss "); + let without = table(&run(&script, &system)); + let lines: Vec<(Protocol, &str, Option<&str>)> = without + .sockets() + .iter() + .map(|socket| { + ( + socket.protocol, + socket.local.as_str(), + socket.user.as_deref(), + ) + }) + .collect(); + assert_eq!( + lines, + [ + (Protocol::Tcp, "0.0.0.0:22", Some("root")), + (Protocol::Udp, "127.0.0.53:53", Some("101")), + ] + ); + assert_eq!(without.counts().total, 2); + } + + #[cfg(unix)] + #[test] + fn the_command_is_valid_sh() { + let command = command(); + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap(); + let status = std::process::Command::new("sh") + .args(["-n", "-c", script]) + .status() + .expect("sh runs"); + assert!(status.success()); + } +} diff --git a/src/netstat/mod.rs b/src/netstat/mod.rs new file mode 100644 index 0000000..3954946 --- /dev/null +++ b/src/netstat/mod.rs @@ -0,0 +1,9 @@ +//! 网络连接: the right sidebar's tool that lists the TCP and UDP sockets of +//! the host of the SSH terminal in front, with the processes holding them, +//! read over that terminal's own connection. Linux hosts only, for now. + +mod linux; +mod model; +mod netstat_panel; + +pub use netstat_panel::NetstatPanel; diff --git a/src/netstat/model.rs b/src/netstat/model.rs new file mode 100644 index 0000000..7a6b585 --- /dev/null +++ b/src/netstat/model.rs @@ -0,0 +1,711 @@ +//! What 网络连接 lists, and how its search and filters pick from the list. + +use std::collections::{HashMap, HashSet}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Protocol { + Tcp, + Udp, +} + +impl Protocol { + pub fn label(self) -> &'static str { + match self { + Protocol::Tcp => "TCP", + Protocol::Udp => "UDP", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum SocketState { + /// A TCP socket taking connections, or a UDP socket bound to a port and + /// connected to no peer: either takes what comes to its port. + Listen, + Established, + SynSent, + SynReceived, + FinWait1, + FinWait2, + TimeWait, + Close, + CloseWait, + LastAck, + Closing, +} + +impl SocketState { + /// The state as `ss` names it: 「LISTEN」「ESTAB」「TIME-WAIT」. A UDP + /// socket connected to no peer is 「UNCONN」; a TCP one in that state + /// is bound and neither listening nor connected. + pub fn from_ss(protocol: Protocol, name: &str) -> Option { + Some(match (protocol, name) { + (Protocol::Udp, "UNCONN") => SocketState::Listen, + (Protocol::Tcp, "UNCONN") => SocketState::Close, + (_, "LISTEN") => SocketState::Listen, + (_, "ESTAB") => SocketState::Established, + (_, "SYN-SENT") => SocketState::SynSent, + (_, "SYN-RECV") => SocketState::SynReceived, + (_, "FIN-WAIT-1") => SocketState::FinWait1, + (_, "FIN-WAIT-2") => SocketState::FinWait2, + (_, "TIME-WAIT") => SocketState::TimeWait, + (_, "CLOSE-WAIT") => SocketState::CloseWait, + (_, "LAST-ACK") => SocketState::LastAck, + (_, "CLOSING") => SocketState::Closing, + _ => return None, + }) + } + + /// The state as `/proc/net/tcp` and its kin write it, in hex. UDP uses + /// only two: connected (01) and not (07). + pub fn from_proc(protocol: Protocol, hex: &str) -> Option { + Some(match (protocol, u8::from_str_radix(hex, 16).ok()?) { + (Protocol::Udp, 0x07) => SocketState::Listen, + (Protocol::Udp, 0x01) => SocketState::Established, + (Protocol::Udp, _) => return None, + (Protocol::Tcp, 0x01) => SocketState::Established, + (Protocol::Tcp, 0x02) => SocketState::SynSent, + (Protocol::Tcp, 0x03 | 0x0C) => SocketState::SynReceived, + (Protocol::Tcp, 0x04) => SocketState::FinWait1, + (Protocol::Tcp, 0x05) => SocketState::FinWait2, + (Protocol::Tcp, 0x06) => SocketState::TimeWait, + (Protocol::Tcp, 0x07) => SocketState::Close, + (Protocol::Tcp, 0x08) => SocketState::CloseWait, + (Protocol::Tcp, 0x09) => SocketState::LastAck, + (Protocol::Tcp, 0x0A) => SocketState::Listen, + (Protocol::Tcp, 0x0B) => SocketState::Closing, + (Protocol::Tcp, _) => return None, + }) + } + + /// How the list says it: 「监听」「已连接」, and the TCP name for the + /// states only the TCP-minded look for: 「TIME_WAIT」. + pub fn label(self) -> &'static str { + match self { + SocketState::Listen => "监听", + SocketState::Established => "已连接", + SocketState::SynSent => "SYN_SENT", + SocketState::SynReceived => "SYN_RECV", + SocketState::FinWait1 => "FIN_WAIT1", + SocketState::FinWait2 => "FIN_WAIT2", + SocketState::TimeWait => "TIME_WAIT", + SocketState::Close => "CLOSE", + SocketState::CloseWait => "CLOSE_WAIT", + SocketState::LastAck => "LAST_ACK", + SocketState::Closing => "CLOSING", + } + } + + /// The name netstat gives it, which a search finds as well. + fn netstat_name(self) -> &'static str { + match self { + SocketState::Listen => "LISTEN", + SocketState::Established => "ESTABLISHED", + other => other.label(), + } + } + + /// Where the list puts it: what listens first, then the live + /// connections, the ones on their way out last. + fn rank(self) -> u8 { + match self { + SocketState::Listen => 0, + SocketState::Established => 1, + SocketState::CloseWait => 2, + SocketState::SynSent | SocketState::SynReceived => 3, + SocketState::FinWait1 + | SocketState::FinWait2 + | SocketState::Closing + | SocketState::LastAck + | SocketState::Close => 4, + SocketState::TimeWait => 5, + } + } +} + +/// What a socket is for the host. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + /// Takes what comes to its port. + Listening, + /// Came in to a port something on the host listens on. + Inbound, + /// Went out from the host. + Outbound, +} + +impl Role { + pub fn label(self) -> &'static str { + match self { + Role::Listening => "监听端口", + Role::Inbound => "入站连接", + Role::Outbound => "出站连接", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SocketProcess { + pub name: String, + pub pid: u32, +} + +/// One line of the list: a socket, or the sockets sharing one address +/// (a server's workers listening on the same port). +#[derive(Clone, Debug, PartialEq)] +pub struct Socket { + pub protocol: Protocol, + pub state: SocketState, + /// 「0.0.0.0:22」「[::1]:631」「*:5678」, as `ss` writes it. + pub local: String, + /// 「203.0.113.7:51234」, or 「0.0.0.0:*」 with no peer. + pub peer: String, + /// Empty when the login cannot see it (another user's, without root), + /// and for a socket no process holds (in TIME_WAIT). + pub processes: Vec, + /// Whose it is: the name, or the uid when the host has no name for it. + /// `None` for a socket no process holds. + pub user: Option, + pub role: Role, +} + +impl Socket { + pub fn new(protocol: Protocol, state: SocketState, local: String, peer: String) -> Self { + Self { + protocol, + state, + local, + peer, + processes: Vec::new(), + user: None, + role: Role::Outbound, + } + } + + /// The port it is bound to, `None` for 「*」. + pub fn port(&self) -> Option { + port_of(&self.local) + } + + /// 「sshd (PID 891)」「nginx (PID 913756 等 4 个)」, `None` with none. + pub fn process_label(&self) -> Option { + let first = self.processes.first()?; + Some(match self.processes.len() { + 1 => format!("{} (PID {})", first.name, first.pid), + count => format!("{} (PID {} 等 {count} 个)", first.name, first.pid), + }) + } + + /// What the search looks in, lowercased. + fn haystack(&self) -> String { + let mut haystack = format!( + "{} {} {} {} {}", + self.protocol.label(), + self.state.label(), + self.state.netstat_name(), + self.local, + self.peer, + ); + for process in &self.processes { + haystack.push_str(&format!(" {} {}", process.name, process.pid)); + } + if let Some(user) = &self.user { + haystack.push(' '); + haystack.push_str(user); + } + haystack.to_lowercase() + } +} + +fn port_of(address: &str) -> Option { + address.rsplit_once(':')?.1.parse().ok() +} + +/// How many sockets there are, of all and of the two kinds the summary +/// names. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Counts { + pub total: usize, + pub listening: usize, + pub connected: usize, +} + +impl Counts { + pub fn add(&mut self, state: SocketState, count: usize) { + self.total += count; + match state { + SocketState::Listen => self.listening += count, + SocketState::Established => self.connected += count, + _ => {} + } + } + + /// 「共 96 条 · 15 个监听端口 · 21 条已连接」. + pub fn summary(&self) -> String { + format!( + "共 {} 条 · {} 个监听端口 · {} 条已连接", + self.total, self.listening, self.connected + ) + } +} + +/// Whose processes a reading names. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Processes { + /// Every socket's: the login is root. + All, + /// Only the login's own sockets'. + Own, + /// None: the host has no `ss`, and `/proc/net` names no processes. + None, +} + +/// One reading of a host's sockets, in list order. +#[derive(Debug, PartialEq)] +pub struct Table { + sockets: Vec, + /// Lowercased, beside each socket. + haystacks: Vec, + counts: Counts, + truncated: bool, + processes: Processes, +} + +impl Table { + /// `sockets` as the host listed them. `counted`: how many the host + /// counted, when it listed only so many; then there are more than + /// `sockets`. + pub fn new(sockets: Vec, counted: Option, processes: Processes) -> Self { + let listed = sockets.len(); + let mut merged: Vec = Vec::with_capacity(listed); + let mut seen: HashMap<(Protocol, SocketState, String, String), usize> = HashMap::new(); + for socket in sockets { + let key = ( + socket.protocol, + socket.state, + socket.local.clone(), + socket.peer.clone(), + ); + match seen.get(&key) { + Some(&index) => { + let into = &mut merged[index]; + for process in socket.processes { + if !into.processes.contains(&process) { + into.processes.push(process); + } + } + if into.user.is_none() { + into.user = socket.user; + } + } + None => { + seen.insert(key, merged.len()); + merged.push(socket); + } + } + } + + let listening: HashSet<(Protocol, u16)> = merged + .iter() + .filter(|socket| socket.state == SocketState::Listen) + .filter_map(|socket| Some((socket.protocol, socket.port()?))) + .collect(); + for socket in &mut merged { + socket.role = if socket.state == SocketState::Listen { + Role::Listening + } else if socket + .port() + .is_some_and(|port| listening.contains(&(socket.protocol, port))) + { + Role::Inbound + } else { + Role::Outbound + }; + } + merged.sort_by(|a, b| { + (a.state.rank(), a.protocol, a.port(), &a.local, &a.peer).cmp(&( + b.state.rank(), + b.protocol, + b.port(), + &b.local, + &b.peer, + )) + }); + + let truncated = counted.is_some_and(|counted| counted.total > listed); + let counts = match counted { + Some(counted) if truncated => counted, + _ => merged.iter().fold(Counts::default(), |mut counts, socket| { + counts.add(socket.state, 1); + counts + }), + }; + Self { + haystacks: merged.iter().map(Socket::haystack).collect(), + sockets: merged, + counts, + truncated, + processes, + } + } + + pub fn sockets(&self) -> &[Socket] { + &self.sockets + } + + pub fn counts(&self) -> Counts { + self.counts + } + + /// Whether the host had more sockets than it listed. + pub fn truncated(&self) -> bool { + self.truncated + } + + pub fn processes(&self) -> Processes { + self.processes + } + + /// The sockets `filter` lets through, by their place in `sockets()`. + pub fn filtered(&self, filter: &Filter) -> Vec { + let query = filter.query.trim().to_lowercase(); + self.sockets + .iter() + .zip(&self.haystacks) + .enumerate() + .filter(|(_, (socket, haystack))| { + filter.protocol.lets_through(socket.protocol) + && filter.state.lets_through(socket.state) + && (query.is_empty() || haystack.contains(&query)) + }) + .map(|(index, _)| index) + .collect() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ProtocolFilter { + #[default] + All, + Tcp, + Udp, +} + +impl ProtocolFilter { + pub const ALL: [ProtocolFilter; 3] = [ + ProtocolFilter::All, + ProtocolFilter::Tcp, + ProtocolFilter::Udp, + ]; + + pub fn label(self) -> &'static str { + match self { + ProtocolFilter::All => "全部协议", + ProtocolFilter::Tcp => "TCP", + ProtocolFilter::Udp => "UDP", + } + } + + fn lets_through(self, protocol: Protocol) -> bool { + match self { + ProtocolFilter::All => true, + ProtocolFilter::Tcp => protocol == Protocol::Tcp, + ProtocolFilter::Udp => protocol == Protocol::Udp, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StateFilter { + #[default] + All, + Listen, + Established, + TimeWait, + CloseWait, + /// The states not named above. + Other, +} + +impl StateFilter { + pub const ALL: [StateFilter; 6] = [ + StateFilter::All, + StateFilter::Listen, + StateFilter::Established, + StateFilter::TimeWait, + StateFilter::CloseWait, + StateFilter::Other, + ]; + + pub fn label(self) -> &'static str { + match self { + StateFilter::All => "全部状态", + StateFilter::Listen => SocketState::Listen.label(), + StateFilter::Established => SocketState::Established.label(), + StateFilter::TimeWait => SocketState::TimeWait.label(), + StateFilter::CloseWait => SocketState::CloseWait.label(), + StateFilter::Other => "其他状态", + } + } + + fn lets_through(self, state: SocketState) -> bool { + match self { + StateFilter::All => true, + StateFilter::Listen => state == SocketState::Listen, + StateFilter::Established => state == SocketState::Established, + StateFilter::TimeWait => state == SocketState::TimeWait, + StateFilter::CloseWait => state == SocketState::CloseWait, + StateFilter::Other => !matches!( + state, + SocketState::Listen + | SocketState::Established + | SocketState::TimeWait + | SocketState::CloseWait + ), + } + } +} + +/// What the list is narrowed to. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Filter { + pub protocol: ProtocolFilter, + pub state: StateFilter, + /// Looked for in the addresses, the state, the processes and their + /// PIDs and the user, ignoring case. + pub query: String, +} + +impl Filter { + /// Whether it narrows the list at all. + pub fn is_active(&self) -> bool { + self.protocol != ProtocolFilter::All + || self.state != StateFilter::All + || !self.query.trim().is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn socket(protocol: Protocol, state: SocketState, local: &str, peer: &str) -> Socket { + Socket::new(protocol, state, local.into(), peer.into()) + } + + fn process(name: &str, pid: u32) -> SocketProcess { + SocketProcess { + name: name.into(), + pid, + } + } + + fn sample() -> Table { + let mut sshd = socket( + Protocol::Tcp, + SocketState::Listen, + "0.0.0.0:22", + "0.0.0.0:*", + ); + sshd.processes = vec![process("sshd", 891)]; + sshd.user = Some("root".into()); + let worker = |pid| { + let mut nginx = socket( + Protocol::Tcp, + SocketState::Listen, + "0.0.0.0:80", + "0.0.0.0:*", + ); + nginx.processes = vec![process("nginx", pid)]; + nginx.user = Some("nginx".into()); + nginx + }; + let (first, second) = (worker(100), worker(101)); + Table::new( + vec![ + socket( + Protocol::Tcp, + SocketState::TimeWait, + "10.0.0.5:80", + "198.51.100.2:6000", + ), + socket( + Protocol::Tcp, + SocketState::Established, + "10.0.0.5:48210", + "140.82.112.4:443", + ), + socket( + Protocol::Tcp, + SocketState::Established, + "10.0.0.5:22", + "203.0.113.7:51234", + ), + first, + socket( + Protocol::Udp, + SocketState::Listen, + "127.0.0.53%lo:53", + "0.0.0.0:*", + ), + sshd, + second, + socket( + Protocol::Tcp, + SocketState::CloseWait, + "10.0.0.5:39000", + "10.0.0.9:3306", + ), + ], + None, + Processes::All, + ) + } + + #[test] + fn what_listens_comes_first_and_shares_of_a_port_make_one_line() { + let table = sample(); + let lines: Vec<(&str, &str, Role)> = table + .sockets() + .iter() + .map(|socket| (socket.state.label(), socket.local.as_str(), socket.role)) + .collect(); + assert_eq!( + lines, + [ + ("监听", "0.0.0.0:22", Role::Listening), + ("监听", "0.0.0.0:80", Role::Listening), + ("监听", "127.0.0.53%lo:53", Role::Listening), + ("已连接", "10.0.0.5:22", Role::Inbound), + ("已连接", "10.0.0.5:48210", Role::Outbound), + ("CLOSE_WAIT", "10.0.0.5:39000", Role::Outbound), + ("TIME_WAIT", "10.0.0.5:80", Role::Inbound), + ] + ); + let nginx = &table.sockets()[1]; + assert_eq!( + nginx.process_label().as_deref(), + Some("nginx (PID 100 等 2 个)") + ); + assert_eq!( + table.counts(), + Counts { + total: 7, + listening: 3, + connected: 2 + } + ); + assert!(!table.truncated()); + assert_eq!( + table.counts().summary(), + "共 7 条 · 3 个监听端口 · 2 条已连接" + ); + } + + #[test] + fn a_host_that_counted_more_than_it_listed_is_said_to_have_more() { + let counted = Counts { + total: 50_000, + listening: 12, + connected: 9_000, + }; + let table = Table::new( + vec![socket( + Protocol::Tcp, + SocketState::Listen, + "0.0.0.0:22", + "0.0.0.0:*", + )], + Some(counted), + Processes::All, + ); + assert!(table.truncated()); + assert_eq!(table.counts(), counted); + + // A count that matches the list is no news. + let table = Table::new( + vec![socket( + Protocol::Tcp, + SocketState::Listen, + "0.0.0.0:22", + "0.0.0.0:*", + )], + Some(Counts { + total: 1, + listening: 1, + connected: 0, + }), + Processes::All, + ); + assert!(!table.truncated()); + } + + #[test] + fn the_search_finds_addresses_states_processes_pids_and_users() { + let table = sample(); + let local = |filter: Filter| -> Vec { + table + .filtered(&filter) + .into_iter() + .map(|index| table.sockets()[index].local.clone()) + .collect() + }; + let query = |text: &str| Filter { + query: text.into(), + ..Filter::default() + }; + assert_eq!(local(query("NGINX")), ["0.0.0.0:80"]); + assert_eq!(local(query("101")), ["0.0.0.0:80"]); + assert_eq!(local(query(":22")), ["0.0.0.0:22", "10.0.0.5:22"]); + assert_eq!( + local(query("established")), + ["10.0.0.5:22", "10.0.0.5:48210"] + ); + assert_eq!(local(query("time_wait")), ["10.0.0.5:80"]); + assert_eq!(local(query("203.0.113")), ["10.0.0.5:22"]); + assert_eq!(local(query(" root ")), ["0.0.0.0:22"]); + assert_eq!(local(Filter::default()).len(), 7); + + let udp = Filter { + protocol: ProtocolFilter::Udp, + ..Filter::default() + }; + assert_eq!(local(udp), ["127.0.0.53%lo:53"]); + let other = Filter { + state: StateFilter::Other, + ..Filter::default() + }; + assert!(local(other).is_empty()); + let close_wait = Filter { + state: StateFilter::CloseWait, + ..Filter::default() + }; + assert!(close_wait.is_active()); + assert_eq!(local(close_wait), ["10.0.0.5:39000"]); + assert!(!query(" ").is_active()); + } + + #[test] + fn states_read_from_ss_and_from_proc_agree() { + for (name, hex, state) in [ + ("LISTEN", "0A", SocketState::Listen), + ("ESTAB", "01", SocketState::Established), + ("TIME-WAIT", "06", SocketState::TimeWait), + ("CLOSE-WAIT", "08", SocketState::CloseWait), + ("SYN-RECV", "03", SocketState::SynReceived), + ("FIN-WAIT-2", "05", SocketState::FinWait2), + ] { + assert_eq!(SocketState::from_ss(Protocol::Tcp, name), Some(state)); + assert_eq!(SocketState::from_proc(Protocol::Tcp, hex), Some(state)); + } + assert_eq!( + SocketState::from_ss(Protocol::Udp, "UNCONN"), + Some(SocketState::Listen) + ); + assert_eq!( + SocketState::from_proc(Protocol::Udp, "07"), + Some(SocketState::Listen) + ); + assert_eq!(SocketState::from_ss(Protocol::Tcp, "Netid"), None); + } +} diff --git a/src/netstat/netstat_panel.rs b/src/netstat/netstat_panel.rs new file mode 100644 index 0000000..a027daf --- /dev/null +++ b/src/netstat/netstat_panel.rs @@ -0,0 +1,615 @@ +use std::collections::HashMap; +use std::rc::Rc; +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, IndexPath, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + scroll::ScrollableElement as _, + searchable_list::SearchableListItem, + select::{Select, SelectEvent, SelectState}, + spinner::Spinner, + tag::Tag, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::linux::{self, Parsed}; +use super::model::{ + Filter, Processes, ProtocolFilter, Role, Socket, SocketState, StateFilter, Table, +}; +use crate::app::{CatalogIcon, RefreshConnections}; +use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; + +/// How long a terminal not connected is left before it is asked again. +const RETRY: Duration = Duration::from_secs(2); + +/// 网络连接: every TCP and UDP socket of the host of the SSH terminal in +/// front, with the processes holding them, read when the panel comes on +/// screen and again on 刷新. +/// +/// A list, not a gauge, so it holds still while it is read: what changes +/// underneath shows on the next refresh, not under the pointer. Each +/// reading runs `linux::command` beside the shell, on the terminal's own +/// connection: no second login, nothing asked again. What each terminal's +/// host showed last is kept, so coming back to a terminal shows its list at +/// once while it is read again. The search and the filters stay as they +/// are from one terminal to the next. +pub struct NetstatPanel { + target: Option, + active: bool, + hosts: HashMap, + /// The reading on its way; dropping it stops it. + reading: Option>, + /// Whether a reading is on its way. + loading: bool, + search: Entity, + protocol: Entity>>, + state: Entity>>, + filter: Filter, + scroll_handle: UniformListScrollHandle, + /// The workspace's focus handle: 刷新 dispatches on it, so it works + /// whatever is focused. + dispatch: FocusHandle, + _subscriptions: Vec, +} + +#[derive(Default)] +struct HostState { + table: Option>, + problem: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum Problem { + /// The terminal is not connected, so there is no connection to read on. + NotConnected, + /// Not Linux; `uname -s` when the host said it. + Unsupported(Option), + /// The command did not run, or did not finish. + Failed(String), +} + +impl SearchableListItem for ProtocolFilter { + type Value = Self; + + fn title(&self) -> SharedString { + self.label().into() + } + + fn value(&self) -> &Self::Value { + self + } +} + +impl SearchableListItem for StateFilter { + type Value = Self; + + fn title(&self) -> SharedString { + self.label().into() + } + + fn value(&self) -> &Self::Value { + self + } +} + +impl NetstatPanel { + pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("搜索地址、端口、状态、PID 或进程") + .clean_on_escape() + }); + let protocol = cx.new(|cx| { + SelectState::new( + ProtocolFilter::ALL.to_vec(), + Some(IndexPath::new(0)), + window, + cx, + ) + }); + let state = cx.new(|cx| { + SelectState::new( + StateFilter::ALL.to_vec(), + Some(IndexPath::new(0)), + window, + cx, + ) + }); + let subscriptions = vec![ + cx.subscribe(&search, |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let query = input.read(cx).value().to_string(); + this.set_filter( + Filter { + query, + ..this.filter.clone() + }, + cx, + ); + } + }), + cx.subscribe( + &protocol, + |this, _, event: &SelectEvent>, cx| { + if let SelectEvent::Confirm(Some(protocol)) = event { + this.set_filter( + Filter { + protocol: *protocol, + ..this.filter.clone() + }, + cx, + ); + } + }, + ), + cx.subscribe( + &state, + |this, _, event: &SelectEvent>, cx| { + if let SelectEvent::Confirm(Some(state)) = event { + this.set_filter( + Filter { + state: *state, + ..this.filter.clone() + }, + cx, + ); + } + }, + ), + ]; + Self { + target: None, + active: false, + hosts: HashMap::new(), + reading: None, + loading: false, + search, + protocol, + state, + filter: Filter::default(), + scroll_handle: UniformListScrollHandle::new(), + dispatch, + _subscriptions: subscriptions, + } + } + + /// Read `target`'s host when `active`, the panel showing it, and was + /// not before. + pub fn set_target(&mut self, target: Option, active: bool, cx: &mut Context) { + if self.target == target && self.active == active { + return; + } + self.target = target; + self.active = active; + self.read(cx); + cx.notify(); + } + + /// Read the host again: 刷新. + pub fn refresh(&mut self, cx: &mut Context) { + self.read(cx); + cx.notify(); + } + + fn set_filter(&mut self, filter: Filter, cx: &mut Context) { + if self.filter != filter { + self.filter = filter; + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + } + } + + /// Start reading the target's host over, or stop when there is nothing + /// to show. A terminal not connected is asked again every little while, + /// so its list comes once it connects. + fn read(&mut self, cx: &mut Context) { + self.reading = None; + self.loading = false; + let Some(target) = self.target.clone().filter(|_| self.active) else { + return; + }; + self.loading = true; + self.reading = Some(cx.spawn(async move |this, cx| { + loop { + let reply = this.update(cx, |_, cx| { + let view = target.view.upgrade()?; + view.read(cx).exec(linux::command(), cx) + }); + let Ok(reply) = reply else { return }; + let answer = match reply { + Some(reply) => exec_answer(reply, cx).await, + None => None, + }; + let connected = answer.is_some(); + if this + .update(cx, |this, cx| this.record(target.terminal, answer, cx)) + .is_err() + || connected + { + return; + } + cx.background_executor().timer(RETRY).await; + } + })); + } + + fn record( + &mut self, + terminal: RemoteTerminalId, + answer: Option, + cx: &mut Context, + ) { + let host = self.hosts.entry(terminal).or_default(); + host.problem = match answer { + None => Some(Problem::NotConnected), + Some(Err(error)) => Some(Problem::Failed(error)), + Some(Ok(output)) => match linux::parse(&output) { + Parsed::Table(table) => { + host.table = Some(Rc::new(table)); + None + } + Parsed::Unsupported(system) => Some(Problem::Unsupported(system)), + }, + }; + // Not connected, it keeps asking; the spinner stops all the same. + self.loading = false; + cx.notify(); + } + + fn render_table( + &self, + table: Rc, + problem: Option, + cx: &Context, + ) -> impl IntoElement { + let rows = Rc::new(table.filtered(&self.filter)); + let summary = table.counts().summary(); + let dispatch = self.dispatch.clone(); + let header = v_flex() + .flex_shrink_0() + .px_3() + .pt_3() + .pb_2() + .gap_2() + .child( + h_flex() + .gap_2() + .child( + div() + .id("netstat-summary") + .test_support() + .aria_label(summary.clone()) + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(summary), + ) + .child( + Button::new("netstat-refresh") + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::RefreshCw)) + .loading(self.loading) + .tooltip("刷新") + .accessibility_label("刷新") + .on_click(move |_, window, cx| { + dispatch.dispatch_action(&RefreshConnections, window, cx) + }), + ), + ) + .when_some(problem, |header, problem| { + header.child(render_problem(&problem, cx)) + }) + .when(table.truncated(), |header| { + header.child(note( + "netstat-truncated", + format!("连接太多,只列出了前 {} 条。", linux::LIMIT), + cx.theme().warning, + )) + }) + .map(|header| { + let text = match table.processes() { + Processes::All => return header, + Processes::Own => "当前用户不是 root,只能看到自己的进程。", + Processes::None => "这台主机没有 ss 命令,看不到占用连接的进程。", + }; + header.child(note( + "netstat-processes", + text.into(), + cx.theme().muted_foreground, + )) + }) + .child( + Input::new(&self.search) + .id("netstat-search") + .small() + .cleanable(true) + .prefix(Icon::new(IconName::Search).small()), + ) + .child( + h_flex() + .gap_2() + // Each in a box of its own, or it covers the whole row. + .child( + div().flex_1().min_w_0().child( + Select::new(&self.protocol) + .id("netstat-protocol") + .small() + .accessibility_label("协议"), + ), + ) + .child( + div().flex_1().min_w_0().child( + Select::new(&self.state) + .id("netstat-state") + .small() + .accessibility_label("状态"), + ), + ), + ) + .when(self.filter.is_active(), |header| { + let text = format!("筛选出 {} 条", rows.len()); + header.child( + div() + .id("netstat-matches") + .test_support() + .aria_label(text.clone()) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text), + ) + }); + + let empty = if self.filter.is_active() { + "没有符合条件的连接" + } else { + "没有网络连接" + }; + // The scrollbar goes on the list's box, which does not scroll: on + // the scrolled rows it would scroll away with them. + let list = div() + .id("netstat-list") + .test_support() + .relative() + .flex_1() + .min_h_0() + .border_t_1() + .border_color(cx.theme().border) + .map(|list| { + if rows.is_empty() { + list.child( + div() + .id("netstat-empty") + .test_support() + .aria_label(empty) + .py_8() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(empty), + ) + } else { + // Thousands of sockets on a busy server: only those in + // sight are drawn. + let count = rows.len(); + list.child( + uniform_list("netstat-sockets", count, move |range, _, cx| { + range + .map(|index| { + div() + .px_3() + .pt_2() + .child(render_socket(&table.sockets()[rows[index]], cx)) + }) + .collect::>() + }) + .size_full() + .pb_3() + .track_scroll(&self.scroll_handle), + ) + } + }) + .vertical_scrollbar(&self.scroll_handle); + v_flex().size_full().child(header).child(list) + } +} + +impl Render for NetstatPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let host = self + .target + .as_ref() + .and_then(|target| self.hosts.get(&target.terminal)); + let table = host.and_then(|host| host.table.clone()); + let problem = host.and_then(|host| host.problem.clone()); + v_flex() + .id("netstat") + .test_support() + .size_full() + .map(|panel| match (table, problem) { + // A list from before a failed reading still says something; + // the failure goes above it. + (Some(table), problem @ (None | Some(Problem::Failed(_)))) => { + panel.child(self.render_table(table, problem, cx)) + } + (_, Some(problem)) => panel.child(div().p_3().child(render_problem(&problem, cx))), + (None, None) => panel.child( + h_flex() + .id("netstat-loading") + .test_support() + .gap_2() + .justify_center() + .py_8() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child("正在读取…"), + ), + }) + } +} + +fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement { + let (text, color) = match problem { + Problem::NotConnected => ( + "终端没有连接。连接后这里显示主机的网络连接。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Unsupported(Some(system)) => ( + format!("暂不支持查看 {system} 的网络连接,目前只支持 Linux 主机。"), + cx.theme().muted_foreground, + ), + Problem::Unsupported(None) => ( + "暂不支持查看这台主机的网络连接,目前只支持 Linux 主机。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger), + }; + div() + .id("netstat-message") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(color) + .child(text) +} + +/// A line under the summary: 「连接太多,只列出了前 3000 条。」. +fn note(id: &'static str, text: String, color: Hsla) -> impl IntoElement { + div() + .id(id) + .test_support() + .aria_label(text.clone()) + .text_xs() + .text_color(color) + .child(text) +} + +/// What a state looks like: green for what listens, blue for what is +/// connected, yellow for CLOSE_WAIT, which piles up when a program does not +/// close what its peer has; the rest quiet. +fn state_color(state: SocketState, cx: &App) -> Hsla { + match state { + SocketState::Listen => cx.theme().success, + SocketState::Established => cx.theme().info, + SocketState::CloseWait => cx.theme().warning, + _ => cx.theme().muted_foreground, + } +} + +fn role_icon(role: Role) -> CatalogIcon { + match role { + Role::Listening => CatalogIcon::RadioTower, + Role::Inbound => CatalogIcon::ArrowDownLeft, + Role::Outbound => CatalogIcon::ArrowUpRight, + } +} + +/// A socket's card. Every card has the same lines, so they are all as tall +/// as the list takes them to be. +fn render_socket(socket: &Socket, cx: &App) -> impl IntoElement { + let color = state_color(socket.state, cx); + let process = socket.process_label(); + let label = format!( + "{} {} · 本地 {} · 远端 {} · {} · {}", + socket.protocol.label(), + socket.state.label(), + socket.local, + socket.peer, + process.as_deref().unwrap_or("—"), + socket.user.as_deref().unwrap_or("—"), + ); + let address = |name: &'static str, value: &str| { + h_flex() + .gap_2() + .text_xs() + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child(name), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(cx.theme().mono_font_family.clone()) + .child(value.to_owned()), + ) + }; + v_flex() + .id(SharedString::from(format!( + "netstat-socket:{} {} {}", + socket.protocol.label().to_lowercase(), + socket.local, + socket.peer + ))) + .test_support() + .aria_label(label) + .gap_1() + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().tokens.group_box) + .text_color(cx.theme().group_box_foreground) + .child( + h_flex() + .gap_1p5() + .child(Icon::new(role_icon(socket.role)).small().text_color(color)) + .child(Tag::secondary().small().child(socket.protocol.label())) + .child( + Tag::custom(color.opacity(0.12), color, color.opacity(0.3)) + .small() + .child(socket.state.label()), + ) + .child(div().flex_1()) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(socket.role.label()), + ), + ) + .child(address("本地", &socket.local)) + .child(address("远端", &socket.peer)) + .child( + h_flex() + .gap_2() + .mt_1() + .pt_2() + .border_t_1() + .border_color(cx.theme().border) + .text_xs() + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child("进程"), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .when(process.is_none(), |text| { + text.text_color(cx.theme().muted_foreground) + }) + .child(process.unwrap_or_else(|| "—".into())), + ) + .when_some(socket.user.clone(), |row, user| { + row.child( + div() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child(user), + ) + }), + ) +} diff --git a/src/terminal/exec.rs b/src/terminal/exec.rs new file mode 100644 index 0000000..6678d81 --- /dev/null +++ b/src/terminal/exec.rs @@ -0,0 +1,35 @@ +//! Commands the right sidebar's tools run beside an SSH terminal's shell. + +use std::sync::mpsc; +use std::time::Duration; + +use gpui_kit::*; + +use super::{ExecResult, RemoteTerminalId, TerminalView}; + +/// How often an answer on its way is looked for. It comes from the +/// terminal's transport thread, which must not wake the UI itself. +const POLL: Duration = Duration::from_millis(50); + +/// The SSH terminal whose host a tool reads, over that terminal's own +/// connection. +#[derive(Clone, PartialEq)] +pub struct ExecTarget { + pub terminal: RemoteTerminalId, + pub view: WeakEntity, +} + +/// Look for a command's answer until it comes. `None` when the transport +/// drops the request instead: the terminal went away. +pub async fn exec_answer( + reply: mpsc::Receiver, + cx: &mut AsyncApp, +) -> Option { + loop { + match reply.try_recv() { + Ok(result) => return Some(result), + Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await, + Err(mpsc::TryRecvError::Disconnected) => return None, + } + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index d5f47e0..c0009d9 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,6 +1,7 @@ //! 终端: shared terminal emulation, transports and Dock panels. mod engine; +mod exec; mod font; mod local_pty; mod local_terminal_panel; @@ -11,6 +12,7 @@ mod terminal_view; mod transport; pub use engine::{TerminalCell, TerminalEngine, TerminalEvent, TerminalSnapshot}; +pub use exec::{ExecTarget, exec_answer}; pub use font::{ DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFont, TerminalFontPreview, is_font_installed, monospace_font_families, diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index a58c05d..2791b03 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -9,8 +9,9 @@ use gpui_kit::*; use crate::app::{CatalogIcon, ToggleTool, ToolKind}; use crate::host::{HostId, HostOs, HostStore}; -use crate::monitor::{MonitorDetail, MonitorPanel, MonitorTarget}; -use crate::terminal::{RemoteTerminalId, TerminalView}; +use crate::monitor::{MonitorDetail, MonitorPanel}; +use crate::netstat::NetstatPanel; +use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView}; impl ToolKind { pub fn label(self) -> &'static str { @@ -18,6 +19,7 @@ impl ToolKind { ToolKind::Snippets => "命令片段", ToolKind::History => "历史命令", ToolKind::Docker => "Docker", + ToolKind::Connections => "网络连接", ToolKind::Monitor => "系统监控", } } @@ -27,6 +29,7 @@ impl ToolKind { ToolKind::Snippets => CatalogIcon::CodeXml, ToolKind::History => CatalogIcon::RotateCcwClock, ToolKind::Docker => CatalogIcon::Container, + ToolKind::Connections => CatalogIcon::Network, ToolKind::Monitor => CatalogIcon::Activity, } } @@ -35,7 +38,7 @@ impl ToolKind { /// yet gets every tool, and is told if one cannot work there. pub fn works_on(self, os: Option) -> bool { match self { - ToolKind::Monitor => os.is_none_or(HostOs::is_linux), + ToolKind::Connections | ToolKind::Monitor => os.is_none_or(HostOs::is_linux), ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true, } } @@ -46,6 +49,7 @@ impl ToolKind { ToolKind::Snippets => "tool-snippets", ToolKind::History => "tool-history", ToolKind::Docker => "tool-docker", + ToolKind::Connections => "tool-connections", ToolKind::Monitor => "tool-monitor", } } @@ -57,6 +61,7 @@ impl ToolKind { ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(), ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"), ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"), + ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"), ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"), } } @@ -91,6 +96,7 @@ pub struct ToolSidebar { /// Whether the right dock is open. shown: bool, monitor: Entity, + netstat: Entity, store: Entity, focus_handle: FocusHandle, _subscription: Subscription, @@ -99,14 +105,20 @@ pub struct ToolSidebar { impl ToolSidebar { /// `dispatch` is the workspace's focus handle, which the tools' buttons /// dispatch on. - pub fn new(store: Entity, dispatch: FocusHandle, cx: &mut Context) -> Self { + pub fn new( + store: Entity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut Context, + ) -> Self { // A host renamed is named anew. let subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { tool: ToolKind::default(), terminal: None, shown: false, - monitor: cx.new(|_| MonitorPanel::new(dispatch)), + monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())), + netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)), store, focus_handle: cx.focus_handle(), _subscription: subscription, @@ -138,15 +150,24 @@ impl ToolSidebar { .update(cx, |monitor, cx| monitor.toggle(detail, cx)); } + /// 网络连接's 刷新. + pub fn refresh_connections(&mut self, cx: &mut Context) { + self.netstat.update(cx, |netstat, cx| netstat.refresh(cx)); + } + /// Point each tool at the terminal, running only the one on screen. fn sync_tools(&mut self, cx: &mut Context) { - let target = self.terminal.as_ref().map(|terminal| MonitorTarget { + let target = self.terminal.as_ref().map(|terminal| ExecTarget { terminal: terminal.id, view: terminal.view.clone(), }); - let active = self.shown && self.tool == ToolKind::Monitor; - self.monitor - .update(cx, |monitor, cx| monitor.set_target(target, active, cx)); + let showing = |tool| self.shown && self.tool == tool; + let (monitor, netstat) = (showing(ToolKind::Monitor), showing(ToolKind::Connections)); + self.monitor.update(cx, |panel, cx| { + panel.set_target(target.clone(), monitor, cx) + }); + self.netstat + .update(cx, |panel, cx| panel.set_target(target, netstat, cx)); } /// Show another tool. A focus inside the one going away moves to the @@ -239,6 +260,9 @@ impl Render for ToolSidebar { ToolKind::Monitor => { sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone())) } + ToolKind::Connections => { + sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone())) + } _ => sidebar.child(self.render_placeholder(terminal.host, cx)), } }) diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 81ef5e0..9cb7013 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -11,12 +11,14 @@ use gpui_kit::component::dock::{DockArea, DockPlacement}; use gpui_kit::*; -use crate::app::{CenterTab, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind}; +use crate::app::{ + CenterTab, RefreshConnections, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind, +}; use super::{Workspace, tool_sidebar::ToolTerminal}; /// How wide the right sidebar opens, and the narrowest it can be dragged: -/// the monitor's cards are laid out for this width. +/// the monitor's and the connections' cards are laid out for this width. pub(super) const TOOL_SIDEBAR_WIDTH: Pixels = px(320.); impl Workspace { @@ -121,6 +123,17 @@ impl Workspace { .update(cx, |tools, cx| tools.toggle_monitor_detail(detail, cx)); } + /// 网络连接's 刷新: read the host's sockets again. + pub(super) fn on_refresh_connections( + &mut self, + _: &RefreshConnections, + _: &mut Window, + cx: &mut Context, + ) { + self.tools + .update(cx, |tools, cx| tools.refresh_connections(cx)); + } + /// Hide the sidebar, or show the tool shown last; the first tool on /// offer when that one does not work on this host. Without an SSH /// terminal in front there is nothing to show or hide. diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 58f27c9..694877a 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -330,7 +330,7 @@ impl Workspace { let credential_panel = cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx)); let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel)); - let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), cx)); + let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), window, cx)); // Start with focus in the host panel so window-level actions have a // dispatch path. The workspace's own handle is never focused: the // dialog layer is its child, and a focused ancestor would keep the @@ -2235,6 +2235,7 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_toggle_tool_sidebar)) .on_action(cx.listener(Self::on_toggle_tool)) .on_action(cx.listener(Self::on_toggle_monitor_detail)) + .on_action(cx.listener(Self::on_refresh_connections)) .on_action(cx.listener(Self::on_show_hosts)) .on_action(cx.listener(Self::on_show_forwards)) .on_action(cx.listener(Self::on_new_forward)) diff --git a/tests/ui/main.rs b/tests/ui/main.rs index d1d256b..c6a2c22 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -13,6 +13,7 @@ mod forward; mod host_dialog; mod host_tree; mod monitor; +mod netstat; mod settings; mod sftp; mod terminal; diff --git a/tests/ui/monitor.rs b/tests/ui/monitor.rs index bbc9880..53b13ae 100644 --- a/tests/ui/monitor.rs +++ b/tests/ui/monitor.rs @@ -256,6 +256,7 @@ fn the_monitor_is_not_offered_on_a_host_known_not_to_run_linux(cx: &mut TestAppC }); in_frame(cx, handle, |window, _| { assert!(window.try_find("tool-monitor").is_none()); + assert!(window.try_find("tool-connections").is_none()); assert!(window.find("tool-history").visible()); assert!( window diff --git a/tests/ui/netstat.rs b/tests/ui/netstat.rs new file mode 100644 index 0000000..4f6b867 --- /dev/null +++ b/tests/ui/netstat.rs @@ -0,0 +1,313 @@ +//! 网络连接: the right sidebar's tool that lists the sockets of the SSH +//! terminal's host, read over the terminal's own connection. + +use crate::support::*; + +/// What `ss` says about a small web server, read as root. +const FIRST: &str = "\ +@@uid +0 +@@users +0 root +33 www-data +@@ss +udp UNCONN 0 0 127.0.0.53%lo:53 0.0.0.0:* users:((\"systemd-resolve\",pid=600,fd=13)) uid:101 ino:20123 sk:1 <-> +tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 <-> +tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* users:((\"nginx\",pid=913756,fd=6)) uid:33 ino:30001 sk:6 <-> +tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4)) ino:123456 sk:9 <-> +tcp TIME-WAIT 0 0 10.0.0.5:80 198.51.100.2:6000 timer:(timewait,50sec,0) ino:0 sk:a +@@counts +1 udp UNCONN +2 tcp LISTEN +1 tcp ESTAB +1 tcp TIME-WAIT +"; + +/// The same server a moment later: the visit to port 80 has gone, another +/// login has come. +const SECOND: &str = "\ +@@uid +0 +@@users +0 root +@@ss +tcp LISTEN 0 4096 0.0.0.0:22 0.0.0.0:* users:((\"sshd\",pid=891,fd=3)) ino:21233 sk:5 <-> +tcp ESTAB 0 0 10.0.0.5:22 203.0.113.7:51234 users:((\"sshd\",pid=1234,fd=4)) ino:123456 sk:9 <-> +tcp ESTAB 0 0 10.0.0.5:22 203.0.113.8:40000 users:((\"sshd\",pid=1300,fd=4)) ino:123457 sk:b <-> +@@counts +1 tcp LISTEN +2 tcp ESTAB +"; + +fn open_connections( + cx: &mut TestAppContext, + factory: Arc, +) -> WindowHandle { + let (handle, _) = open_workspace_with_remote_factory(cx, HostStore::seed(), factory); + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| { + window.click("tool-connections", cx) + }); + handle +} + +async fn wait_for_label( + cx: &mut TestAppContext, + handle: WindowHandle, + id: &'static str, + label: &str, +) { + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window + .try_find(id) + .is_some_and(|element| element.label() == Some(label)) + }) + .await; +} + +#[gpui_kit::test] +async fn the_connections_list_the_hosts_sockets_with_their_processes(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST])); + let handle = open_connections(cx, factory.clone()); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 5 条 · 3 个监听端口 · 1 条已连接", + ) + .await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(), + Some("网络连接") + ); + assert_eq!(window.find("tool-connections").checked(), Some(true)); + assert_eq!( + window + .find("netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*") + .label(), + Some("TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root") + ); + assert_eq!( + window + .find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*") + .label(), + Some("TCP 监听 · 本地 0.0.0.0:80 · 远端 0.0.0.0:* · nginx (PID 913756) · www-data") + ); + // No process holds a socket in TIME_WAIT. + assert_eq!( + window + .find("netstat-socket:tcp 10.0.0.5:80 198.51.100.2:6000") + .label(), + Some("TCP TIME_WAIT · 本地 10.0.0.5:80 · 远端 198.51.100.2:6000 · — · —") + ); + // Root sees everything, and the list is whole: nothing to say. + assert!(window.try_find("netstat-truncated").is_none()); + assert!(window.try_find("netstat-processes").is_none()); + assert!(window.try_find("netstat-matches").is_none()); + }); + let command = &factory.exec_commands()[0]; + assert!(command.starts_with("sh -c '") && command.contains("ss -tuanpe")); + + // A list holds still: nothing more runs until it is asked to. + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), 1); +} + +#[gpui_kit::test] +async fn the_search_narrows_the_list(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST])); + let handle = open_connections(cx, factory); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 5 条 · 3 个监听端口 · 1 条已连接", + ) + .await; + + in_frame(cx, handle, |window, cx| { + window.click("netstat-search", cx); + window.input("sshd", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("netstat-matches").label(), Some("筛选出 2 条")); + assert!( + window + .try_find("netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*") + .is_some() + ); + assert!( + window + .try_find("netstat-socket:tcp 10.0.0.5:22 203.0.113.7:51234") + .is_some() + ); + assert!( + window + .try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*") + .is_none() + ); + // The summary is still the host's. + assert_eq!( + window.find("netstat-summary").label(), + Some("共 5 条 · 3 个监听端口 · 1 条已连接") + ); + }); + + // A PID finds its process. + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("913756", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("netstat-matches").label(), Some("筛选出 1 条")); + assert!( + window + .try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*") + .is_some() + ); + }); + + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("mysql", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("netstat-empty").label(), + Some("没有符合条件的连接") + ); + }); +} + +#[gpui_kit::test] +async fn refresh_reads_the_host_again(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_connections(cx, factory.clone()); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 5 条 · 3 个监听端口 · 1 条已连接", + ) + .await; + + in_frame(cx, handle, |window, cx| window.click("netstat-refresh", cx)); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 3 条 · 1 个监听端口 · 2 条已连接", + ) + .await; + in_frame(cx, handle, |window, _| { + assert!( + window + .try_find("netstat-socket:tcp 10.0.0.5:22 203.0.113.8:40000") + .is_some() + ); + assert!( + window + .try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*") + .is_none() + ); + }); + assert_eq!(factory.exec_count(), 2); +} + +#[gpui_kit::test] +async fn the_connections_are_read_each_time_they_come_on_screen_and_only_then( + cx: &mut TestAppContext, +) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_connections(cx, factory.clone()); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 5 条 · 3 个监听端口 · 1 条已连接", + ) + .await; + + // Another tool: nothing runs. + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), 1); + + // Back, the list it had shows at once while it is read again. + in_frame(cx, handle, |window, cx| { + window.click("tool-connections", cx) + }); + in_frame(cx, handle, |window, _| { + assert!( + window + .try_find("netstat-socket:tcp 0.0.0.0:80 0.0.0.0:*") + .is_some() + ); + }); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 3 条 · 1 个监听端口 · 2 条已连接", + ) + .await; + assert_eq!(factory.exec_count(), 2); +} + +#[gpui_kit::test] +async fn the_connections_say_what_the_login_cannot_see_and_when_there_are_too_many( + cx: &mut TestAppContext, +) { + let output = FIRST + .replacen("@@uid\n0\n", "@@uid\n1000\n", 1) + .replace("1 tcp TIME-WAIT", "50000 tcp TIME-WAIT"); + let handle = open_connections(cx, Arc::new(FakeTerminalFactory::answering(&[&output]))); + wait_for_label( + cx, + handle, + "netstat-summary", + "共 50004 条 · 3 个监听端口 · 1 条已连接", + ) + .await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("netstat-truncated").label(), + Some("连接太多,只列出了前 3000 条。") + ); + assert_eq!( + window.find("netstat-processes").label(), + Some("当前用户不是 root,只能看到自己的进程。") + ); + }); +} + +#[gpui_kit::test] +async fn the_connections_wait_for_the_terminal_to_connect(cx: &mut TestAppContext) { + // web-01's terminal is the first started, and exits at once. + let handle = open_connections(cx, Arc::new(FakeTerminalFactory::exit_first())); + wait_for_label( + cx, + handle, + "netstat-message", + "终端没有连接。连接后这里显示主机的网络连接。", + ) + .await; +} + +#[gpui_kit::test] +async fn the_connections_say_they_read_linux_hosts_only(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nDarwin\n"])); + let handle = open_connections(cx, factory); + wait_for_label( + cx, + handle, + "netstat-message", + "暂不支持查看 Darwin 的网络连接,目前只支持 Linux 主机。", + ) + .await; +}