diff --git a/CLAUDE.md b/CLAUDE.md index 7fa52fd..5704106 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% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 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 条。」,计数仍是主机上的全部。 +已确认的产品决定: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 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(`/proc//stat` 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(`PF_KTHREAD`);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 `top` 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;**点进程**打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(`top` 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 `/proc//cmdline`(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。**右键进程**有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。 做 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` 为准。 @@ -58,6 +58,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `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`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`、`ExecTarget`),不依赖 `workspace`。 +- `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。 - `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。 - `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `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+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 @@ -76,7 +77,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。 - `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。 - `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。 -- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、校验错误的通知 `form_error_notification` 与收起它的 `dismiss_form_error`(见下面「表单的校验错误一律弹错误通知」)、端口字段的 `parse_port` 和删除确认 `confirm_delete`,侧栏三个列表的行 tooltip `RowTooltips` / `RowTooltip`(见下面「侧栏三个列表的行 tooltip」),以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 +- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、右侧栏工具共用的 `format_bytes` / `format_percent` / `format_duration`(「32 天 23:40:26」)、`HostMark`、远程终端和 SFTP 标签栏右侧的延迟 `LatencyLabel`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、校验错误的通知 `form_error_notification` 与收起它的 `dismiss_form_error`(见下面「表单的校验错误一律弹错误通知」)、端口字段的 `parse_port`、删除确认 `confirm_delete` 和其他危险操作的确认 `confirm_danger`,侧栏三个列表的行 tooltip `RowTooltips` / `RowTooltip`(见下面「侧栏三个列表的行 tooltip」),以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 关键流程与不变量: @@ -110,9 +111,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`、`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` 查按顺序回答、不再登录。 +- **右侧栏是右侧 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`、`ProcessPanel`),`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()`(见下面「滚动条挂在不滚动的外层上」)。 +- **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL 2>&1; echo @@status $?`,PID 是数字,命令里不拼进程名之类主机给的文字),`ended` 把「Operation not permitted」「No such process」翻成中文,通知结果,再 `refresh_processes_soon`(1 秒后重读)。排序按钮派发 `SortProcesses(ProcessSort)`、刷新派发 `RefreshProcesses`,都经工作区转给面板;搜索框是面板自己的状态。详情:卡片的单击派发 `ShowProcess(pid)`,工作区的 `on_show_process` 从 `ToolSidebar::process_details` 取 `ProcessDetails`(`Snapshot::details`:进程、父进程名、按 ppid 数出的直接子进程和后代,列表里没有父进程时写「PID n」,ppid 0 是「无」),交给 `processes::open_process_dialog`;详情要的 `stat` 字段(父进程、进程组、会话、终端、前台进程组、优先级、nice、线程、虚拟内存)和读数一起解析进 `ProcessInfo`,`ps` 的状态字母(`<` / `N`、`s`、`l`、`+`)和终端名(136–143 是 `pts/n`,4 是 `ttyN` / `ttySN`)在 `parse_process` 里算好;命令行由 `ProcessDetailsView` 打开时在同一个终端上执行 `cat /proc//cmdline`(`command_line_command`),NUL 分隔的参数用空格连起来(`command_line`),空的是僵尸或已退出。对话框的标题和页脚在 `open_dialog` 的构造闭包里,每帧读正文实体(复制按钮按一下变成「已复制」两秒,状态在正文实体里);「结束进程」「强制结束」先 `close_dialog` 再在工作区的 focus handle 上派发 `EndProcess`(关掉对话框后焦点是空的,`window.dispatch_action` 没有落点)。卡片的行数必须一样(同网络连接,`uniform_list` 按第一项的高度排)。 - **网络连接(`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`。 @@ -185,4 +187,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`(系统监控)、`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` 初始为已连接并已打开终端标签。 +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`(网络连接)、`processes`(进程管理)、`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-processes` / `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`,只有看得见的卡片在元素树里)。进程管理的有:面板 `processes`、汇总 `processes-summary`(「共 3 个进程 · 每 15 秒刷新」)、`processes-refresh`、`processes-search`、排序按钮 `processes-sort-memory` / `processes-sort-cpu`(`checked()` 是正按它排的那个)、当前列出的个数 `processes-count`(`aria_label` 是数字)、列表 `processes-list`、一个都没有时的 `processes-empty`、`processes-loading`、`processes-message`、`processes-truncated`、卡片 `("process", PID)`(`aria_label` 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 `bounds` 看顺序);结束进程直接派发 `EndProcess { pid, force }`,确认框点 `ok`。详情对话框:正文 `process-details`、每项 `process-field:<标签>`(如 `process-field:父进程`,`aria_label` 是值)、命令行 `process-command`(`aria_label` 是整行)与读取中的 `process-command-reading`、读不到时的 `process-command-unknown`、按钮 `process-copy-command` / `process-copy-pid` / `process-end` / `process-force-end`;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。假终端 `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 258a3b6..9b25477 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -135,9 +135,15 @@ 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 主机上的进程(不含内核线程),每个一张卡片:状态圆点(绿色运行中、灰色休眠、黄色等待 I/O 或已暂停、红色僵尸进程,鼠标停上去显示状态)、进程名、PID、用户、启动时间(按本机时区),以及内存用量和占物理内存的比例、CPU 占用。CPU 占用和 `top` 的算法一样,按一个核计:占满两个核是 200%;它要两次读数才算得出,所以刚打开时先显示「—」,2 秒后出现。默认按内存从多到少排列,点「CPU」改按 CPU 排列,再点一次同一个按钮反过来排;可以按进程名、PID 或用户搜索,右边的数字是当前列出的个数。每 15 秒刷新一次,只在进程管理显示时读取,点右上角的刷新按钮立即读取。 + +点一个进程打开它的详情:PID、父进程、用户、状态(括号里是 `ps` 的状态字母,如 `Ssl`)、启动时间、运行时长、终端、优先级、Nice、CPU 使用率、累计 CPU 时间、常驻内存、虚拟内存、线程数、直接子进程数和全部后代进程数,下面是完整的命令行(打开详情时读取,可「复制命令」)。底部有「复制 PID」「结束进程」「强制结束」。 + +右键一个进程可以「查看详情」「结束进程…」(发送 SIGTERM,进程可以先做完收尾再退出)或「强制结束进程…」(发送 SIGKILL,立即终止);从详情里结束也一样,都会先确认。结果以通知显示,1 秒后列表重新读取。不是以 root 登录时只能结束自己的进程。进程超过 2500 个时只列出前 2500 个,列表顶部会写明。 **网络连接**列出远程 Linux 主机上所有的 TCP、UDP 连接和监听端口,每条一张卡片:协议、状态、本地和远端地址,占用它的进程和 PID,以及所属用户。右上角写明这是「监听端口」「入站连接」(连到本机监听的端口)还是「出站连接」。顶部是总数、监听端口数和已连接数;可以按地址、端口、状态、PID、进程名或用户搜索,也可以按协议和状态筛选。监听的排在最前,然后是已连接的,TIME_WAIT 等正在关闭的排在最后;同一个端口被多个进程共同监听(如 nginx 的多个 worker)时合成一张卡片。 diff --git a/src/app/actions.rs b/src/app/actions.rs index 6fcf78a..b298650 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -8,6 +8,7 @@ use crate::{ explorer::{ExplorerId, FileSizeFormat}, host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop}, monitor::MonitorDetail, + processes::ProcessSort, terminal::{LocalTerminalId, RemoteTerminalId}, }; @@ -41,6 +42,8 @@ gpui_kit::actions!( ToggleToolSidebar, /// Read the network connections of the SSH terminal's host again. RefreshConnections, + /// Read the processes of the SSH terminal's host again. + RefreshProcesses, /// Show the host list in the left dock. ShowHosts, /// Show the port-forwarding list in the left dock. @@ -188,6 +191,12 @@ id_actions! { ToggleTool(ToolKind); /// Unfold a part of the system monitor, or fold it away again. ToggleMonitorDetail(MonitorDetail); + /// Sort the process list by memory or CPU, the most first; by the same + /// again, the other way. + SortProcesses(ProcessSort); + /// Open the details of a process of the host of the SSH terminal in + /// front, by its PID. + ShowProcess(u32); /// Start a port forward over a connection of its own. StartForward(ForwardId); @@ -216,6 +225,15 @@ pub struct MoveHostNode { pub destination: NodeDrop, } +/// Ask for confirmation, then end a process of the host of the SSH terminal +/// in front: with SIGTERM, or with `force` SIGKILL. +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = shellrs, no_json)] +pub struct EndProcess { + pub pid: u32, + pub force: bool, +} + /// One tab of the center area, by the identity of what it shows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CenterTab { @@ -233,15 +251,17 @@ pub enum ToolKind { Snippets, History, Docker, + Processes, Connections, Monitor, } impl ToolKind { - pub const ALL: [ToolKind; 5] = [ + pub const ALL: [ToolKind; 6] = [ ToolKind::Snippets, ToolKind::History, ToolKind::Docker, + ToolKind::Processes, ToolKind::Connections, ToolKind::Monitor, ]; diff --git a/src/app/assets.rs b/src/app/assets.rs index 5bd4174..c8935a9 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -56,7 +56,10 @@ icon_assets!( Network, RadioTower, ArrowDownLeft, - ArrowUpRight + ArrowUpRight, + ListFilter, + CircleStop, + OctagonX ] ); diff --git a/src/lib.rs b/src/lib.rs index 0e3a3f8..30ccff2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,8 @@ //! Modules are organized by capability so they can become crates later: //! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` -//! (凭据), `monitor` (系统监控), `netstat` (网络连接), `secrets` (系统钥匙串), -//! `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 +//! (凭据), `monitor` (系统监控), `processes` (进程管理), `netstat` (网络连接), +//! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 //! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). pub mod app; @@ -16,6 +16,7 @@ pub mod forward; pub mod host; pub mod monitor; pub mod netstat; +pub mod processes; pub mod secrets; pub mod settings; pub mod sftp; diff --git a/src/monitor/model.rs b/src/monitor/model.rs index af5aa55..74d0355 100644 --- a/src/monitor/model.rs +++ b/src/monitor/model.rs @@ -2,6 +2,8 @@ use std::time::{Duration, Instant}; +use crate::shared::format_bytes; + /// One look at a host: its counters at that moment, and the parts read /// less often when this reading asked for them. #[derive(Clone, Debug, Default, PartialEq)] @@ -248,22 +250,6 @@ impl Load { } } -const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"]; - -/// 「421.02 MB」, in units of 1024. -pub fn format_bytes(bytes: u64) -> String { - if bytes < 1024 { - return format!("{bytes} B"); - } - let mut value = bytes as f64 / 1024.; - let mut unit = 0; - while value >= 1024. && unit + 1 < UNITS.len() { - value /= 1024.; - unit += 1; - } - format!("{value:.2} {}", UNITS[unit]) -} - /// 「5.44 KB/s」. pub fn format_rate(bytes_per_second: f64) -> String { format!( @@ -272,28 +258,6 @@ pub fn format_rate(bytes_per_second: f64) -> String { ) } -/// 「43.3%」. -pub fn format_percent(percent: f32) -> String { - format!("{percent:.1}%") -} - -/// 「32 天 23:40:26」, or 「23:40:26」 within the first day. -pub fn format_uptime(uptime: Duration) -> String { - let seconds = uptime.as_secs(); - let days = seconds / 86_400; - let clock = format!( - "{:02}:{:02}:{:02}", - seconds / 3600 % 24, - seconds / 60 % 60, - seconds % 60 - ); - if days > 0 { - format!("{days} 天 {clock}") - } else { - clock - } -} - /// How many of the per-core bars fit on a row `width` wide, at the /// interface's `rem`: bars are 0.375 rem with 0.125 rem between. pub fn bars_per_row(width: f32, rem: f32) -> usize { @@ -423,22 +387,8 @@ mod tests { } #[test] - fn sizes_rates_and_uptimes_read_naturally() { - assert_eq!(format_bytes(0), "0 B"); - assert_eq!(format_bytes(512), "512 B"); - assert_eq!(format_bytes(441_470_976), "421.02 MB"); - assert_eq!(format_bytes(9_889_644_544), "9.21 GB"); + fn rates_read_naturally() { assert_eq!(format_rate(5570.), "5.44 KB/s"); - assert_eq!(format_percent(43.27), "43.3%"); - assert_eq!( - format_uptime(Duration::from_secs(32 * 86_400 + 23 * 3600 + 40 * 60 + 26)), - "32 天 23:40:26" - ); - assert_eq!( - format_uptime(Duration::from_secs_f64(5. * 3600. + 12. * 60. + 9.7)), - "05:12:09" - ); - assert_eq!(format_uptime(Duration::from_secs(30)), "00:00:30"); } #[test] diff --git a/src/monitor/monitor_panel.rs b/src/monitor/monitor_panel.rs index 09adcc3..e22008c 100644 --- a/src/monitor/monitor_panel.rs +++ b/src/monitor/monitor_panel.rs @@ -21,9 +21,10 @@ use super::MonitorDetail; use super::linux::{self, Parsed, Parts}; use super::model::{ DiskUsage, InterfaceRate, Load, Memory, Snapshot, SystemInfo, Tracker, bars_per_row, - format_bytes, format_percent, format_rate, format_uptime, + format_rate, }; use crate::app::{CatalogIcon, ToggleMonitorDetail}; +use crate::shared::{format_bytes, format_duration, format_percent}; use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; /// Time between readings of the load. The second comes sooner: the CPU's @@ -419,7 +420,7 @@ fn render_system(system: &SystemInfo, uptime: Option, cx: &App) -> imp .child(field( "monitor-uptime", "运行时长", - uptime.map(format_uptime).unwrap_or_default(), + uptime.map(format_duration).unwrap_or_default(), )), ) } diff --git a/src/processes/linux.rs b/src/processes/linux.rs new file mode 100644 index 0000000..4746545 --- /dev/null +++ b/src/processes/linux.rs @@ -0,0 +1,469 @@ +//! Reading a Linux host's processes, and ending one: the commands, and the +//! parsers for what they print. + +use std::collections::HashMap; + +use super::model::{ProcessInfo, ProcessState, ProcessTimes, Reading}; + +/// The most processes a reading lists: some 300 bytes of `/proc` each, and +/// a reading's output holds 1 MB. +pub const LIMIT: usize = 2500; + +/// `PF_KTHREAD` in `/proc//stat`'s flags: a kernel thread, which has +/// no program to manage. +const KERNEL_THREAD: u64 = 0x0020_0000; + +/// What 进程管理 runs on the host for a reading: the uptime and the +/// memory, the clock tick and page size, the host's user names, and each +/// process's `stat` line with its owner's uid from `status`. Each part +/// under an `@@` line of its own. +/// +/// Shell built-ins only (`read`, `case`, `echo`, `for`) besides `getconf`, +/// so a reading starts two short processes on the host, and the `sh` +/// running it. 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; if test -r /proc/self/stat; then \ + echo @@uptime; read -r line < /proc/uptime; echo \"$line\"; \ + echo @@memory; while read -r key value rest; do case $key in MemTotal:) echo \"$value\"; break;; esac; done < /proc/meminfo; \ + echo @@clock; getconf CLK_TCK 2>/dev/null; \ + echo @@page; getconf PAGESIZE 2>/dev/null; \ + echo @@users; while IFS=: read -r name x uid rest; do echo \"$uid $name\"; done < /etc/passwd; \ + echo @@processes; n=0; for d in /proc/[0-9]*; do \ + n=$((n+1)); if test $n -gt {LIMIT}; then echo @@more; break; fi; \ + read -r stat < $d/stat || continue; uid=; \ + while read -r key value rest; do case $key in Uid:) uid=$value; break;; esac; done < $d/status; \ + echo \"$uid $stat\"; done 2>/dev/null; \ + else echo @@unsupported; uname -s; fi'" + ) +} + +/// What the command's output said. +#[derive(Debug, PartialEq)] +pub enum Parsed { + Reading(Box), + /// 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()) + }; + let number = |name: &str| { + first(name).and_then(|line| line.split_whitespace().next()?.parse::().ok()) + }; + + let Some(uptime) = number("uptime") else { + // A shell other than sh's (cmd.exe) prints nothing we know. + return Parsed::Unsupported(first("unsupported").map(str::to_owned)); + }; + let users: HashMap<&str, &str> = section("users") + .iter() + .filter_map(|line| line.trim().split_once(' ')) + .collect(); + let page = number("page").map_or(4096, |page| page as u64); + Parsed::Reading(Box::new(Reading { + uptime, + memory_total: number("memory").map_or(0, |kib| kib as u64 * 1024), + ticks_per_second: number("clock").map_or(100, |hz| hz as u64), + processes: section("processes") + .iter() + .filter_map(|line| parse_process(line, &users, page)) + .collect(), + truncated: sections.contains_key("more"), + })) +} + +/// 「0 1234 (java) S 1 …」: the owner's uid, then `/proc//stat`, whose +/// name is in parentheses and may hold spaces and parentheses itself, so +/// the fields are counted from the last `)`. `None` for a kernel thread. +fn parse_process(line: &str, users: &HashMap<&str, &str>, page: u64) -> Option { + let (uid, stat) = line.split_once(' ')?; + let (pid, rest) = stat.split_once(" (")?; + let (name, rest) = rest.rsplit_once(") ")?; + // Fields 3 on: state, ppid, pgrp, session, tty, tpgid, flags, four + // fault counts, utime, stime, … + let fields: Vec<&str> = rest.split_whitespace().collect(); + let field = |number: usize| -> Option { fields.get(number - 3)?.parse().ok() }; + let signed = |number: usize| -> Option { fields.get(number - 3)?.parse().ok() }; + if field(9)? & KERNEL_THREAD != 0 { + return None; + } + let pid: u32 = pid.trim().parse().ok()?; + let state = fields.first()?; + let (group, session, terminal, foreground) = (signed(5)?, signed(6)?, signed(7)?, signed(8)?); + let (nice, threads) = (signed(19)?, field(20)?); + // `ps`'s letters after the state: priority, session leader, threads, + // foreground. + let mut letters = state.to_string(); + if nice < 0 { + letters.push('<'); + } else if nice > 0 { + letters.push('N'); + } + if session == i64::from(pid) { + letters.push('s'); + } + if threads > 1 { + letters.push('l'); + } + if terminal != 0 && foreground == group { + letters.push('+'); + } + Some(ProcessTimes { + pid, + name: name.to_owned(), + state: ProcessState::from_letter(state), + user: (!uid.is_empty()).then(|| users.get(uid).unwrap_or(&uid).to_string()), + started: field(22)?, + cpu: field(14)? + field(15)?, + memory: field(24)? * page, + info: ProcessInfo { + parent: field(4)? as u32, + letters, + terminal: terminal_name(terminal), + priority: signed(18)?, + nice, + threads, + virtual_memory: field(23)?, + }, + }) +} + +/// A terminal's device number as `stat` has it, by name: 「pts/0」「tty1」. +fn terminal_name(device: i64) -> Option { + if device == 0 { + return None; + } + let device = device as u32; + let major = (device >> 8) & 0xfff; + let minor = (device & 0xff) | ((device >> 12) & 0xfff00); + Some(match major { + 136..=143 => format!("pts/{}", minor + (major - 136) * 256), + 4 if minor < 64 => format!("tty{minor}"), + 4 => format!("ttyS{}", minor - 64), + _ => format!("{major}:{minor}"), + }) +} + +/// What reads a process's whole command line: its arguments, each ended +/// by a NUL. +pub fn command_line_command(pid: u32) -> String { + format!("sh -c 'cat /proc/{pid}/cmdline'") +} + +/// The command line as `ps` shows it, the arguments joined by spaces; +/// `None` for a process without one (a zombie), or gone. +pub fn command_line(output: &str) -> Option { + let line = output + .split('\0') + .filter(|argument| !argument.is_empty()) + .collect::>() + .join(" "); + (!line.trim().is_empty()).then_some(line) +} + +/// What ends `pid`: SIGTERM, which lets it clean up, or with `force` +/// SIGKILL, which it cannot refuse. Says how `kill` went in an `@@status` +/// line, with its complaint before it. +pub fn end_command(pid: u32, force: bool) -> String { + let signal = if force { "KILL" } else { "TERM" }; + format!("sh -c 'kill -{signal} {pid} 2>&1; echo @@status $?'") +} + +/// How ending a process went, from what `end_command` printed: why not, +/// when it did not. +pub fn ended(output: &str) -> Result<(), String> { + let (said, status) = output + .rsplit_once("@@status") + .ok_or_else(|| "主机没有回答".to_string())?; + if status.trim() == "0" { + return Ok(()); + } + let said = said.trim(); + Err(if said.contains("not permitted") { + "没有权限:只能结束自己的进程,root 才能结束所有进程".into() + } else if said.contains("No such process") { + "进程已经退出".into() + } else if said.is_empty() { + format!("kill 返回 {}", status.trim()) + } else { + said.to_owned() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OUTPUT: &str = "\ +@@uptime +1000.50 3800.00 +@@memory +2000000 +@@clock +100 +@@page +4096 +@@users +0 root +1000 ecs-user +@@processes +0 1 (systemd) S 0 1 1 0 -1 4194560 100 0 0 0 150 80 0 0 20 0 1 0 5 172000000 3000 18446744073709551615 1 1 0 0 0 0 +0 2 (kthreadd) S 0 0 0 0 -1 2129984 0 0 0 0 0 0 0 0 20 0 1 0 5 0 0 18446744073709551615 0 0 0 0 0 0 +1000 1479 (node) R 1 1479 1479 0 -1 4194560 100 0 0 0 4000 500 0 0 20 0 11 0 9000 900000000 30000 18446744073709551615 +33 2202 (Web Content) S 1 2202 2202 0 -1 4194560 0 0 0 0 10 10 0 0 20 0 1 0 9500 1000 10 0 +1001 3000 (a) b) Z 1 0 0 0 -1 4194564 0 0 0 0 0 0 0 0 20 0 1 0 9600 0 0 0 + 4000 (gone) S 1 0 0 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 9700 1000 1 0 +"; + + fn reading(output: &str) -> Reading { + match parse(output) { + Parsed::Reading(reading) => *reading, + other => panic!("not a reading: {other:?}"), + } + } + + #[test] + fn stat_lines_give_the_processes_but_not_the_kernels_threads() { + let reading = reading(OUTPUT); + assert_eq!(reading.uptime, 1000.5); + assert_eq!(reading.memory_total, 2_048_000_000); + assert_eq!(reading.ticks_per_second, 100); + assert!(!reading.truncated); + let processes: Vec<(u32, &str, ProcessState, Option<&str>)> = reading + .processes + .iter() + .map(|process| { + ( + process.pid, + process.name.as_str(), + process.state, + process.user.as_deref(), + ) + }) + .collect(); + assert_eq!( + processes, + [ + (1, "systemd", ProcessState::Sleeping, Some("root")), + (1479, "node", ProcessState::Running, Some("ecs-user")), + // A uid with no name stays a number; a name may hold spaces + // and parentheses. + (2202, "Web Content", ProcessState::Sleeping, Some("33")), + (3000, "a) b", ProcessState::Zombie, Some("1001")), + // Its status went before it could be read. + (4000, "gone", ProcessState::Sleeping, None), + ] + ); + let node = &reading.processes[1]; + assert_eq!(node.cpu, 4500); + assert_eq!(node.started, 9000); + assert_eq!(node.memory, 30000 * 4096); + assert_eq!( + node.info, + ProcessInfo { + parent: 1, + // A session leader with threads. + letters: "Rsl".into(), + terminal: None, + priority: 20, + nice: 0, + threads: 11, + virtual_memory: 900_000_000, + } + ); + } + + #[test] + fn a_process_on_a_terminal_in_front_of_it_reads_like_ps() { + // vim on pts/1 (136 << 8 | 1), in the terminal's foreground group, + // niced down. + let line = + "0 900 (vim) S 800 900 800 34817 900 4194304 0 0 0 0 5 1 0 0 25 5 1 0 100 1000 10 0"; + let process = parse_process(line, &HashMap::new(), 4096).expect("vim"); + assert_eq!(process.info.letters, "SN+"); + assert_eq!(process.info.terminal.as_deref(), Some("pts/1")); + assert_eq!((process.info.priority, process.info.nice), (25, 5)); + assert_eq!(terminal_name((4 << 8) | 1).as_deref(), Some("tty1")); + assert_eq!(terminal_name((4 << 8) | 64).as_deref(), Some("ttyS0")); + } + + #[test] + fn a_command_line_is_its_arguments_joined_by_spaces() { + assert_eq!(command_line_command(2202), "sh -c 'cat /proc/2202/cmdline'"); + assert_eq!( + command_line("/usr/bin/java\0-Xmx256m\0-jar\0app.jar\0").as_deref(), + Some("/usr/bin/java -Xmx256m -jar app.jar") + ); + // A zombie's is empty, and so is a gone process's. + assert_eq!(command_line(""), None); + } + + #[test] + fn a_host_with_more_processes_than_a_reading_lists_says_so() { + let reading = reading(&format!("{OUTPUT}@@more\n")); + assert!(reading.truncated); + assert_eq!(reading.processes.len(), 5); + } + + #[test] + fn a_host_that_is_not_linux_says_what_it_is() { + assert_eq!( + parse("@@unsupported\nDarwin\n"), + Parsed::Unsupported(Some("Darwin".into())) + ); + assert_eq!(parse(""), Parsed::Unsupported(None)); + } + + #[test] + fn ending_a_process_says_why_it_did_not_end() { + assert_eq!( + end_command(2202, false), + "sh -c 'kill -TERM 2202 2>&1; echo @@status $?'" + ); + assert_eq!( + end_command(2202, true), + "sh -c 'kill -KILL 2202 2>&1; echo @@status $?'" + ); + assert_eq!(ended("@@status 0\n"), Ok(())); + assert_eq!( + ended("sh: 1: kill: Operation not permitted\n@@status 2\n"), + Err("没有权限:只能结束自己的进程,root 才能结束所有进程".into()) + ); + assert_eq!( + ended("sh: kill: (2202) - No such process\n@@status 1\n"), + Err("进程已经退出".into()) + ); + assert_eq!(ended("@@status 1\n"), Err("kill 返回 1".into())); + assert_eq!(ended(""), Err("主机没有回答".into())); + } + + #[test] + fn the_commands_survive_any_login_shell() { + for command in [command(), end_command(1, false), command_line_command(1)] { + 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("\\\\")); + } + } + + /// The script, run by this machine's `sh` against a `/proc` and an + /// `/etc/passwd` stood in for, gives what the parser reads. + #[cfg(unix)] + #[test] + fn the_script_reads_proc_with_shell_built_ins() { + let root = tempfile::tempdir().expect("temp dir"); + let at = |path: &str| root.path().join(path); + let write = |path: &str, text: &str| { + std::fs::create_dir_all(at(path).parent().unwrap()).unwrap(); + std::fs::write(at(path), text).unwrap(); + }; + write("etc/passwd", "root:x:0:0:root:/root:/bin/bash\n"); + write("proc/uptime", "1000.50 3800.00\n"); + write( + "proc/meminfo", + "MemTotal: 2000000 kB\nMemFree: 1 kB\n", + ); + write("proc/self/stat", "1 (sh) S 0\n"); + write( + "proc/1/stat", + "1 (systemd) S 0 1 1 0 -1 4194560 100 0 0 0 150 80 0 0 20 0 1 0 5 172000000 3000 0\n", + ); + write( + "proc/1/status", + "Name:\tsystemd\nState:\tS (sleeping)\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\n", + ); + write( + "proc/2/stat", + "2 (kthreadd) S 0 0 0 0 -1 2129984 0 0 0 0 0 0 0 0 20 0 1 0 5 0 0 0\n", + ); + write("proc/2/status", "Name:\tkthreadd\nUid:\t0\t0\t0\t0\n"); + write( + "proc/77/stat", + "77 (Web Content) R 1 1 1 0 -1 4194560 0 0 0 0 40 2 0 0 20 0 1 0 900 1000 25 0\n", + ); + write( + "proc/77/status", + "Name:\tWeb Content\nUid:\t1000\t1000\t1000\t1000\n", + ); + // A process that went between the listing and the reading. + std::fs::create_dir_all(at("proc/88")).unwrap(); + + let command = command() + .replace("/proc/", &format!("{}/", at("proc").display())) + .replace("/etc/passwd", &at("etc/passwd").display().to_string()) + .replace("getconf CLK_TCK", "echo 100") + .replace("getconf PAGESIZE", "echo 4096"); + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap(); + let output = std::process::Command::new("sh") + .args(["-c", script]) + .output() + .expect("sh runs"); + let reading = reading(&String::from_utf8(output.stdout).unwrap()); + assert_eq!(reading.memory_total, 2_048_000_000); + let processes: Vec<(u32, &str, Option<&str>, u64, u64)> = reading + .processes + .iter() + .map(|process| { + ( + process.pid, + process.name.as_str(), + process.user.as_deref(), + process.cpu, + process.memory, + ) + }) + .collect(); + assert_eq!( + processes, + [ + (1, "systemd", Some("root"), 230, 3000 * 4096), + (77, "Web Content", Some("1000"), 42, 25 * 4096), + ] + ); + } + + #[cfg(unix)] + #[test] + fn the_commands_are_valid_sh() { + for command in [command(), end_command(1, true), command_line_command(1)] { + 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/processes/mod.rs b/src/processes/mod.rs new file mode 100644 index 0000000..742388e --- /dev/null +++ b/src/processes/mod.rs @@ -0,0 +1,13 @@ +//! 进程管理: the right sidebar's tool that lists the processes of the host +//! of the SSH terminal in front, with their memory and CPU, and ends them, +//! over that terminal's own connection. Linux hosts only, for now. + +mod linux; +mod model; +mod process_details; +mod process_panel; + +pub use linux::{end_command, ended}; +pub use model::{Process, ProcessDetails, ProcessSort}; +pub use process_details::open_process_dialog; +pub use process_panel::ProcessPanel; diff --git a/src/processes/model.rs b/src/processes/model.rs new file mode 100644 index 0000000..22e036c --- /dev/null +++ b/src/processes/model.rs @@ -0,0 +1,535 @@ +//! What 进程管理 reads off a host, and how it sorts and searches the list. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +/// One look at a host's processes. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Reading { + /// Seconds since boot, the clock the CPU times are read against. + pub uptime: f64, + /// Physical memory, in bytes. + pub memory_total: u64, + /// Clock ticks a second, which CPU times and start times count in. + pub ticks_per_second: u64, + pub processes: Vec, + /// Whether the host has more processes than the reading lists. + pub truncated: bool, +} + +/// A process as `/proc//stat` has it. +#[derive(Clone, Debug, PartialEq)] +pub struct ProcessTimes { + pub pid: u32, + /// The executable's name, cut to 15 bytes by the kernel. + pub name: String, + pub state: ProcessState, + /// The name, or the uid when the host has no name for it. + pub user: Option, + /// When it started, in ticks since boot. + pub started: u64, + /// CPU time used, user and system, in ticks. + pub cpu: u64, + /// Resident memory, in bytes. + pub memory: u64, + pub info: ProcessInfo, +} + +/// What a process's details say besides its times and memory, as +/// `/proc//stat` has it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ProcessInfo { + /// The parent's PID; 0 for the first process. + pub parent: u32, + /// `ps`'s state letters: 「Sl」 asleep with threads, 「Ss+」 a session + /// leader in the foreground of its terminal. + pub letters: String, + /// The controlling terminal: 「pts/0」, `None` without one. + pub terminal: Option, + /// The kernel's priority, as `top` shows it: 20 at nice 0. + pub priority: i64, + pub nice: i64, + pub threads: u64, + /// Virtual memory, in bytes. + pub virtual_memory: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProcessState { + Running, + Sleeping, + /// Waiting on the disk or the like, and deaf to signals meanwhile. + Uninterruptible, + Stopped, + Zombie, + /// A kernel worker with nothing to do. + Idle, + Other, +} + +impl ProcessState { + /// The state's letter in `/proc//stat`. + pub fn from_letter(letter: &str) -> Self { + match letter { + "R" => ProcessState::Running, + "S" => ProcessState::Sleeping, + "D" => ProcessState::Uninterruptible, + "T" | "t" => ProcessState::Stopped, + "Z" => ProcessState::Zombie, + "I" => ProcessState::Idle, + _ => ProcessState::Other, + } + } + + pub fn label(self) -> &'static str { + match self { + ProcessState::Running => "运行中", + ProcessState::Sleeping => "休眠", + ProcessState::Uninterruptible => "等待 I/O", + ProcessState::Stopped => "已暂停", + ProcessState::Zombie => "僵尸进程", + ProcessState::Idle => "空闲", + ProcessState::Other => "其他", + } + } +} + +/// A process as the list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct Process { + pub pid: u32, + pub name: String, + pub state: ProcessState, + pub user: Option, + /// When it started, as seconds since the Unix epoch. + pub started: i64, + pub memory: u64, + pub memory_percent: f32, + /// Its share of one core since the last reading, the way `top` counts + /// it: a process busy on two cores is at 200%. `None` on the first + /// reading of it. + pub cpu: Option, + /// CPU time used since it started. + pub cpu_time: Duration, + /// How long it has been running. + pub running: Duration, + pub info: ProcessInfo, +} + +/// A process with what its details say about its family. +#[derive(Clone, Debug, PartialEq)] +pub struct ProcessDetails { + pub process: Process, + /// The parent: 「systemd (1)」, or 「PID 1」 when the list does not have + /// it; `None` for the first process, which has none. + pub parent: Option, + /// The processes it started. + pub children: usize, + /// Those, the processes they started, and so on down. + pub descendants: usize, +} + +impl Process { + /// What the search looks in, lowercased. + fn haystack(&self) -> String { + format!( + "{} {} {}", + self.name, + self.pid, + self.user.as_deref().unwrap_or_default() + ) + .to_lowercase() + } +} + +/// The host's processes at one reading, with what changed since the last. +#[derive(Clone, Debug, PartialEq)] +pub struct Snapshot { + processes: Vec, + /// Lowercased, beside each process. + haystacks: Vec, + truncated: bool, +} + +impl Snapshot { + pub fn processes(&self) -> &[Process] { + &self.processes + } + + pub fn process(&self, pid: u32) -> Option<&Process> { + self.processes.iter().find(|process| process.pid == pid) + } + + /// A process with its parent named and its children counted, from + /// the list. + pub fn details(&self, pid: u32) -> Option { + let process = self.process(pid)?.clone(); + let parent = match process.info.parent { + 0 => None, + parent => Some(match self.process(parent) { + Some(found) => format!("{} ({parent})", found.name), + None => format!("PID {parent}"), + }), + }; + let mut children: HashMap> = HashMap::new(); + for each in &self.processes { + children.entry(each.info.parent).or_default().push(each.pid); + } + let direct = children.get(&pid).map_or(0, Vec::len); + // Down the tree; a PID seen twice (a list read mid-change) counts + // once. + let mut seen = HashSet::from([pid]); + let mut next = vec![pid]; + while let Some(parent) = next.pop() { + for &child in children.get(&parent).into_iter().flatten() { + if seen.insert(child) { + next.push(child); + } + } + } + Some(ProcessDetails { + process, + parent, + children: direct, + descendants: seen.len() - 1, + }) + } + + /// Whether the host has more processes than the reading listed. + pub fn truncated(&self) -> bool { + self.truncated + } + + /// The processes `query` finds in their name, PID or user, ignoring + /// case, in `order`: by their place in `processes()`. + pub fn listed(&self, query: &str, order: Order) -> Vec { + let query = query.trim().to_lowercase(); + let mut listed: Vec = self + .haystacks + .iter() + .enumerate() + .filter(|(_, haystack)| query.is_empty() || haystack.contains(&query)) + .map(|(index, _)| index) + .collect(); + listed.sort_by(|&a, &b| order.compare(&self.processes[a], &self.processes[b])); + listed + } +} + +/// What the list is sorted by. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ProcessSort { + #[default] + Memory, + Cpu, +} + +/// What the list is sorted by, and which way. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Order { + pub by: ProcessSort, + /// The most first. + pub descending: bool, +} + +impl Default for Order { + fn default() -> Self { + Self { + by: ProcessSort::Memory, + descending: true, + } + } +} + +impl Order { + /// Sort by `by`, the most first; by the same again, the other way. + pub fn toggle(self, by: ProcessSort) -> Self { + Self { + by, + descending: self.by != by || !self.descending, + } + } + + fn compare(self, a: &Process, b: &Process) -> std::cmp::Ordering { + // A CPU share not known yet goes below every known one. + let cpu = |process: &Process| process.cpu.unwrap_or(-1.); + let ordering = match self.by { + ProcessSort::Memory => a.memory.cmp(&b.memory).then(cpu(a).total_cmp(&cpu(b))), + ProcessSort::Cpu => cpu(a).total_cmp(&cpu(b)).then(a.memory.cmp(&b.memory)), + }; + let ordering = if self.descending { + ordering.reverse() + } else { + ordering + }; + ordering.then(a.pid.cmp(&b.pid)) + } +} + +/// Turns readings into snapshots: a CPU share needs the CPU time of the +/// reading before. +#[derive(Debug, Default)] +pub struct Tracker { + last: Option, +} + +#[derive(Debug)] +struct LastReading { + uptime: f64, + /// Each process's CPU time, by PID and start time: a PID taken again + /// by a new process is another process. + cpu: HashMap<(u32, u64), u64>, +} + +impl Tracker { + /// `now`: the time of the reading, as seconds since the Unix epoch, + /// which dates the processes' starts. + pub fn update(&mut self, reading: Reading, now: i64) -> Snapshot { + let hz = reading.ticks_per_second.max(1) as f64; + // Seconds of the host's own clock since the last reading; none + // when it went back (another machine, or one rebooted). + let elapsed = self + .last + .as_ref() + .map(|last| reading.uptime - last.uptime) + .filter(|elapsed| *elapsed > 0.); + let boot = now as f64 - reading.uptime; + let processes: Vec = reading + .processes + .iter() + .map(|process| { + let cpu = elapsed.and_then(|elapsed| { + let last = self.last.as_ref()?; + let before = last.cpu.get(&(process.pid, process.started))?; + let used = process.cpu.checked_sub(*before)? as f64 / hz; + Some((used / elapsed * 100.) as f32) + }); + Process { + pid: process.pid, + name: process.name.clone(), + state: process.state, + user: process.user.clone(), + started: (boot + process.started as f64 / hz).round() as i64, + memory: process.memory, + memory_percent: if reading.memory_total == 0 { + 0. + } else { + (process.memory as f64 / reading.memory_total as f64 * 100.) as f32 + }, + cpu, + cpu_time: Duration::from_secs_f64(process.cpu as f64 / hz), + running: Duration::from_secs_f64( + (reading.uptime - process.started as f64 / hz).max(0.), + ), + info: process.info.clone(), + } + }) + .collect(); + self.last = Some(LastReading { + uptime: reading.uptime, + cpu: reading + .processes + .iter() + .map(|process| ((process.pid, process.started), process.cpu)) + .collect(), + }); + Snapshot { + haystacks: processes.iter().map(Process::haystack).collect(), + processes, + truncated: reading.truncated, + } + } +} + +/// 「2026-09-15 17:53:54」, in this computer's time zone. +pub fn format_started(seconds: i64) -> String { + chrono::DateTime::from_timestamp(seconds, 0) + .map(|time| { + time.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn times(pid: u32, name: &str, started: u64, cpu: u64, memory: u64) -> ProcessTimes { + ProcessTimes { + pid, + name: name.into(), + state: ProcessState::Sleeping, + user: Some("root".into()), + started, + cpu, + memory, + info: ProcessInfo::default(), + } + } + + fn reading(uptime: f64, processes: Vec) -> Reading { + Reading { + uptime, + memory_total: 1000, + ticks_per_second: 100, + processes, + truncated: false, + } + } + + #[test] + fn cpu_shares_come_from_two_readings_of_the_same_process() { + let mut tracker = Tracker::default(); + let first = tracker.update( + reading( + 1000., + vec![ + times(10, "java", 500, 1_000, 280), + times(20, "node", 600, 50, 70), + ], + ), + 1_800_000_000, + ); + assert!( + first + .processes() + .iter() + .all(|process| process.cpu.is_none()) + ); + let java = first.process(10).expect("java"); + assert!((java.memory_percent - 28.).abs() < 0.01); + // Booted 1000 s before now, started 5 s after that. + assert_eq!(java.started, 1_800_000_000 - 1000 + 5); + + // 15 s later: java used 3 s of CPU, a fifth of a core; node is + // another process under the same PID. + let second = tracker.update( + reading( + 1015., + vec![ + times(10, "java", 500, 1_300, 280), + times(20, "node", 99_000, 60, 70), + ], + ), + 1_800_000_015, + ); + assert_eq!( + second.process(10).and_then(|process| process.cpu), + Some(20.) + ); + assert_eq!(second.process(20).and_then(|process| process.cpu), None); + + // A clock gone back is another machine: no shares. + let third = tracker.update( + reading(5., vec![times(10, "java", 500, 1_400, 280)]), + 1_800_000_030, + ); + assert_eq!(third.process(10).and_then(|process| process.cpu), None); + } + + #[test] + fn the_list_sorts_by_memory_or_cpu_either_way_and_searches() { + let mut tracker = Tracker::default(); + tracker.update( + reading( + 100., + vec![ + times(1, "systemd", 1, 0, 100), + times(10, "java", 50, 0, 500), + times(20, "node", 60, 0, 200), + ], + ), + 0, + ); + let snapshot = tracker.update( + reading( + 110., + vec![ + times(1, "systemd", 1, 0, 100), + times(10, "java", 50, 10, 500), + times(20, "node", 60, 500, 200), + times(30, "sshd", 100, 0, 300), + ], + ), + 0, + ); + let names = |listed: Vec| -> Vec<&str> { + listed + .into_iter() + .map(|index| snapshot.processes()[index].name.as_str()) + .collect() + }; + let memory = Order::default(); + assert_eq!( + names(snapshot.listed("", memory)), + ["java", "sshd", "node", "systemd"] + ); + let cpu = memory.toggle(ProcessSort::Cpu); + assert_eq!( + cpu, + Order { + by: ProcessSort::Cpu, + descending: true + } + ); + // sshd is new: its share is not known yet, so it goes last. + assert_eq!( + names(snapshot.listed("", cpu)), + ["node", "java", "systemd", "sshd"] + ); + let least = cpu.toggle(ProcessSort::Cpu); + assert!(!least.descending); + assert_eq!( + names(snapshot.listed("", least)), + ["sshd", "systemd", "java", "node"] + ); + assert!(least.toggle(ProcessSort::Memory).descending); + + assert_eq!(names(snapshot.listed(" JAVA ", memory)), ["java"]); + assert_eq!(names(snapshot.listed("20", memory)), ["node"]); + assert_eq!(snapshot.listed("root", memory).len(), 4); + assert!(snapshot.listed("nginx", memory).is_empty()); + } + + #[test] + fn details_name_the_parent_and_count_the_children_and_theirs() { + let child = |pid: u32, name: &str, parent: u32| { + let mut process = times(pid, name, 100, 4_250, 10); + process.info.parent = parent; + process + }; + let mut tracker = Tracker::default(); + let snapshot = tracker.update( + reading( + 10_000., + vec![ + child(1, "systemd", 0), + child(500, "sshd", 1), + child(600, "sshd", 500), + child(601, "bash", 600), + child(602, "vim", 601), + // Its parent is a kernel thread, which the list leaves out. + child(700, "kworker-ish", 2), + ], + ), + 0, + ); + let details = snapshot.details(500).expect("sshd"); + assert_eq!(details.parent.as_deref(), Some("systemd (1)")); + assert_eq!((details.children, details.descendants), (1, 3)); + assert_eq!(snapshot.details(1).expect("systemd").parent, None); + assert_eq!( + snapshot.details(700).expect("700").parent.as_deref(), + Some("PID 2") + ); + assert_eq!(snapshot.details(1).expect("systemd").descendants, 4); + assert_eq!(snapshot.details(42), None); + + // 42.5 s of CPU; started at tick 100, a second after boot. + let process = &details.process; + assert_eq!(process.cpu_time, Duration::from_millis(42_500)); + assert_eq!(process.running, Duration::from_secs(9_999)); + } +} diff --git a/src/processes/process_details.rs b/src/processes/process_details.rs new file mode 100644 index 0000000..33e011a --- /dev/null +++ b/src/processes/process_details.rs @@ -0,0 +1,351 @@ +//! A process's details: the dialog a click on it in 进程管理 opens. + +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + dialog::DialogFooter, + h_flex, + spinner::Spinner, + tag::Tag, + v_flex, +}; +use gpui_kit::*; + +use super::linux::{command_line, command_line_command}; +use super::model::{ProcessDetails, format_started}; +use crate::app::{CatalogIcon, EndProcess}; +use crate::shared::{format_bytes, format_duration, format_percent}; +use crate::terminal::{TerminalView, exec_answer}; + +/// How wide the dialog is: two columns of a label and a value. +const DIALOG_WIDTH: f32 = 40.; +/// How long a copy button says it copied. +const COPIED_FOR: Duration = Duration::from_secs(2); + +/// Open the details of a process of the host of `terminal`, whose +/// connection reads its command line. `dispatch` is the workspace's focus +/// handle: the dialog's 结束进程 buttons close it and dispatch there. +pub fn open_process_dialog( + details: ProcessDetails, + terminal: WeakEntity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut App, +) { + let view = cx.new(|cx| ProcessDetailsView::new(details, terminal, cx)); + window.open_dialog(cx, move |dialog, window, cx| { + let this = view.read(cx); + let process = &this.details.process; + let pid = process.pid; + let title = v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .child(div().truncate().child(process.name.clone())) + .child( + Tag::secondary() + .small() + .rounded_full() + .child(process.state.label()), + ), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::NORMAL) + .text_color(cx.theme().muted_foreground) + .child(format!( + "PID {pid} · {}", + process.user.as_deref().unwrap_or("—") + )), + ); + let end = |id: &'static str, force: bool| { + let dispatch = dispatch.clone(); + Button::new(id).on_click(move |_, window, cx| { + // One dialog at a time: the confirmation takes its place. + window.close_dialog(cx); + dispatch.dispatch_action(&EndProcess { pid, force }, window, cx); + }) + }; + let footer = DialogFooter::new() + .justify_between() + .child(copy_button( + "process-copy-pid", + "复制 PID", + Some(pid.to_string()), + this.copied == Some(Copied::Pid), + Copied::Pid, + &view, + )) + .child( + h_flex() + .gap_2() + .child( + end("process-end", false) + .outline() + .icon(Icon::new(CatalogIcon::CircleStop)) + .label("结束进程"), + ) + .child( + end("process-force-end", true) + .danger() + .icon(Icon::new(CatalogIcon::OctagonX)) + .label("强制结束"), + ), + ); + dialog + .title(title) + // Dialog geometry is an API boundary that takes `Pixels`; the + // width follows the interface zoom through the rem. + .w(rems(DIALOG_WIDTH).to_pixels(window.rem_size())) + .max_h(window.viewport_size().height * 0.9) + // Closed by its buttons or Escape, like every dialog. + .overlay_closable(false) + .child(view.clone()) + .footer(footer) + }); +} + +/// Which copy button just copied. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Copied { + Pid, + Command, +} + +/// The command line, read once the dialog opens: the list does not carry +/// it. +enum CommandLine { + Reading, + Known(String), + /// Why there is none: 「读取失败:命令超时」. + Unknown(String), +} + +/// The dialog's body: the process as the list last read it, and its +/// command line. +pub struct ProcessDetailsView { + details: ProcessDetails, + command: CommandLine, + copied: Option, + _reading: Task<()>, + /// Puts a copy button's 已复制 back after a moment. + copied_reset: Option>, +} + +impl ProcessDetailsView { + fn new( + details: ProcessDetails, + terminal: WeakEntity, + cx: &mut Context, + ) -> Self { + let reply = terminal.upgrade().and_then(|view| { + view.read(cx) + .exec(command_line_command(details.process.pid), cx) + }); + let reading = cx.spawn(async move |this, cx| { + let command = match reply { + None => CommandLine::Unknown("终端没有连接".into()), + Some(reply) => match exec_answer(reply, cx).await { + None => CommandLine::Unknown("终端没有连接".into()), + Some(Err(error)) => CommandLine::Unknown(format!("读取失败:{error}")), + Some(Ok(output)) => match command_line(&output) { + Some(line) => CommandLine::Known(line), + None => { + CommandLine::Unknown("没有命令行:进程已经退出,或是僵尸进程".into()) + } + }, + }, + }; + this.update(cx, |this, cx| { + this.command = command; + cx.notify(); + }) + .ok(); + }); + Self { + details, + command: CommandLine::Reading, + copied: None, + _reading: reading, + copied_reset: None, + } + } + + fn copy(&mut self, which: Copied, text: String, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.copied = Some(which); + self.copied_reset = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(COPIED_FOR).await; + this.update(cx, |this, cx| { + this.copied = None; + cx.notify(); + }) + .ok(); + })); + cx.notify(); + } +} + +/// 「复制命令」, which says 「已复制」 for a moment once it has. +fn copy_button( + id: &'static str, + label: &'static str, + text: Option, + copied: bool, + which: Copied, + view: &Entity, +) -> Button { + let view = view.clone(); + Button::new(id) + .outline() + .icon(Icon::new(if copied { + IconName::Check + } else { + IconName::Copy + })) + .label(if copied { "已复制" } else { label }) + .disabled(text.is_none()) + .on_click(move |_, _, cx| { + if let Some(text) = text.clone() { + view.update(cx, |view, cx| view.copy(which, text, cx)); + } + }) +} + +impl Render for ProcessDetailsView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let details = &self.details; + let process = &details.process; + let info = &process.info; + let fields: [(&str, String); 16] = [ + ("PID", process.pid.to_string()), + ( + "父进程", + details.parent.clone().unwrap_or_else(|| "无".into()), + ), + ("用户", process.user.clone().unwrap_or_else(|| "—".into())), + ( + "状态", + format!("{} ({})", process.state.label(), info.letters), + ), + ("启动时间", format_started(process.started)), + ("运行时长", format_duration(process.running)), + ("终端", info.terminal.clone().unwrap_or_else(|| "无".into())), + ("优先级", info.priority.to_string()), + ("Nice", info.nice.to_string()), + ("CPU 使用率", process.cpu.map_or("—".into(), format_percent)), + ("累计 CPU 时间", format_duration(process.cpu_time)), + ( + "常驻内存", + format!( + "{} ({})", + format_bytes(process.memory), + format_percent(process.memory_percent) + ), + ), + ("虚拟内存", format_bytes(info.virtual_memory)), + ("线程", info.threads.to_string()), + ("直接子进程", details.children.to_string()), + ("全部后代进程", details.descendants.to_string()), + ]; + let heading = |text: &'static str| { + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(text) + }; + let command_text = match &self.command { + CommandLine::Known(line) => Some(line.clone()), + _ => None, + }; + let view = cx.entity(); + v_flex() + .id("process-details") + .test_support() + .gap_5() + .child( + v_flex().gap_1().child(heading("进程信息")).child( + div() + .grid() + .grid_cols(2) + .gap_x_8() + .children(fields.into_iter().map(|(label, value)| { + h_flex() + .id(SharedString::from(format!("process-field:{label}"))) + .test_support() + .aria_label(value.clone()) + .min_w_0() + .gap_3() + .py_2() + .border_b_1() + .border_color(cx.theme().border) + .text_sm() + .child( + div() + .w(rems(6.5)) + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child(div().flex_1().min_w_0().truncate().child(value)) + })), + ), + ) + .child( + v_flex() + .gap_2() + .child( + h_flex().justify_between().child(heading("完整命令")).child( + copy_button( + "process-copy-command", + "复制命令", + command_text, + self.copied == Some(Copied::Command), + Copied::Command, + &view, + ) + .ghost() + .small(), + ), + ) + .child(match &self.command { + CommandLine::Reading => h_flex() + .id("process-command-reading") + .test_support() + .gap_2() + .p_3() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child("正在读取…") + .into_any_element(), + CommandLine::Known(line) => div() + .id("process-command") + .test_support() + .aria_label(line.clone()) + .max_h(rems(10.)) + .overflow_y_scroll() + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().muted) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .child(line.clone()) + .into_any_element(), + CommandLine::Unknown(why) => div() + .id("process-command-unknown") + .test_support() + .aria_label(why.clone()) + .p_3() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(why.clone()) + .into_any_element(), + }), + ) + } +} diff --git a/src/processes/process_panel.rs b/src/processes/process_panel.rs new file mode 100644 index 0000000..bce5de1 --- /dev/null +++ b/src/processes/process_panel.rs @@ -0,0 +1,672 @@ +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + menu::{ContextMenuExt as _, PopupMenu}, + progress::Progress, + scroll::ScrollableElement as _, + spinner::Spinner, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::linux::{self, Parsed}; +use super::model::{ + Order, Process, ProcessDetails, ProcessSort, ProcessState, Snapshot, Tracker, format_started, +}; +use crate::app::{CatalogIcon, EndProcess, RefreshProcesses, ShowProcess, SortProcesses}; +use crate::shared::{format_bytes, format_percent}; +use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; + +/// Time between readings. The second comes sooner: a CPU share needs two, +/// and the list should not sit on dashes. +const INTERVAL: Duration = Duration::from_secs(15); +const SECOND_READING: Duration = Duration::from_secs(2); +/// How long a terminal not connected is left before it is asked again. +const RETRY: Duration = Duration::from_secs(2); + +/// 进程管理: the processes of the host of the SSH terminal in front, with +/// their memory and CPU, read every 15 seconds while the panel shows; a +/// right click on one ends it. +/// +/// Each reading runs `linux::command` beside the shell, on the terminal's +/// own connection: no second login, nothing asked again. Nothing runs on +/// the host while the panel is hidden. What each terminal's host showed +/// last is kept, so coming back to a terminal shows its list at once. The +/// search and the order stay as they are from one terminal to the next. +pub struct ProcessPanel { + target: Option, + active: bool, + hosts: HashMap, + /// The readings; dropping it stops them. + sampling: Option>, + /// Whether a reading is on its way. + loading: bool, + search: Entity, + query: String, + order: Order, + scroll_handle: UniformListScrollHandle, + /// The process a right click landed on, for the list's menu; `None` + /// off every process. + menu_hit: Rc>>, + /// The workspace's focus handle: the panel's buttons dispatch on it, so + /// they work whatever is focused. + dispatch: FocusHandle, + focus_handle: FocusHandle, + _subscriptions: Vec, +} + +#[derive(Default)] +struct HostState { + tracker: Tracker, + /// The terminal's connection the tracker's last reading came over: a + /// reconnect may land on another machine. + connection: Option, + snapshot: 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 ProcessPanel { + 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 subscriptions = vec![ + cx.subscribe(&search, |this, input, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query = input.read(cx).value().to_string(); + this.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + } + }), + ]; + Self { + target: None, + active: false, + hosts: HashMap::new(), + sampling: None, + loading: false, + search, + query: String::new(), + order: Order::default(), + scroll_handle: UniformListScrollHandle::new(), + menu_hit: Rc::new(Cell::new(None)), + dispatch, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// Read `target`'s host while `active`, the panel showing it. + 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.restart(None, cx); + cx.notify(); + } + + /// Read the host now, and every 15 seconds from now: 刷新. + pub fn refresh(&mut self, cx: &mut Context) { + self.restart(None, cx); + cx.notify(); + } + + /// Read the host in a moment: a process just told to end takes that + /// long to go. + pub fn refresh_soon(&mut self, cx: &mut Context) { + self.restart(Some(Duration::from_secs(1)), cx); + } + + /// Sort by `by`, the most first; by the same again, the other way. + pub fn sort(&mut self, by: ProcessSort, cx: &mut Context) { + self.order = self.order.toggle(by); + self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top); + cx.notify(); + } + + /// A process of the host on screen, as the list last showed it. + pub fn process(&self, pid: u32) -> Option { + self.snapshot()?.process(pid).cloned() + } + + /// A process of the host on screen with its family, for its details. + pub fn details(&self, pid: u32) -> Option { + self.snapshot()?.details(pid) + } + + fn snapshot(&self) -> Option<&Snapshot> { + let target = self.target.as_ref()?; + self.hosts.get(&target.terminal)?.snapshot.as_deref() + } + + /// Start reading the target's host over, after `delay` if any, or stop + /// when there is nothing to show. Dropping the task stops it; a reading + /// still on its way is answered to no one. + fn restart(&mut self, delay: Option, cx: &mut Context) { + self.sampling = None; + self.loading = false; + let Some(target) = self.target.clone().filter(|_| self.active) else { + return; + }; + self.sampling = Some(cx.spawn(async move |this, cx| { + if let Some(delay) = delay { + cx.background_executor().timer(delay).await; + } + loop { + let request = this.update(cx, |this, cx| { + let view = target.view.upgrade()?; + let view = view.read(cx); + let reply = view.exec(linux::command(), cx)?; + let connection = view.generation(cx); + this.loading = true; + cx.notify(); + Some((reply, connection)) + }); + let Ok(request) = request else { break }; + let (answer, connection) = match request { + None => (None, 0), + Some((reply, connection)) => (exec_answer(reply, cx).await, connection), + }; + let Ok(pause) = this.update(cx, |this, cx| { + this.record(target.terminal, connection, answer, cx) + }) else { + break; + }; + cx.background_executor().timer(pause).await; + } + })); + } + + /// Keep what a reading came back with; how long until the next. + fn record( + &mut self, + terminal: RemoteTerminalId, + connection: u64, + answer: Option, + cx: &mut Context, + ) -> Duration { + self.loading = false; + cx.notify(); + let host = self.hosts.entry(terminal).or_default(); + let output = match answer { + None => { + host.problem = Some(Problem::NotConnected); + return RETRY; + } + Some(Err(error)) => { + host.problem = Some(Problem::Failed(error)); + return INTERVAL; + } + Some(Ok(output)) => output, + }; + match linux::parse(&output) { + Parsed::Reading(reading) => { + if host.connection != Some(connection) { + host.tracker = Tracker::default(); + host.connection = Some(connection); + } + let now = chrono::Utc::now().timestamp(); + let snapshot = host.tracker.update(*reading, now); + let shares = snapshot + .processes() + .iter() + .any(|process| process.cpu.is_some()); + host.snapshot = Some(Rc::new(snapshot)); + host.problem = None; + if shares { INTERVAL } else { SECOND_READING } + } + Parsed::Unsupported(system) => { + host.problem = Some(Problem::Unsupported(system)); + INTERVAL + } + } + } + + fn render_list( + &self, + snapshot: Rc, + problem: Option, + cx: &Context, + ) -> impl IntoElement { + let rows = Rc::new(snapshot.listed(&self.query, self.order)); + let summary = format!( + "共 {} 个进程 · 每 {} 秒刷新", + snapshot.processes().len(), + INTERVAL.as_secs() + ); + let refresh = 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("processes-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("processes-refresh") + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::RefreshCw)) + .loading(self.loading) + .tooltip("刷新") + .accessibility_label("刷新") + .on_click(move |_, window, cx| { + refresh.dispatch_action(&RefreshProcesses, window, cx) + }), + ), + ) + .when_some(problem, |header, problem| { + header.child(render_problem(&problem, cx)) + }) + .when(snapshot.truncated(), |header| { + let text = format!("进程太多,只列出了前 {} 个。", linux::LIMIT); + header.child( + div() + .id("processes-truncated") + .test_support() + .aria_label(text.clone()) + .text_xs() + .text_color(cx.theme().warning) + .child(text), + ) + }) + .child( + Input::new(&self.search) + .id("processes-search") + .small() + .cleanable(true) + .prefix(Icon::new(IconName::Search).small()), + ) + .child( + h_flex() + .gap_1() + .child(sort_button( + "processes-sort-memory", + IconName::MemoryStick, + ProcessSort::Memory, + self.order, + &self.dispatch, + )) + .child(sort_button( + "processes-sort-cpu", + IconName::Cpu, + ProcessSort::Cpu, + self.order, + &self.dispatch, + )) + .child(div().flex_1()) + .child({ + let count = rows.len().to_string(); + div() + .id("processes-count") + .test_support() + .aria_label(count.clone()) + .text_xs() + .font_family(cx.theme().mono_font_family.clone()) + .text_color(cx.theme().muted_foreground) + .child(count) + }), + ); + + let clear_hit = self.menu_hit.clone(); + let menu_hit = self.menu_hit.clone(); + // The scrollbar goes on the list's box, which does not scroll: on + // the scrolled rows it would scroll away with them. The menu hangs + // off the box too, not off the rows, which are drawn after layout + // (see the host tree). + let list = div() + .id("processes-list") + .test_support() + .relative() + .flex_1() + .min_h_0() + .border_t_1() + .border_color(cx.theme().border) + .map(|list| { + if rows.is_empty() { + let empty = if self.query.trim().is_empty() { + "没有进程" + } else { + "没有符合条件的进程" + }; + list.child( + div() + .id("processes-empty") + .test_support() + .aria_label(empty) + .py_8() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(empty), + ) + } else { + let count = rows.len(); + let (hit, focus, dispatch) = ( + self.menu_hit.clone(), + self.focus_handle.clone(), + self.dispatch.clone(), + ); + list.child( + uniform_list("processes-rows", count, move |range, _, cx| { + range + .map(|index| { + let process = &snapshot.processes()[rows[index]]; + div().px_3().pt_2().child(render_process( + process, + hit.clone(), + focus.clone(), + dispatch.clone(), + cx, + )) + }) + .collect::>() + }) + .size_full() + .pb_3() + .track_scroll(&self.scroll_handle), + ) + } + }) + .vertical_scrollbar(&self.scroll_handle) + .capture_any_mouse_down(move |event, _, _| { + if matches!(event.button, MouseButton::Left | MouseButton::Right) { + clear_hit.set(None); + } + }) + .context_menu(move |menu, _, _| build_context_menu(menu_hit.get(), menu)); + v_flex().size_full().child(header).child(list) + } +} + +impl Focusable for ProcessPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ProcessPanel { + 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 snapshot = host.and_then(|host| host.snapshot.clone()); + let problem = host.and_then(|host| host.problem.clone()); + v_flex() + .id("processes") + .test_support() + .track_focus(&self.focus_handle) + .size_full() + .map(|panel| match (snapshot, problem) { + // A list from before a failed reading still says something; + // the failure goes above it. + (Some(snapshot), problem @ (None | Some(Problem::Failed(_)))) => { + panel.child(self.render_list(snapshot, problem, cx)) + } + (_, Some(problem)) => panel.child(div().p_3().child(render_problem(&problem, cx))), + (None, None) => panel.child( + h_flex() + .id("processes-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("processes-message") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(color) + .child(text) +} + +/// 「内存 ↓」: sorts by `by`, the most first, and the other way round when +/// it already does; the arrow says which way it does. +fn sort_button( + id: &'static str, + icon: IconName, + by: ProcessSort, + order: Order, + dispatch: &FocusHandle, +) -> impl IntoElement { + let active = order.by == by; + let label = match by { + ProcessSort::Memory => "内存", + ProcessSort::Cpu => "CPU", + }; + let tooltip = match (active, order.descending) { + (true, true) => format!("改为按{label}从少到多排列"), + _ => format!("按{label}从多到少排列"), + }; + let dispatch = dispatch.clone(); + Button::new(id) + .ghost() + .small() + .icon(Icon::new(icon)) + .label(label) + .when(active, |button| { + button.child(Icon::new(if order.descending { + IconName::ArrowDown + } else { + IconName::ArrowUp + })) + }) + .selected(active) + .toggled(active) + .tooltip(tooltip) + .on_click(move |_, window, cx| dispatch.dispatch_action(&SortProcesses(by), window, cx)) +} + +fn build_context_menu(hit: Option, menu: PopupMenu) -> PopupMenu { + let Some(pid) = hit else { + return menu; + }; + menu.menu_with_icon( + "查看详情", + Icon::new(IconName::Info), + Box::new(ShowProcess(pid)), + ) + .separator() + .menu_with_icon( + "结束进程…", + Icon::new(CatalogIcon::CircleStop), + Box::new(EndProcess { pid, force: false }), + ) + .menu_with_icon( + "强制结束进程…", + Icon::new(CatalogIcon::OctagonX), + Box::new(EndProcess { pid, force: true }), + ) +} + +/// What a state looks like: green running, yellow stuck or paused, red a +/// zombie, the rest (most processes, asleep) quiet. +fn state_color(state: ProcessState, cx: &App) -> Hsla { + match state { + ProcessState::Running => cx.theme().success, + ProcessState::Uninterruptible | ProcessState::Stopped => cx.theme().warning, + ProcessState::Zombie => cx.theme().danger, + ProcessState::Sleeping | ProcessState::Idle | ProcessState::Other => { + cx.theme().muted_foreground + } + } +} + +/// A process's card. Every card has the same lines, so they are all as +/// tall as the list takes them to be. +fn render_process( + process: &Process, + menu_hit: Rc>>, + focus: FocusHandle, + dispatch: FocusHandle, + cx: &App, +) -> impl IntoElement { + let pid = process.pid; + let user = process.user.clone().unwrap_or_else(|| "—".into()); + let started = format_started(process.started); + let memory = format!( + "{} · {}", + format_bytes(process.memory), + format_percent(process.memory_percent) + ); + let cpu = process.cpu.map_or("—".into(), format_percent); + let label = format!( + "{} · PID {pid} · {} · {user} · 内存 {memory} · CPU {cpu}", + process.name, + process.state.label(), + ); + let state: SharedString = process.state.label().into(); + let numeric = |text: String| { + div() + .font_family(cx.theme().mono_font_family.clone()) + .child(text) + }; + v_flex() + .id(("process", pid as u64)) + .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) + // A click opens its details; the border says it can. + .border_1() + .border_color(cx.theme().tokens.group_box) + .hover({ + let border = cx.theme().border; + move |style| style.border_color(border) + }) + .cursor_pointer() + .on_click(move |_, window, cx| dispatch.dispatch_action(&ShowProcess(pid), window, cx)) + // A right click puts the menu on this process. The menu's commands + // are dispatched from what is focused, which may be nothing. + .on_mouse_down(MouseButton::Right, move |_, window, cx| { + menu_hit.set(Some(pid)); + focus.focus(window, cx); + }) + .child( + h_flex() + .gap_2() + .child( + div() + .id(("process-state", pid as u64)) + .flex_shrink_0() + .size_2() + .rounded_full() + .bg(state_color(process.state, cx)) + .tooltip(move |window, cx| Tooltip::new(state.clone()).build(window, cx)), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(process.name.clone()), + ) + .child( + numeric(format!("PID {pid}")) + .flex_shrink_0() + .text_xs() + .text_color(cx.theme().muted_foreground), + ), + ) + .child( + div() + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("{user} · {started}")), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + h_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_color(cx.theme().muted_foreground).child("内存")) + .child(numeric(memory).text_color(cx.theme().info)), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child(div().text_color(cx.theme().muted_foreground).child("CPU")) + .child(numeric(cpu).text_color(cx.theme().warning)), + ), + ) + .child( + Progress::new(SharedString::from(format!("process-memory-{pid}"))) + .small() + .value(process.memory_percent) + .color(cx.theme().info), + ) +} diff --git a/src/shared/dialog.rs b/src/shared/dialog.rs index 2bb1a76..453b51a 100644 --- a/src/shared/dialog.rs +++ b/src/shared/dialog.rs @@ -68,7 +68,26 @@ pub fn confirm_delete( window: &mut Window, cx: &mut App, ) { - let title: SharedString = format!("删除“{name}”?").into(); + confirm_danger( + format!("删除“{name}”?").into(), + description, + "删除", + on_delete, + window, + cx, + ); +} + +/// A confirmation of something that cannot be taken back: `title` asks, +/// `description` says what follows, and the danger button names the verb. +pub fn confirm_danger( + title: SharedString, + description: Option, + verb: &'static str, + on_ok: DeleteHandler, + window: &mut Window, + cx: &mut App, +) { window.open_alert_dialog(cx, move |alert, _, _| { alert .title(title.clone()) @@ -77,15 +96,15 @@ pub fn confirm_delete( }) .button_props( DialogButtonProps::default() - .ok_text("删除") + .ok_text(verb) .ok_variant(ButtonVariant::Danger) .cancel_text("取消"), ) .show_cancel(true) .on_ok({ - let on_delete = on_delete.clone(); + let on_ok = on_ok.clone(); move |_, window, cx| { - on_delete(window, cx); + on_ok(window, cx); true } }) diff --git a/src/shared/format.rs b/src/shared/format.rs new file mode 100644 index 0000000..c1e995d --- /dev/null +++ b/src/shared/format.rs @@ -0,0 +1,69 @@ +//! How sizes, shares and durations read, the same in every tool. + +use std::time::Duration; + +const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"]; + +/// 「421.02 MB」, in units of 1024. +pub fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + return format!("{bytes} B"); + } + let mut value = bytes as f64 / 1024.; + let mut unit = 0; + while value >= 1024. && unit + 1 < UNITS.len() { + value /= 1024.; + unit += 1; + } + format!("{value:.2} {}", UNITS[unit]) +} + +/// 「43.3%」. +pub fn format_percent(percent: f32) -> String { + format!("{percent:.1}%") +} + +/// 「32 天 23:40:26」, or 「23:40:26」 within the first day: how long a host +/// or a process has been up, or the CPU time a process has used. +pub fn format_duration(duration: Duration) -> String { + let seconds = duration.as_secs(); + let days = seconds / 86_400; + let clock = format!( + "{:02}:{:02}:{:02}", + seconds / 3600 % 24, + seconds / 60 % 60, + seconds % 60 + ); + if days > 0 { + format!("{days} 天 {clock}") + } else { + clock + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes_and_shares_read_naturally() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(441_470_976), "421.02 MB"); + assert_eq!(format_bytes(9_889_644_544), "9.21 GB"); + assert_eq!(format_percent(43.27), "43.3%"); + } + + #[test] + fn durations_read_as_days_and_a_clock() { + assert_eq!( + format_duration(Duration::from_secs(32 * 86_400 + 23 * 3600 + 40 * 60 + 26)), + "32 天 23:40:26" + ); + assert_eq!( + format_duration(Duration::from_secs_f64(5. * 3600. + 12. * 60. + 9.7)), + "05:12:09" + ); + assert_eq!(format_duration(Duration::from_secs(30)), "00:00:30"); + } +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 3672fbf..89c72a6 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -2,6 +2,7 @@ mod closable_tab; mod dialog; +mod format; mod host_mark; mod latency_label; mod rename_tab_dialog; @@ -11,9 +12,10 @@ mod tab_menu; pub use closable_tab::ClosableTabTitle; pub use dialog::{ - DeleteHandler, commit_footer, confirm_delete, dismiss_form_error, form_error_notification, - parse_port, + DeleteHandler, commit_footer, confirm_danger, confirm_delete, dismiss_form_error, + form_error_notification, parse_port, }; +pub use format::{format_bytes, format_duration, format_percent}; pub use host_mark::HostMark; pub use latency_label::LatencyLabel; pub use rename_tab_dialog::{RenamableTab, open_rename_tab_dialog}; diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 2791b03..7237e3d 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -11,6 +11,7 @@ use crate::app::{CatalogIcon, ToggleTool, ToolKind}; use crate::host::{HostId, HostOs, HostStore}; use crate::monitor::{MonitorDetail, MonitorPanel}; use crate::netstat::NetstatPanel; +use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort}; use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView}; impl ToolKind { @@ -19,6 +20,7 @@ impl ToolKind { ToolKind::Snippets => "命令片段", ToolKind::History => "历史命令", ToolKind::Docker => "Docker", + ToolKind::Processes => "进程管理", ToolKind::Connections => "网络连接", ToolKind::Monitor => "系统监控", } @@ -29,6 +31,7 @@ impl ToolKind { ToolKind::Snippets => CatalogIcon::CodeXml, ToolKind::History => CatalogIcon::RotateCcwClock, ToolKind::Docker => CatalogIcon::Container, + ToolKind::Processes => CatalogIcon::ListFilter, ToolKind::Connections => CatalogIcon::Network, ToolKind::Monitor => CatalogIcon::Activity, } @@ -38,7 +41,9 @@ 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::Connections | ToolKind::Monitor => os.is_none_or(HostOs::is_linux), + ToolKind::Processes | ToolKind::Connections | ToolKind::Monitor => { + os.is_none_or(HostOs::is_linux) + } ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true, } } @@ -49,6 +54,7 @@ impl ToolKind { ToolKind::Snippets => "tool-snippets", ToolKind::History => "tool-history", ToolKind::Docker => "tool-docker", + ToolKind::Processes => "tool-processes", ToolKind::Connections => "tool-connections", ToolKind::Monitor => "tool-monitor", } @@ -61,6 +67,7 @@ impl ToolKind { ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(), ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"), ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"), + ToolKind::Processes => format!("{host} 上的进程:内存、CPU,结束进程。"), ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"), ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"), } @@ -96,6 +103,7 @@ pub struct ToolSidebar { /// Whether the right dock is open. shown: bool, monitor: Entity, + processes: Entity, netstat: Entity, store: Entity, focus_handle: FocusHandle, @@ -118,6 +126,7 @@ impl ToolSidebar { terminal: None, shown: false, monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())), + processes: cx.new(|cx| ProcessPanel::new(dispatch.clone(), window, cx)), netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)), store, focus_handle: cx.focus_handle(), @@ -155,6 +164,34 @@ impl ToolSidebar { self.netstat.update(cx, |netstat, cx| netstat.refresh(cx)); } + /// 进程管理's 刷新. + pub fn refresh_processes(&mut self, cx: &mut Context) { + self.processes + .update(cx, |processes, cx| processes.refresh(cx)); + } + + /// Read the processes again in a moment, once one was told to end. + pub fn refresh_processes_soon(&mut self, cx: &mut Context) { + self.processes + .update(cx, |processes, cx| processes.refresh_soon(cx)); + } + + /// 进程管理's sort buttons. + pub fn sort_processes(&mut self, by: ProcessSort, cx: &mut Context) { + self.processes + .update(cx, |processes, cx| processes.sort(by, cx)); + } + + /// A process of the host in front, as 进程管理 last showed it. + pub fn process(&self, pid: u32, cx: &App) -> Option { + self.processes.read(cx).process(pid) + } + + /// The same with its family, for its details. + pub fn process_details(&self, pid: u32, cx: &App) -> Option { + self.processes.read(cx).details(pid) + } + /// 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| ExecTarget { @@ -162,10 +199,17 @@ impl ToolSidebar { view: terminal.view.clone(), }); let showing = |tool| self.shown && self.tool == tool; - let (monitor, netstat) = (showing(ToolKind::Monitor), showing(ToolKind::Connections)); + let (monitor, netstat, processes) = ( + showing(ToolKind::Monitor), + showing(ToolKind::Connections), + showing(ToolKind::Processes), + ); self.monitor.update(cx, |panel, cx| { panel.set_target(target.clone(), monitor, cx) }); + self.processes.update(cx, |panel, cx| { + panel.set_target(target.clone(), processes, cx) + }); self.netstat .update(cx, |panel, cx| panel.set_target(target, netstat, cx)); } @@ -263,6 +307,9 @@ impl Render for ToolSidebar { ToolKind::Connections => { sidebar.child(div().flex_1().min_h_0().child(self.netstat.clone())) } + ToolKind::Processes => { + sidebar.child(div().flex_1().min_h_0().child(self.processes.clone())) + } _ => sidebar.child(self.render_placeholder(terminal.host, cx)), } }) diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 9cb7013..efaeb7b 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -8,12 +8,22 @@ //! on the host in front (the monitor on a host known not to run Linux) is //! not offered there: no button, and no sidebar while it is the one picked. -use gpui_kit::component::dock::{DockArea, DockPlacement}; +use std::rc::Rc; + +use gpui_kit::component::{ + WindowExt as _, + dock::{DockArea, DockPlacement}, + notification::Notification, +}; use gpui_kit::*; use crate::app::{ - CenterTab, RefreshConnections, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind, + CenterTab, EndProcess, RefreshConnections, RefreshProcesses, ShowProcess, SortProcesses, + ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind, }; +use crate::processes::{end_command, ended, open_process_dialog}; +use crate::shared::confirm_danger; +use crate::terminal::exec_answer; use super::{Workspace, tool_sidebar::ToolTerminal}; @@ -134,6 +144,139 @@ impl Workspace { .update(cx, |tools, cx| tools.refresh_connections(cx)); } + /// 进程管理's 刷新: read the host's processes again. + pub(super) fn on_refresh_processes( + &mut self, + _: &RefreshProcesses, + _: &mut Window, + cx: &mut Context, + ) { + self.tools + .update(cx, |tools, cx| tools.refresh_processes(cx)); + } + + /// 进程管理's sort buttons. + pub(super) fn on_sort_processes( + &mut self, + action: &SortProcesses, + _: &mut Window, + cx: &mut Context, + ) { + let by = action.0; + self.tools + .update(cx, |tools, cx| tools.sort_processes(by, cx)); + } + + /// A process's details, from 进程管理's list and its command line. + pub(super) fn on_show_process( + &mut self, + action: &ShowProcess, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.tool_terminal(cx) else { + return; + }; + let Some(details) = self.tools.read(cx).process_details(action.0, cx) else { + return; + }; + open_process_dialog( + details, + terminal.view, + self.focus_handle.clone(), + window, + cx, + ); + } + + /// Ask, naming the process, then send it SIGTERM, or SIGKILL when + /// forced, over the SSH terminal's own connection. How that went is a + /// notification, and the list is read again a moment later. + pub(super) fn on_end_process( + &mut self, + action: &EndProcess, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.tool_terminal(cx) else { + return; + }; + let EndProcess { pid, force } = *action; + let Some(process) = self.tools.read(cx).process(pid, cx) else { + return; + }; + let name = process.name; + let user = process.user.unwrap_or_else(|| "—".into()); + let (title, description, verb) = if force { + ( + format!("强制结束进程“{name}”?"), + format!( + "PID {pid},用户 {user}。进程会立即被终止(SIGKILL),来不及保存数据; 只在「结束进程」不起作用时使用。" + ), + "强制结束", + ) + } else { + ( + format!("结束进程“{name}”?"), + format!( + "PID {pid},用户 {user}。进程会收到结束信号(SIGTERM),可以先做完收尾再退出。" + ), + "结束进程", + ) + }; + let workspace = cx.entity().downgrade(); + confirm_danger( + title.into(), + Some(description.into()), + verb, + Rc::new(move |window, cx| { + let Some(view) = terminal.view.upgrade() else { + return; + }; + let Some(reply) = view.read(cx).exec(end_command(pid, force), cx) else { + window.push_notification( + Notification::error(format!("无法结束 {name}(PID {pid}):终端没有连接")), + cx, + ); + return; + }; + let (workspace, name) = (workspace.clone(), name.clone()); + window + .spawn(cx, async move |cx| { + let outcome = match exec_answer(reply, cx).await { + None => Err("终端没有连接".to_string()), + Some(Err(error)) => Err(error), + Some(Ok(output)) => ended(&output), + }; + cx.update(|window, cx| { + let notification = match outcome { + Ok(()) if force => { + Notification::success(format!("已强制结束 {name}(PID {pid})")) + } + Ok(()) => Notification::success(format!( + "已向 {name}(PID {pid})发送结束信号" + )), + Err(why) => Notification::error(format!( + "无法结束 {name}(PID {pid}):{why}" + )), + }; + window.push_notification(notification, cx); + workspace + .update(cx, |this, cx| { + this.tools + .update(cx, |tools, cx| tools.refresh_processes_soon(cx)) + }) + .ok(); + }) + .ok(); + }) + .detach(); + }), + window, + 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 694877a..5c2228e 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2236,6 +2236,10 @@ impl Render for Workspace { .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_refresh_processes)) + .on_action(cx.listener(Self::on_sort_processes)) + .on_action(cx.listener(Self::on_end_process)) + .on_action(cx.listener(Self::on_show_process)) .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 c6a2c22..ea7db45 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -14,6 +14,7 @@ mod host_dialog; mod host_tree; mod monitor; mod netstat; +mod processes; mod settings; mod sftp; mod terminal; diff --git a/tests/ui/monitor.rs b/tests/ui/monitor.rs index 53b13ae..d0c1f7d 100644 --- a/tests/ui/monitor.rs +++ b/tests/ui/monitor.rs @@ -257,6 +257,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.try_find("tool-processes").is_none()); assert!(window.find("tool-history").visible()); assert!( window diff --git a/tests/ui/processes.rs b/tests/ui/processes.rs new file mode 100644 index 0000000..bcd77a8 --- /dev/null +++ b/tests/ui/processes.rs @@ -0,0 +1,444 @@ +//! 进程管理: the right sidebar's tool that lists the processes of the SSH +//! terminal's host, and ends them, over the terminal's own connection. + +use gpui_kit::{SharedString, Window}; +use shellrs::app::EndProcess; + +use crate::support::*; + +/// A small host: systemd, a java service and a node app, with 2 000 000 kB +/// of memory. +const FIRST: &str = "\ +@@uptime +1000.00 3000.00 +@@memory +2000000 +@@clock +100 +@@page +4096 +@@users +0 root +1000 ecs-user +@@processes +0 1 (systemd) S 0 1 1 0 -1 4194560 0 0 0 0 150 80 0 0 20 0 1 0 5 172000000 3000 0 +0 2202 (java) S 1 2202 2202 0 -1 4194560 0 0 0 0 1000 0 0 0 20 0 30 0 5000 900000000 120000 0 +1000 1479 (node) R 1 1479 1479 0 -1 4194560 0 0 0 0 4000 0 0 0 20 0 11 0 9000 900000000 30000 0 +"; + +/// Two seconds later: java used a tenth of a second of CPU, node a whole +/// second. +const SECOND: &str = "\ +@@uptime +1002.00 3000.00 +@@memory +2000000 +@@clock +100 +@@page +4096 +@@users +0 root +1000 ecs-user +@@processes +0 1 (systemd) S 0 1 1 0 -1 4194560 0 0 0 0 150 80 0 0 20 0 1 0 5 172000000 3000 0 +0 2202 (java) S 1 2202 2202 0 -1 4194560 0 0 0 0 1010 0 0 0 20 0 30 0 5000 900000000 120000 0 +1000 1479 (node) R 1 1479 1479 0 -1 4194560 0 0 0 0 4100 0 0 0 20 0 11 0 9000 900000000 30000 0 +"; + +const JAVA: u64 = 2202; +const NODE: u64 = 1479; +const SYSTEMD: u64 = 1; + +fn open_processes( + 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-processes", cx)); + handle +} + +async fn wait_for_label( + cx: &mut TestAppContext, + handle: WindowHandle, + id: impl Into + Clone, + label: &str, +) { + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window + .try_find(id.clone()) + .is_some_and(|element| element.label() == Some(label)) + }) + .await; +} + +/// The processes from the top of the list down. +fn listed(window: &mut Window) -> Vec { + let mut cards: Vec<(f32, u64)> = [JAVA, NODE, SYSTEMD] + .into_iter() + .filter_map(|pid| { + let card = window.try_find(("process", pid))?; + Some((f32::from(card.bounds().origin.y), pid)) + }) + .collect(); + cards.sort_by(|a, b| a.0.total_cmp(&b.0)); + cards.into_iter().map(|(_, pid)| pid).collect() +} + +#[gpui_kit::test] +async fn the_processes_show_by_memory_and_their_cpu_once_read_twice(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_processes(cx, factory.clone()); + wait_for_label( + cx, + handle, + "processes-summary", + "共 3 个进程 · 每 15 秒刷新", + ) + .await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(), + Some("进程管理") + ); + assert_eq!(listed(window), [JAVA, NODE, SYSTEMD]); + assert_eq!(window.find("processes-count").label(), Some("3")); + assert_eq!(window.find("processes-sort-memory").checked(), Some(true)); + }); + + // The second reading comes soon, and gives the CPU shares. + wait_for_label( + cx, + handle, + ("process", JAVA), + "java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%", + ) + .await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("process", NODE)).label(), + Some("node · PID 1479 · 运行中 · ecs-user · 内存 117.19 MB · 6.0% · CPU 50.0%") + ); + }); + assert_eq!(factory.exec_count(), 2); + + // Then every 15 seconds. + cx.executor().advance_clock(Duration::from_secs(14)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), 2); + cx.executor().advance_clock(Duration::from_secs(2)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), 3); +} + +#[gpui_kit::test] +async fn the_processes_sort_by_cpu_and_back(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_processes(cx, factory); + wait_for_label( + cx, + handle, + ("process", NODE), + "node · PID 1479 · 运行中 · ecs-user · 内存 117.19 MB · 6.0% · CPU 50.0%", + ) + .await; + + in_frame(cx, handle, |window, cx| { + window.click("processes-sort-cpu", cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("processes-sort-cpu").checked(), Some(true)); + assert_eq!(window.find("processes-sort-memory").checked(), Some(false)); + assert_eq!(listed(window), [NODE, JAVA, SYSTEMD]); + }); + + // Again: the least first. + in_frame(cx, handle, |window, cx| { + window.click("processes-sort-cpu", cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!(listed(window), [SYSTEMD, JAVA, NODE]); + }); + + in_frame(cx, handle, |window, cx| { + window.click("processes-sort-memory", cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!(listed(window), [JAVA, NODE, SYSTEMD]); + }); +} + +#[gpui_kit::test] +async fn the_search_finds_processes_by_name_pid_or_user(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST])); + let handle = open_processes(cx, factory); + wait_for_label( + cx, + handle, + "processes-summary", + "共 3 个进程 · 每 15 秒刷新", + ) + .await; + + in_frame(cx, handle, |window, cx| { + window.click("processes-search", cx); + window.input("ecs", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!(listed(window), [NODE]); + assert_eq!(window.find("processes-count").label(), Some("1")); + // The summary is still the host's. + assert_eq!( + window.find("processes-summary").label(), + Some("共 3 个进程 · 每 15 秒刷新") + ); + }); + + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("2202", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!(listed(window), [JAVA]); + }); + + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("nginx", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("processes-empty").label(), + Some("没有符合条件的进程") + ); + }); +} + +#[gpui_kit::test] +async fn ending_a_process_asks_first_then_reads_the_list_again(cx: &mut TestAppContext) { + let after = SECOND + .lines() + .filter(|line| !line.contains("(java)")) + .collect::>() + .join("\n"); + let factory = Arc::new(FakeTerminalFactory::answering(&[ + FIRST, + SECOND, + "@@status 0\n", + &after, + ])); + let handle = open_processes(cx, factory.clone()); + wait_for_label( + cx, + handle, + ("process", JAVA), + "java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%", + ) + .await; + + in_frame(cx, handle, |window, cx| { + window.dispatch_action( + Box::new(EndProcess { + pid: 2202, + force: false, + }), + cx, + ) + }); + // Nothing is sent before the answer. + assert_eq!(factory.exec_count(), 2); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window.try_find(("process", JAVA)).is_none() + }) + .await; + let commands = factory.exec_commands(); + assert_eq!( + commands[2], + "sh -c 'kill -TERM 2202 2>&1; echo @@status $?'" + ); + assert_eq!(commands.len(), 4); +} + +/// What `/proc/2202/cmdline` holds: the arguments, each ended by a NUL. +const JAVA_COMMAND: &str = + "/usr/lib/jvm/java-21-openjdk-amd64/bin/java\0-Xms128m\0-jar\0opc-server-0.0.1.jar\0"; + +#[gpui_kit::test] +async fn a_click_on_a_process_shows_its_details_and_command_line(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[ + FIRST, + SECOND, + JAVA_COMMAND, + ])); + let handle = open_processes(cx, factory.clone()); + wait_for_label( + cx, + handle, + ("process", JAVA), + "java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%", + ) + .await; + + in_frame(cx, handle, |window, cx| window.click(("process", JAVA), cx)); + wait_for_label( + cx, + handle, + "process-command", + "/usr/lib/jvm/java-21-openjdk-amd64/bin/java -Xms128m -jar opc-server-0.0.1.jar", + ) + .await; + assert_eq!(factory.exec_commands()[2], "sh -c 'cat /proc/2202/cmdline'"); + in_frame(cx, handle, |window, _| { + let field = |label: &str| { + window + .find(SharedString::from(format!("process-field:{label}"))) + .label() + .map(str::to_owned) + }; + for (label, value) in [ + ("PID", "2202"), + ("父进程", "systemd (1)"), + ("用户", "root"), + // A session leader with threads. + ("状态", "休眠 (Ssl)"), + // Up since tick 5000 of a host up 1002 s. + ("运行时长", "00:15:52"), + ("终端", "无"), + ("优先级", "20"), + ("Nice", "0"), + ("CPU 使用率", "5.0%"), + ("累计 CPU 时间", "00:00:10"), + ("常驻内存", "468.75 MB (24.0%)"), + ("虚拟内存", "858.31 MB"), + ("线程", "30"), + ("直接子进程", "0"), + ("全部后代进程", "0"), + ] { + assert_eq!(field(label).as_deref(), Some(value), "{label}"); + } + }); + + in_frame(cx, handle, |window, cx| { + window.click("process-copy-command", cx) + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some( + "/usr/lib/jvm/java-21-openjdk-amd64/bin/java -Xms128m -jar opc-server-0.0.1.jar".into() + ) + ); + in_frame(cx, handle, |window, cx| { + window.click("process-copy-pid", cx) + }); + assert_eq!( + cx.read_from_clipboard().and_then(|item| item.text()), + Some("2202".into()) + ); +} + +#[gpui_kit::test] +async fn the_details_end_the_process_after_asking(cx: &mut TestAppContext) { + let after = SECOND + .lines() + .filter(|line| !line.contains("(java)")) + .collect::>() + .join("\n"); + let factory = Arc::new(FakeTerminalFactory::answering(&[ + FIRST, + SECOND, + JAVA_COMMAND, + "@@status 0\n", + &after, + ])); + let handle = open_processes(cx, factory.clone()); + wait_for_label( + cx, + handle, + ("process", JAVA), + "java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%", + ) + .await; + in_frame(cx, handle, |window, cx| window.click(("process", JAVA), cx)); + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window.try_find("process-command").is_some() + }) + .await; + + // The details give way to the question: one dialog at a time. + in_frame(cx, handle, |window, cx| { + window.click("process-force-end", cx) + }); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("process-details").is_none()); + }); + assert_eq!(factory.exec_count(), 3); + in_frame(cx, handle, |window, cx| window.click("ok", cx)); + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window.try_find(("process", JAVA)).is_none() + }) + .await; + assert_eq!( + factory.exec_commands()[3], + "sh -c 'kill -KILL 2202 2>&1; echo @@status $?'" + ); +} + +#[gpui_kit::test] +async fn the_processes_are_read_only_while_they_show(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_processes(cx, factory.clone()); + wait_for_label( + cx, + handle, + ("process", JAVA), + "java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%", + ) + .await; + + in_frame(cx, handle, |window, cx| window.click("tool-processes", cx)); + let hidden = factory.exec_count(); + cx.executor().advance_clock(Duration::from_secs(60)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), hidden); + + // Back, the list it had shows at once while it is read again. + in_frame(cx, handle, |window, cx| window.click("tool-processes", cx)); + in_frame(cx, handle, |window, _| { + assert_eq!(listed(window), [JAVA, NODE, SYSTEMD]); + }); + cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| { + factory.exec_count() > hidden + }) + .await; +} + +#[gpui_kit::test] +async fn the_processes_wait_for_the_terminal_to_connect(cx: &mut TestAppContext) { + // web-01's terminal is the first started, and exits at once. + let handle = open_processes(cx, Arc::new(FakeTerminalFactory::exit_first())); + wait_for_label( + cx, + handle, + "processes-message", + "终端没有连接。连接后这里显示主机的进程。", + ) + .await; +} + +#[gpui_kit::test] +async fn the_processes_say_they_read_linux_hosts_only(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nDarwin\n"])); + let handle = open_processes(cx, factory); + wait_for_label( + cx, + handle, + "processes-message", + "暂不支持管理 Darwin 的进程,目前只支持 Linux 主机。", + ) + .await; +}