diff --git a/CLAUDE.md b/CLAUDE.md index 5704106..00b61a9 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 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 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 个进程,超过时顶上写明。 +已确认的产品决定: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 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(`is-system-running` 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),`TabBar` 的四个标签「全部 / 运行中 / 已停止 / 失败」带数量(数量随搜索变);列表分「自定义服务」(unit 文件在 `/etc/systemd/system/`)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;`TabBar` 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 `journalctl -u` 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(`ssh.service` / `sshd.service` 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。**不是 root 时用 `sudo -n`**:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 `systemctl`):打开、换终端、刷新、执行命令之后各读一次。 做 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`。 +- `services/` — 系统服务,右侧栏的第四个真工具,只管 systemd 的 Linux 主机:`linux.rs`(列表的命令 `command()` 和解析 `parse`,`valid_name`,命令 `control_command` / `controlled`,详情的 `status_command` / `parse_status`,日志的 `log_command`)、`model.rs`(`Service` / `ActiveState` / `ServiceCommand`,`ServiceTable::rows` 分组、按标签和搜索过滤,`counts`,状态的中文说法,都是纯函数)、`service_panel.rs`(`ServicePanel`:读取、搜索框、`TabBar`、gpui 的 `list` 画的分组标题和卡片、卡片上的按钮和挂在列表上的右键菜单)、`service_details.rs`(`open_service_dialog`:详情对话框,正文 `ServiceDetailsView` 打开时读状态、切到日志页时读日志)。依赖 `terminal` 和 `shared`;命令的确认和通知在工作区(`on_control_service`)。 - `processes/` — 进程管理,右侧栏的第三个真工具,只读和结束远程 Linux 主机的进程:`linux.rs`(读数的命令 `command()` 和解析 `parse`,结束进程的 `end_command` 和读结果的 `ended`)、`model.rs`(`Reading` / `ProcessTimes`,`Tracker` 用两次读数的 CPU 节拍算出 `Snapshot`,`Snapshot::listed` 按 `Order` 排序、按搜索词过滤,都是纯函数)、`process_panel.rs`(`ProcessPanel`:轮询读数、搜索框、排序按钮、`uniform_list` 画的卡片和挂在列表上的右键菜单)、`process_details.rs`(`open_process_dialog`:详情对话框,正文 `ProcessDetailsView` 打开时读命令行)。依赖 `terminal` 和 `shared`,不依赖 `workspace`、`monitor` 和 `netstat`;结束进程的确认和通知在工作区(`workspace/tools.rs` 的 `on_end_process`)。 - `netstat/` — 网络连接,右侧栏的第二个真工具,只读远程 Linux 主机:`linux.rs`(命令 `command()` 和解析 `parse`)、`model.rs`(`Socket` / `SocketState` / `Role`;`Table::new` 合并同一地址的几行、定角色、排序、计数,`Table::filtered` 按 `Filter` 搜索和筛选,都是纯函数)、`netstat_panel.rs`(`NetstatPanel`:读取、搜索框和两个下拉、`uniform_list` 画的卡片),见下面「网络连接(`netstat/`)读一次,按需刷新」。依赖 `terminal`,不依赖 `workspace` 和 `monitor`。 - `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 @@ -111,9 +112,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`、`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,主机改名跟着变。 +- **右侧栏是右侧 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`、`ServicePanel`),`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()`(见下面「滚动条挂在不滚动的外层上」)。 +- **系统服务(`services/`)一次 `systemctl show` 读全部服务。** 列表命令(`linux::command()`)先判 `/run/systemd/system` 存不存在(sd_booted 的判法,不在就 `@@unsupported` 加 `uname -s`,Linux 没 systemd 时界面写「这台主机没有使用 systemd」),`@@version`、`@@state` 是 `systemctl --version` 和 `is-system-running`;然后 `list-unit-files` 和 `list-units --all --plain` 的第一列合起来,去掉模板(`*@.service`,只能以实例运行),交给**一次** `systemctl show -p Id -p Description … -- $names`(属性一个 `-p` 一个,systemd 230 以前不认逗号)。别名(`sshd.service`)在 `show` 里的 `Id` 是本名,`ServiceTable::new` 按名字去重;`LoadState=not-found`(被引用但没有 unit 文件)的只在失败时列出;自定义与否看 `FragmentPath` 在不在 `/etc/systemd/system/`。`$(…)` 里的 `case` 分支写成 `(pattern)`:bash 3.2 会把分支裸的 `)` 当成命令替换的结尾(macOS 的 `sh` 就是它,`the_commands_are_valid_sh` 在本机这样失败过);服务名用 `printf "%s\n"` 印,dash 的 `echo` 会解释反斜杠。**拼进命令的服务名先过 `valid_name`**(只许字母、数字和 `:_.@-\`,systemd 自己的规则,且不许连续两个反斜杠),放在双引号里(`\x2d` 这种转义原样留下),不合格就不执行。命令(`control_command`)是 `if test "$(id -u)" = 0; then systemctl <动词> -- "名字"; else sudo -n systemctl …; fi 2>&1; echo @@status $?`,`controlled` 把要密码、要交互认证、不在 sudoers 都翻成「需要 root 权限…」,别的取 systemctl 的第一行(「Job for … failed because …」)。日志是 `journalctl -u … -n 200 --no-pager`,非 root 先试 `sudo -n`、不行就看自己看得到的。`the_script_shows_every_service_but_templates` 用一个假 `systemctl` 脚本(按第一个参数回答)真跑列表命令。列表用 gpui 的 `list`(`ListState`,量每行的高度)而不是 `uniform_list`,因为分组标题和卡片不一样高;`ServicePanel::sync_list` 在行变了时 `splice`、换标签或搜索时 `reset`(回到顶上)。卡片上的按钮 `stop_propagation`,免得连卡片的单击(打开详情)一起触发。`ControlService { name, command }` 由工作区处理:停止、重启走 `confirm_danger`,其余直接执行;详情对话框的按钮同进程详情,先 `close_dialog` 再在工作区的 focus handle 上派发。 - **进程管理(`processes/`)只用 shell 内建命令读 `/proc`。** 命令(`linux::command()`)和系统监控一样是一行 `sh -c '…'`:`@@uptime`、`@@memory`(`MemTotal`)、`@@clock` / `@@page` 是 `getconf CLK_TCK` / `getconf PAGESIZE`(没有 `getconf` 时按 100 和 4096),`@@users` 读 `/etc/passwd`,然后 `for d in /proc/[0-9]*` 每个进程读一行 `stat`、再读 `status` 读到 `Uid:` 为止,印出「uid stat 行」;进程在列出和读取之间退出时 `read … || continue` 跳过(dash、bash 遇到重定向失败都不退出),整个循环的错误输出丢掉;数到 `LIMIT`(2500,一行约 300 字节,exec 的输出最多 1 MB)就印 `@@more` 停下。`stat` 的名字在括号里、可能带空格和括号,所以 `parse_process` 从最后一个 `)` 往后数字段(第 9 个是 flags,14、15 是 utime、stime,22 是启动时刻,24 是 rss 页数);flags 带 `PF_KTHREAD`(0x200000)的是内核线程,不列。`Tracker` 以「PID + 启动时刻」认进程(PID 会被新进程重用),用主机自己的 uptime 差做分母,uptime 变小(重启、另一台机器)不出比率;终端重连(`TerminalView::generation` 变了)就换一个新的 `Tracker`。启动时间是「本机现在 − uptime + 启动节拍 / 每秒节拍」,按本机时区显示,和主机的时钟差多少就偏多少。`the_script_reads_proc_with_shell_built_ins` 在临时目录里摆一份假 `/proc`(含一个内核线程和一个读不到 `stat` 的进程)用本机的 `sh` 真跑。结束进程:右键菜单挂在 `processes-list` 上(同主机树,卡片是 `uniform_list` 在布局之后画的),卡片的右键按下记下 PID(`menu_hit`)并把焦点交给面板,菜单项才有派发路径;菜单派发 `EndProcess { pid, force }`,工作区的 `on_end_process` 从 `ToolSidebar::process` 取名称和用户,用 `shared::confirm_danger` 确认(`confirm_delete` 也是它),确认后在同一个终端上执行 `end_command`(`kill -TERM|-KILL 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` 上。 @@ -187,4 +189,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`(网络连接)、`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` 初始为已连接并已打开终端标签。 +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`(进程管理)、`services`(系统服务)、`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-services` / `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 分隔的命令行输出。系统服务的有:面板 `services`、汇总 `services-summary`(「systemd 249 · 降级运行」)、`services-refresh`、`services-search`、标签栏外包的 `services-tabs`(`within("services-tabs").click(3usize)` 是「失败」,`aria_label` 形如「失败 1」)、列表 `services-list`、分组标题 `services-group:custom` / `services-group:system`(`aria_label` 是「自定义服务 1」)、卡片 `service:<名字>`(`aria_label` 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 `service-stop:<名字>` / `service-restart:<名字>` / `service-start:<名字>` / `service-more:<名字>`、`services-empty`、`services-loading`、`services-message`;详情的正文 `service-details`、标签栏 `service-tabs`(`1usize` 是日志)、每项 `service-field:<标签>`、日志 `service-journal`(`aria_label` 是全文)与读取中的 `service-status-reading` / `service-journal-reading`、读不到的 `service-status-unknown` / `service-journal-unknown`、底部按钮 `service-dialog-<动词>`(`stop` / `restart` / `start` / `enable` / `disable`);假终端按顺序回答列表、`@@status 0`、状态、日志。假终端 `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 9b25477..5d6ca54 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -135,9 +135,13 @@ shellrs download <远程路径> <本地路径> - **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。 - **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。 -## 右侧栏:进程管理、网络连接与系统监控 +## 右侧栏 -当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前进程管理、网络连接和系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示这三个工具的图标。 +当前标签是远程终端时,窗口最右边有一列工具按钮:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。点一个就在右侧打开这个工具,再点一次收起;⌘⌥B(其他平台 Ctrl+Alt+B)显示或隐藏右侧栏。拖动右侧栏的左边缘可以加宽,最窄就是打开时的宽度。右侧栏只跟着远程终端出现,切到 SFTP、本地终端或设置时连同按钮一起隐藏,回到任一远程终端时按原样恢复,内容对应当前这个终端的主机。目前系统服务、进程管理、网络连接和系统监控可用,其余工具即将推出。已知不是 Linux 的主机(macOS、Windows、BSD)不显示这几个工具的图标。 + +**系统服务**列出远程主机上的 systemd 服务(不含模板),分成「自定义服务」(unit 文件在 `/etc/systemd/system`,也就是管理员自己加的)和「系统服务」两组。顶部是 systemd 版本和整机状态(有服务失败时是「降级运行」);「全部」「运行中」「已停止」「失败」四个标签页带数量,也可以按服务名或描述搜索。每个服务一张卡片:状态圆点、名称、描述、运行状态和开机启动状态;运行中的服务右边有「停止」「重启」,没运行的有「启动」,点「…」或卡片打开详情。详情的「状态」页是加载状态、运行状态、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动和停止时间、unit 文件;「日志」页是这个服务在 journal 里最近 200 行。详情底部和右键菜单里同样可以启动、停止、重启,以及启用或禁用开机启动。 + +停止和重启会先确认(停止 SSH 服务时会特别说明),启动和开机启动的设置直接执行,结果以通知显示,之后列表重新读取。这些操作需要 root 权限:不是以 root 登录时通过免密码的 sudo 执行,sudo 需要密码时会提示需要 root 权限。列表在打开系统服务或切换终端时读取,之后点刷新按钮才重新读取。没有使用 systemd 的主机会写明暂不支持。 **进程管理**列出远程 Linux 主机上的进程(不含内核线程),每个一张卡片:状态圆点(绿色运行中、灰色休眠、黄色等待 I/O 或已暂停、红色僵尸进程,鼠标停上去显示状态)、进程名、PID、用户、启动时间(按本机时区),以及内存用量和占物理内存的比例、CPU 占用。CPU 占用和 `top` 的算法一样,按一个核计:占满两个核是 200%;它要两次读数才算得出,所以刚打开时先显示「—」,2 秒后出现。默认按内存从多到少排列,点「CPU」改按 CPU 排列,再点一次同一个按钮反过来排;可以按进程名、PID 或用户搜索,右边的数字是当前列出的个数。每 15 秒刷新一次,只在进程管理显示时读取,点右上角的刷新按钮立即读取。 diff --git a/src/app/actions.rs b/src/app/actions.rs index b298650..40b0f1a 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -9,6 +9,7 @@ use crate::{ host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop}, monitor::MonitorDetail, processes::ProcessSort, + services::ServiceCommand, terminal::{LocalTerminalId, RemoteTerminalId}, }; @@ -44,6 +45,8 @@ gpui_kit::actions!( RefreshConnections, /// Read the processes of the SSH terminal's host again. RefreshProcesses, + /// Read the services of the SSH terminal's host again. + RefreshServices, /// Show the host list in the left dock. ShowHosts, /// Show the port-forwarding list in the left dock. @@ -197,6 +200,9 @@ id_actions! { /// Open the details of a process of the host of the SSH terminal in /// front, by its PID. ShowProcess(u32); + /// Open the details of a service of the host of the SSH terminal in + /// front, by its name. + ShowService(String); /// Start a port forward over a connection of its own. StartForward(ForwardId); @@ -234,6 +240,16 @@ pub struct EndProcess { pub force: bool, } +/// Start, stop or restart a service of the host of the SSH terminal in +/// front, or enable or disable it at boot; asks first before stopping or +/// restarting it. +#[derive(Action, Clone, PartialEq, Eq)] +#[action(namespace = shellrs, no_json)] +pub struct ControlService { + pub name: String, + pub command: ServiceCommand, +} + /// One tab of the center area, by the identity of what it shows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CenterTab { @@ -251,16 +267,18 @@ pub enum ToolKind { Snippets, History, Docker, + Services, Processes, Connections, Monitor, } impl ToolKind { - pub const ALL: [ToolKind; 6] = [ + pub const ALL: [ToolKind; 7] = [ ToolKind::Snippets, ToolKind::History, ToolKind::Docker, + ToolKind::Services, ToolKind::Processes, ToolKind::Connections, ToolKind::Monitor, diff --git a/src/app/assets.rs b/src/app/assets.rs index c8935a9..dd421aa 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -59,7 +59,13 @@ icon_assets!( ArrowUpRight, ListFilter, CircleStop, - OctagonX + OctagonX, + ServerCog, + RotateCw, + CircleCheck, + Ban, + CircleDot, + FileText ] ); diff --git a/src/lib.rs b/src/lib.rs index 30ccff2..334af06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,8 @@ //! Modules are organized by capability so they can become crates later: //! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` -//! (凭据), `monitor` (系统监控), `processes` (进程管理), `netstat` (网络连接), +//! (凭据), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理), +//! `netstat` (网络连接), //! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `connection` 与 //! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). @@ -18,6 +19,7 @@ pub mod monitor; pub mod netstat; pub mod processes; pub mod secrets; +pub mod services; pub mod settings; pub mod sftp; pub mod shared; diff --git a/src/services/linux.rs b/src/services/linux.rs new file mode 100644 index 0000000..9dcae80 --- /dev/null +++ b/src/services/linux.rs @@ -0,0 +1,514 @@ +//! Reading and running a Linux host's systemd services: the commands, and +//! the parsers for what they print. + +use std::collections::HashMap; + +use super::model::{Service, ServiceCommand, ServiceStatus, ServiceTable}; + +/// What the list asks `systemctl show` for each service. +const LIST_PROPERTIES: [&str; 6] = [ + "Id", + "Description", + "ActiveState", + "UnitFileState", + "FragmentPath", + "LoadState", +]; + +/// What a service's details ask for. +const STATUS_PROPERTIES: [&str; 14] = [ + "Id", + "Description", + "LoadState", + "ActiveState", + "SubState", + "UnitFileState", + "FragmentPath", + "MainPID", + "MemoryCurrent", + "TasksCurrent", + "NRestarts", + "ExecMainStatus", + "ActiveEnterTimestamp", + "InactiveEnterTimestamp", +]; + +/// `-p Id -p Description …`: one property a flag, which systemd before 230 +/// needs (later ones take them comma-separated too). +fn properties(names: &[&str]) -> String { + names + .iter() + .map(|name| format!("-p {name}")) + .collect::>() + .join(" ") +} + +/// What 系统服务 runs on the host for the list: systemd's version and how +/// the machine is doing, then every service's state in one `systemctl +/// show`: those with a unit file (but templates, which run only as +/// instances) and those loaded without one (instances, and some LSB +/// scripts). Each part under an `@@` line of its own. +/// +/// One line, in single quotes for `sh -c`, so that whatever the login shell +/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the +/// script has no single quote, no `!` for csh to expand, and no two +/// backslashes in a row for fish to make one. The `case` patterns open +/// with `(`: inside `$(…)`, bash 3.2 takes a pattern's bare `)` for the +/// end of the substitution. +pub fn command() -> String { + format!( + "sh -c 'export LC_ALL=C PATH=$PATH:/usr/sbin:/sbin; \ + if test -d /run/systemd/system; then \ + echo @@version; systemctl --version 2>/dev/null; \ + echo @@state; systemctl is-system-running 2>/dev/null; \ + names=$({{ systemctl list-unit-files --type=service --no-legend --no-pager 2>/dev/null; \ + systemctl list-units --type=service --all --no-legend --no-pager --plain 2>/dev/null; }} \ + | while read -r name rest; do case $name in (*@.service) ;; (*.service) printf \"%s\\n\" \"$name\";; esac; done); \ + echo @@units; systemctl show {properties} -- $names 2>/dev/null; \ + else echo @@unsupported; uname -s; fi'", + properties = properties(&LIST_PROPERTIES), + ) +} + +/// Whether `name` is a service's name and nothing else, so that it can go +/// into a command: systemd allows letters, digits and `:_.@-`, and `\` for +/// its escapes (「\x2d」). +pub fn valid_name(name: &str) -> bool { + name.ends_with(".service") + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || ":_.@-\\".contains(character)) + && !name.contains("\\\\") +} + +/// `script` as root: as it is when the login is root, else through `sudo` +/// without a password, which fails at once where one is needed. +fn as_root(script: &str) -> String { + format!("if test \"$(id -u)\" = 0; then {script}; else sudo -n {script}; fi") +} + +/// What runs `command` on the service `name`, saying how it went in an +/// `@@status` line with `systemctl`'s complaint before it. `None` for a +/// name that is not one. +pub fn control_command(name: &str, command: ServiceCommand) -> Option { + valid_name(name).then(|| { + let script = format!("systemctl {} -- \"{name}\"", command.verb()); + format!("sh -c '{} 2>&1; echo @@status $?'", as_root(&script)) + }) +} + +/// How a command went, from what `control_command` printed: why not, when +/// it did not. +pub fn controlled(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("password is required") + || said.contains("Interactive authentication required") + || said.contains("Access denied") + || said.contains("not in the sudoers") + { + "需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo".into() + } else if said.contains("command not found") && said.contains("sudo") { + "需要 root 权限:这台主机没有 sudo,请以 root 登录".into() + } else if said.is_empty() { + format!("systemctl 返回 {}", status.trim()) + } else { + said.lines().next().unwrap_or(said).trim().to_owned() + }, + ) +} + +/// What reads a service's state in full, for its details. +pub fn status_command(name: &str) -> Option { + valid_name(name).then(|| { + format!( + "sh -c 'export LC_ALL=C; systemctl show {} -- \"{name}\"'", + properties(&STATUS_PROPERTIES) + ) + }) +} + +/// The service's last 200 lines of journal, the newest last. The journal +/// is root's to read, besides some groups', so a login that is not root +/// asks `sudo` first and settles for what it may see. +pub fn log_command(name: &str) -> Option { + valid_name(name).then(|| { + let journal = format!("journalctl -u \"{name}\" -n 200 --no-pager"); + format!( + "sh -c 'export LC_ALL=C; if test \"$(id -u)\" = 0; then {journal}; \ + else sudo -n {journal} 2>/dev/null || {journal}; fi 2>&1'" + ) + }) +} + +/// What the list's command printed. +#[derive(Debug, PartialEq)] +pub enum Parsed { + Table(ServiceTable), + /// Not a host whose services systemd runs. Holds `uname -s` when the + /// host has one: 「Linux」 without systemd, 「Darwin」. + Unsupported(Option), +} + +pub fn parse(output: &str) -> Parsed { + let mut sections: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut current = None; + for line in output.lines() { + if let Some(name) = line.strip_prefix("@@") { + current = Some(name.trim()); + sections.entry(name.trim()).or_default(); + } else if let Some(name) = current { + sections.entry(name).or_default().push(line); + } + } + let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default(); + let first = |name: &str| { + section(name) + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + }; + if !sections.contains_key("units") { + // A shell other than sh's (cmd.exe) prints nothing we know. + return Parsed::Unsupported(first("unsupported").map(str::to_owned)); + } + // 「systemd 249 (249.11-0ubuntu3.12)」. + let version = first("version").map(|line| { + line.split_whitespace() + .take(2) + .collect::>() + .join(" ") + }); + let services = blocks(section("units")) + .into_iter() + .filter_map(|properties| { + let property = |name: &str| properties.get(name).copied().unwrap_or_default(); + let name = property("Id"); + // A unit no file is found for is only worth a line when it + // failed. + let missing = property("LoadState") == "not-found"; + (name.ends_with(".service") && (!missing || property("ActiveState") == "failed")).then( + || { + Service::new( + name, + property("Description"), + property("ActiveState"), + property("UnitFileState"), + property("FragmentPath"), + ) + }, + ) + }) + .collect(); + Parsed::Table(ServiceTable::new( + services, + version, + first("state").map(str::to_owned), + )) +} + +/// `systemctl show`'s `Key=value` lines, a blank line between units. +fn blocks<'a>(lines: &[&'a str]) -> Vec> { + let mut blocks = vec![HashMap::new()]; + for line in lines { + match line.split_once('=') { + Some((key, value)) => { + blocks + .last_mut() + .expect("one at least") + .insert(key.trim(), value.trim()); + } + None if line.trim().is_empty() => blocks.push(HashMap::new()), + None => {} + } + } + blocks.retain(|block| !block.is_empty()); + blocks +} + +/// A service's details from what `status_command` printed. +pub fn parse_status(output: &str) -> Option { + let lines: Vec<&str> = output.lines().collect(); + let properties = blocks(&lines).into_iter().next()?; + let property = |name: &str| { + properties + .get(name) + .copied() + .filter(|value| !value.is_empty() && *value != "[not set]") + }; + // systemd writes an unset number as the largest one it has. + let number = |name: &str| { + property(name) + .and_then(|value| value.parse::().ok()) + .filter(|value| *value != u64::MAX) + }; + Some(ServiceStatus { + description: property("Description").unwrap_or_default().to_owned(), + load: property("LoadState").unwrap_or_default().to_owned(), + active: property("ActiveState").unwrap_or_default().to_owned(), + sub: property("SubState").unwrap_or_default().to_owned(), + file_state: property("UnitFileState").unwrap_or_default().to_owned(), + main_pid: number("MainPID") + .filter(|pid| *pid != 0) + .map(|pid| pid as u32), + memory: number("MemoryCurrent"), + tasks: number("TasksCurrent"), + restarts: number("NRestarts"), + exit_status: property("ExecMainStatus").and_then(|value| value.parse().ok()), + started: property("ActiveEnterTimestamp").map(str::to_owned), + stopped: property("InactiveEnterTimestamp").map(str::to_owned), + path: property("FragmentPath").map(str::to_owned), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::model::{ActiveState, ServiceFilter}; + + const LIST: &str = "\ +@@version +systemd 249 (249.11-0ubuntu3.12) ++PAM +AUDIT +SELINUX +@@state +degraded +@@units +Id=socialc.service +Description=Social Callback Proxy Service +ActiveState=active +UnitFileState=enabled +FragmentPath=/etc/systemd/system/socialc.service +LoadState=loaded + +Id=apport.service +Description=LSB: automatic crash report generation +ActiveState=active +UnitFileState=generated +FragmentPath=/run/systemd/generator.late/apport.service +LoadState=loaded + +Id=auditd.service +Description=auditd.service +ActiveState=inactive +UnitFileState= +FragmentPath= +LoadState=not-found + +Id=certbot.service +Description=Certbot +ActiveState=failed +UnitFileState=static +FragmentPath=/lib/systemd/system/certbot.service +LoadState=loaded +"; + + #[test] + fn systemctl_show_gives_the_services_but_those_without_a_file() { + let Parsed::Table(table) = parse(LIST) else { + panic!("not a table"); + }; + assert_eq!(table.summary(), "systemd 249 · 降级运行"); + let services: Vec<(&str, ActiveState, &str, bool)> = table + .services() + .iter() + .map(|service| { + ( + service.name.as_str(), + service.active, + service.file_state.as_str(), + service.custom, + ) + }) + .collect(); + assert_eq!( + services, + [ + ("apport.service", ActiveState::Active, "generated", false), + ("certbot.service", ActiveState::Failed, "static", false), + ("socialc.service", ActiveState::Active, "enabled", true), + ] + ); + assert_eq!(table.counts(""), [3, 2, 0, 1]); + assert_eq!(table.rows(ServiceFilter::All, "").len(), 5); + } + + #[test] + fn a_host_without_systemd_says_what_it_is() { + assert_eq!( + parse("@@unsupported\nLinux\n"), + Parsed::Unsupported(Some("Linux".into())) + ); + assert_eq!(parse(""), Parsed::Unsupported(None)); + } + + #[test] + fn details_leave_out_what_systemd_has_not_set() { + let status = parse_status( + "Id=socialc.service\nDescription=Social Callback Proxy Service\nLoadState=loaded\n\ + ActiveState=active\nSubState=running\nUnitFileState=enabled\n\ + FragmentPath=/etc/systemd/system/socialc.service\nMainPID=781\n\ + MemoryCurrent=1990656\nTasksCurrent=5\nNRestarts=0\nExecMainStatus=0\n\ + ActiveEnterTimestamp=Tue 2026-09-15 17:49:07 CST\nInactiveEnterTimestamp=\n", + ) + .expect("details"); + assert_eq!(status.main_pid, Some(781)); + assert_eq!(status.memory, Some(1_990_656)); + assert_eq!(status.tasks, Some(5)); + assert_eq!( + status.started.as_deref(), + Some("Tue 2026-09-15 17:49:07 CST") + ); + assert_eq!(status.stopped, None); + + let stopped = parse_status( + "Id=a.service\nMainPID=0\nMemoryCurrent=[not set]\nTasksCurrent=18446744073709551615\n", + ) + .expect("details"); + assert_eq!( + (stopped.main_pid, stopped.memory, stopped.tasks), + (None, None, None) + ); + } + + #[test] + fn only_a_services_name_goes_into_a_command() { + assert!(valid_name("nginx.service")); + assert!(valid_name("getty@tty1.service")); + assert!(valid_name("systemd-fsck@dev-disk-by\\x2duuid-1234.service")); + assert!(!valid_name("nginx")); + assert!(!valid_name("a;reboot.service")); + assert!(!valid_name("a b.service")); + assert!(!valid_name("$(reboot).service")); + assert_eq!(control_command("a\"b.service", ServiceCommand::Stop), None); + assert_eq!( + control_command("nginx.service", ServiceCommand::Restart).as_deref(), + Some( + "sh -c 'if test \"$(id -u)\" = 0; then systemctl restart -- \"nginx.service\"; \ + else sudo -n systemctl restart -- \"nginx.service\"; fi 2>&1; echo @@status $?'" + ) + ); + } + + #[test] + fn a_command_says_why_it_did_not_run() { + assert_eq!(controlled("@@status 0\n"), Ok(())); + assert_eq!( + controlled("sudo: a password is required\n@@status 1\n"), + Err("需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo".into()) + ); + assert_eq!( + controlled( + "Job for nginx.service failed because the control process exited with error code.\n\ + See \"systemctl status nginx.service\" and \"journalctl -xeu nginx.service\" for details.\n\ + @@status 1\n" + ), + Err( + "Job for nginx.service failed because the control process exited with error code." + .into() + ) + ); + assert_eq!(controlled(""), Err("主机没有回答".into())); + } + + fn scripts() -> Vec { + vec![ + command(), + control_command("getty@tty1.service", ServiceCommand::Stop).unwrap(), + status_command("nginx.service").unwrap(), + log_command("nginx.service").unwrap(), + ] + } + + #[test] + fn the_commands_survive_any_login_shell() { + for command in scripts() { + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .expect("one sh -c in single quotes"); + assert!(!script.contains('\''), "{script}"); + assert!(!script.contains('!'), "{script}"); + assert!(!script.contains('\n'), "{script}"); + assert!(!script.contains("\\\\"), "{script}"); + } + } + + #[cfg(unix)] + #[test] + fn the_commands_are_valid_sh() { + for command in scripts() { + 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(), "{script}"); + } + } + + /// The list's script, run by this machine's `sh` with a `systemctl` + /// stood in for, asks `systemctl show` for the right services. + #[cfg(unix)] + #[test] + fn the_script_shows_every_service_but_templates() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("temp dir"); + let bin = root.path().join("bin"); + let booted = root.path().join("systemd"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::create_dir_all(&booted).unwrap(); + let systemctl = bin.join("systemctl"); + std::fs::write( + &systemctl, + "#!/bin/sh\n\ + case \"$1\" in\n\ + --version) echo 'systemd 249 (249.11)';;\n\ + is-system-running) echo running;;\n\ + list-unit-files) printf '%s\\n' 'ssh.service enabled enabled' 'getty@.service enabled enabled' 'sshd.service alias -';;\n\ + list-units) printf '%s\\n' 'getty@tty1.service loaded active running Getty on tty1' 'ssh.service loaded active running OpenBSD Secure Shell server';;\n\ + show) shift; while test \"$1\" = -p; do shift 2; done; shift; for unit in \"$@\"; do \ + id=$unit; test $unit = sshd.service && id=ssh.service; \ + printf 'Id=%s\\nDescription=%s\\nActiveState=active\\nUnitFileState=enabled\\nFragmentPath=/lib/systemd/system/%s\\nLoadState=loaded\\n\\n' $id $id $id; done;;\n\ + esac\n", + ) + .unwrap(); + std::fs::set_permissions(&systemctl, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let command = command().replace("/run/systemd/system", &booted.display().to_string()); + let script = command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap(); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let output = std::process::Command::new("sh") + .args(["-c", script]) + .env("PATH", path) + .output() + .expect("sh runs"); + let Parsed::Table(table) = parse(&String::from_utf8(output.stdout).unwrap()) else { + panic!("not a table"); + }; + let names: Vec<&str> = table + .services() + .iter() + .map(|service| service.name.as_str()) + .collect(); + // The template is left out, and the alias is ssh itself. + assert_eq!(names, ["getty@tty1.service", "ssh.service"]); + assert_eq!(table.summary(), "systemd 249 · 运行正常"); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..d73527d --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,14 @@ +//! 系统服务: the right sidebar's tool that lists the systemd services of the +//! host of the SSH terminal in front, starts, stops and restarts them, and +//! shows their state and journal, over that terminal's own connection. +//! Linux hosts run by systemd only, for now. + +mod linux; +mod model; +mod service_details; +mod service_panel; + +pub use linux::{control_command, controlled}; +pub use model::{Service, ServiceCommand}; +pub use service_details::open_service_dialog; +pub use service_panel::ServicePanel; diff --git a/src/services/model.rs b/src/services/model.rs new file mode 100644 index 0000000..8488a6e --- /dev/null +++ b/src/services/model.rs @@ -0,0 +1,533 @@ +//! What 系统服务 reads off a host, and how its tabs, groups and search +//! pick from it. + +/// Where a service's unit file lives, which the list groups by. +const CUSTOM_UNITS: &str = "/etc/systemd/system/"; + +/// What a service is doing, as systemd's `ActiveState` says. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActiveState { + Active, + Reloading, + Inactive, + Failed, + Activating, + Deactivating, + Other, +} + +impl ActiveState { + pub fn parse(state: &str) -> Self { + match state { + "active" => ActiveState::Active, + "reloading" => ActiveState::Reloading, + "inactive" => ActiveState::Inactive, + "failed" => ActiveState::Failed, + "activating" => ActiveState::Activating, + "deactivating" => ActiveState::Deactivating, + _ => ActiveState::Other, + } + } + + pub fn label(self) -> &'static str { + match self { + ActiveState::Active => "运行中", + ActiveState::Reloading => "正在重载", + ActiveState::Inactive | ActiveState::Other => "已停止", + ActiveState::Failed => "失败", + ActiveState::Activating => "正在启动", + ActiveState::Deactivating => "正在停止", + } + } + + /// Which tab it is under. + pub fn kind(self) -> ServiceKind { + match self { + ActiveState::Active + | ActiveState::Reloading + | ActiveState::Activating + | ActiveState::Deactivating => ServiceKind::Running, + ActiveState::Failed => ServiceKind::Failed, + ActiveState::Inactive | ActiveState::Other => ServiceKind::Stopped, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceKind { + Running, + Stopped, + Failed, +} + +/// The finer state under `ActiveState`, as systemd's `SubState` says: 「已退出」 +/// for a one-shot service that did its work. +pub fn sub_state_label(state: &str) -> String { + match state { + "running" => "运行中", + "exited" => "已退出", + "dead" => "未运行", + "failed" => "失败", + "waiting" => "等待中", + "listening" => "监听中", + "reload" => "正在重载", + "auto-restart" => "等待自动重启", + "condition" => "条件不满足", + state if state.starts_with("start") => "正在启动", + state if state.starts_with("stop") || state.starts_with("final") => "正在停止", + state => return state.to_owned(), + } + .to_owned() +} + +/// Whether it starts at boot, as systemd's `UnitFileState` says; `None` +/// with nothing to say. +pub fn file_state_label(state: &str) -> Option<&'static str> { + Some(match state { + "enabled" => "已启用", + "enabled-runtime" => "已启用(本次运行)", + "disabled" => "已禁用", + "static" => "静态", + "masked" | "masked-runtime" => "已屏蔽", + "generated" => "自动生成", + "indirect" => "间接启用", + "linked" | "linked-runtime" => "已链接", + "transient" => "临时", + "alias" => "别名", + "bad" => "出错", + _ => return None, + }) +} + +/// Whether the unit file was found and read, as systemd's `LoadState` says. +pub fn load_state_label(state: &str) -> String { + match state { + "loaded" => "已加载", + "not-found" => "未找到", + "masked" => "已屏蔽", + "error" => "出错", + "bad-setting" => "配置有误", + state => return state.to_owned(), + } + .to_owned() +} + +/// How the machine as a whole is doing, as `systemctl is-system-running` +/// says: 「降级运行」 when some unit failed. +pub fn system_state_label(state: &str) -> String { + match state { + "running" => "运行正常", + "degraded" => "降级运行", + "starting" => "正在启动", + "initializing" => "正在初始化", + "maintenance" => "维护模式", + "stopping" => "正在关机", + "offline" => "离线", + state => return state.to_owned(), + } + .to_owned() +} + +/// What can be done to a service. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ServiceCommand { + Start, + Stop, + Restart, + /// Start it at boot. + Enable, + /// No longer start it at boot. + Disable, +} + +impl ServiceCommand { + /// The `systemctl` verb. + pub fn verb(self) -> &'static str { + match self { + ServiceCommand::Start => "start", + ServiceCommand::Stop => "stop", + ServiceCommand::Restart => "restart", + ServiceCommand::Enable => "enable", + ServiceCommand::Disable => "disable", + } + } + + /// The button: 「停止」「禁用开机启动」. + pub fn label(self) -> &'static str { + match self { + ServiceCommand::Start => "启动", + ServiceCommand::Stop => "停止", + ServiceCommand::Restart => "重启", + ServiceCommand::Enable => "启用开机启动", + ServiceCommand::Disable => "禁用开机启动", + } + } + + /// What it did, after the service's name: 「已停止」. + pub fn done(self) -> &'static str { + match self { + ServiceCommand::Start => "已启动", + ServiceCommand::Stop => "已停止", + ServiceCommand::Restart => "已重启", + ServiceCommand::Enable => "已设为开机启动", + ServiceCommand::Disable => "已取消开机启动", + } + } +} + +/// A service as the list shows it. +#[derive(Clone, Debug, PartialEq)] +pub struct Service { + /// 「nginx.service」. + pub name: String, + pub description: String, + pub active: ActiveState, + /// systemd's `UnitFileState`: 「enabled」「static」, empty without a + /// unit file. + pub file_state: String, + /// Its unit file is the administrator's, in `/etc/systemd/system`. + pub custom: bool, +} + +impl Service { + pub fn new(name: &str, description: &str, active: &str, file_state: &str, path: &str) -> Self { + Self { + name: name.to_owned(), + description: description.to_owned(), + active: ActiveState::parse(active), + file_state: file_state.to_owned(), + custom: path.starts_with(CUSTOM_UNITS), + } + } + + /// The commands its buttons offer, the one that matters first: stop + /// and restart while it runs, start otherwise. + pub fn commands(&self) -> &'static [ServiceCommand] { + match self.active.kind() { + ServiceKind::Running => &[ServiceCommand::Stop, ServiceCommand::Restart], + ServiceKind::Stopped | ServiceKind::Failed => &[ServiceCommand::Start], + } + } + + /// Enable or disable, when its unit file can be either. + pub fn boot_command(&self) -> Option { + match self.file_state.as_str() { + "enabled" | "enabled-runtime" => Some(ServiceCommand::Disable), + "disabled" => Some(ServiceCommand::Enable), + _ => None, + } + } + + fn matches(&self, query: &str) -> bool { + query.is_empty() + || self.name.to_lowercase().contains(query) + || self.description.to_lowercase().contains(query) + } +} + +/// The tabs over the list. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ServiceFilter { + #[default] + All, + Running, + Stopped, + Failed, +} + +impl ServiceFilter { + pub const ALL: [ServiceFilter; 4] = [ + ServiceFilter::All, + ServiceFilter::Running, + ServiceFilter::Stopped, + ServiceFilter::Failed, + ]; + + pub fn label(self) -> &'static str { + match self { + ServiceFilter::All => "全部", + ServiceFilter::Running => "运行中", + ServiceFilter::Stopped => "已停止", + ServiceFilter::Failed => "失败", + } + } + + fn lets_through(self, service: &Service) -> bool { + match self { + ServiceFilter::All => true, + ServiceFilter::Running => service.active.kind() == ServiceKind::Running, + ServiceFilter::Stopped => service.active.kind() == ServiceKind::Stopped, + ServiceFilter::Failed => service.active.kind() == ServiceKind::Failed, + } + } +} + +/// A line of the list: a group's heading, or a service by its place in +/// `ServiceTable::services`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ServiceRow { + Group { custom: bool, count: usize }, + Service(usize), +} + +/// One reading of a host's services, by name. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ServiceTable { + services: Vec, + /// 「systemd 249」. + version: Option, + /// `systemctl is-system-running`: 「degraded」. + state: Option, +} + +impl ServiceTable { + pub fn new(mut services: Vec, version: Option, state: Option) -> Self { + services.sort_by(|a, b| a.name.cmp(&b.name)); + services.dedup_by(|a, b| a.name == b.name); + Self { + services, + version, + state, + } + } + + pub fn services(&self) -> &[Service] { + &self.services + } + + pub fn service(&self, name: &str) -> Option<&Service> { + self.services.iter().find(|service| service.name == name) + } + + /// 「systemd 249 · 降级运行」. + pub fn summary(&self) -> String { + [ + self.version.clone(), + self.state.as_deref().map(system_state_label), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · ") + } + + /// Whether some unit failed, so the machine is not quite well. + pub fn degraded(&self) -> bool { + self.state.as_deref() == Some("degraded") + } + + /// How many services `query` finds under each tab, in the tabs' order. + pub fn counts(&self, query: &str) -> [usize; 4] { + let query = query.trim().to_lowercase(); + ServiceFilter::ALL.map(|filter| { + self.services + .iter() + .filter(|service| filter.lets_through(service) && service.matches(&query)) + .count() + }) + } + + /// The list under `filter` for `query` (in the name or description, + /// ignoring case): the administrator's own services, then the rest, + /// each group under its heading; a group with none is left out. + pub fn rows(&self, filter: ServiceFilter, query: &str) -> Vec { + let query = query.trim().to_lowercase(); + let mut rows = Vec::new(); + for custom in [true, false] { + let services: Vec = self + .services + .iter() + .enumerate() + .filter(|(_, service)| { + service.custom == custom + && filter.lets_through(service) + && service.matches(&query) + }) + .map(|(index, _)| index) + .collect(); + if !services.is_empty() { + rows.push(ServiceRow::Group { + custom, + count: services.len(), + }); + rows.extend(services.into_iter().map(ServiceRow::Service)); + } + } + rows + } +} + +/// A service's state in full, for its details, as `systemctl show` says. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ServiceStatus { + pub description: String, + pub load: String, + pub active: String, + pub sub: String, + pub file_state: String, + pub main_pid: Option, + /// Bytes; `None` without memory accounting. + pub memory: Option, + pub tasks: Option, + pub restarts: Option, + pub exit_status: Option, + /// 「Tue 2026-09-15 17:49:07 CST」, as the host writes it. + pub started: Option, + pub stopped: Option, + pub path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn table() -> ServiceTable { + ServiceTable::new( + vec![ + Service::new( + "socialc.service", + "Social Callback Proxy Service", + "active", + "enabled", + "/etc/systemd/system/socialc.service", + ), + Service::new( + "ssh.service", + "OpenBSD Secure Shell server", + "active", + "enabled", + "/lib/systemd/system/ssh.service", + ), + Service::new( + "apport.service", + "LSB: automatic crash report generation", + "active", + "generated", + "/run/systemd/generator.late/apport.service", + ), + Service::new( + "ModemManager.service", + "Modem Manager", + "inactive", + "disabled", + "/lib/systemd/system/ModemManager.service", + ), + Service::new( + "certbot.service", + "Certbot", + "failed", + "static", + "/etc/systemd/system/certbot.service", + ), + // Read twice under two names, once as itself. + Service::new( + "ssh.service", + "OpenBSD Secure Shell server", + "active", + "enabled", + "/lib/systemd/system/ssh.service", + ), + ], + Some("systemd 249".into()), + Some("degraded".into()), + ) + } + + fn names(table: &ServiceTable, rows: &[ServiceRow]) -> Vec { + rows.iter() + .map(|row| match row { + ServiceRow::Group { + custom: true, + count, + } => format!("自定义服务 {count}"), + ServiceRow::Group { + custom: false, + count, + } => format!("系统服务 {count}"), + ServiceRow::Service(index) => table.services()[*index].name.clone(), + }) + .collect() + } + + #[test] + fn the_administrators_services_come_first_under_each_tab() { + let table = table(); + assert_eq!(table.summary(), "systemd 249 · 降级运行"); + assert!(table.degraded()); + assert_eq!(table.counts(""), [5, 3, 1, 1]); + assert_eq!( + names(&table, &table.rows(ServiceFilter::All, "")), + [ + "自定义服务 2", + "certbot.service", + "socialc.service", + "系统服务 3", + "ModemManager.service", + "apport.service", + "ssh.service", + ] + ); + assert_eq!( + names(&table, &table.rows(ServiceFilter::Running, "")), + [ + "自定义服务 1", + "socialc.service", + "系统服务 2", + "apport.service", + "ssh.service", + ] + ); + assert_eq!( + names(&table, &table.rows(ServiceFilter::Failed, "")), + ["自定义服务 1", "certbot.service"] + ); + } + + #[test] + fn the_search_finds_names_and_descriptions() { + let table = table(); + assert_eq!( + names(&table, &table.rows(ServiceFilter::All, " SECURE ")), + ["系统服务 1", "ssh.service"] + ); + assert_eq!( + names(&table, &table.rows(ServiceFilter::All, "modem")), + ["系统服务 1", "ModemManager.service"] + ); + assert_eq!(table.counts("cert"), [1, 0, 0, 1]); + assert!(table.rows(ServiceFilter::Running, "cert").is_empty()); + } + + #[test] + fn a_service_offers_what_its_state_allows() { + let table = table(); + let service = |name: &str| table.service(name).expect(name); + let ssh = service("ssh.service"); + assert_eq!( + ssh.commands(), + [ServiceCommand::Stop, ServiceCommand::Restart] + ); + assert_eq!(ssh.boot_command(), Some(ServiceCommand::Disable)); + let modem = service("ModemManager.service"); + assert_eq!(modem.commands(), [ServiceCommand::Start]); + assert_eq!(modem.boot_command(), Some(ServiceCommand::Enable)); + // Neither a static unit nor a generated one can be enabled. + assert_eq!(service("certbot.service").boot_command(), None); + assert_eq!(service("apport.service").boot_command(), None); + assert_eq!( + service("certbot.service").commands(), + [ServiceCommand::Start] + ); + } + + #[test] + fn states_read_in_chinese() { + assert_eq!(sub_state_label("exited"), "已退出"); + assert_eq!(sub_state_label("stop-sigterm"), "正在停止"); + assert_eq!(sub_state_label("mounted"), "mounted"); + assert_eq!(file_state_label("generated"), Some("自动生成")); + assert_eq!(file_state_label(""), None); + assert_eq!(load_state_label("loaded"), "已加载"); + assert_eq!(system_state_label("running"), "运行正常"); + } +} diff --git a/src/services/service_details.rs b/src/services/service_details.rs new file mode 100644 index 0000000..1d04236 --- /dev/null +++ b/src/services/service_details.rs @@ -0,0 +1,380 @@ +//! A service's details: the dialog a click on it in 系统服务 opens. + +use gpui_kit::component::{ + ActiveTheme as _, Icon, Sizable as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + dialog::DialogFooter, + h_flex, + spinner::Spinner, + tab::{Tab, TabBar}, + v_flex, +}; +use gpui_kit::*; + +use super::linux::{log_command, parse_status, status_command}; +use super::model::{ + ActiveState, Service, ServiceCommand, ServiceKind, ServiceStatus, file_state_label, + load_state_label, sub_state_label, +}; +use super::service_panel::{command_icon, soft_tag, state_color}; +use crate::app::{CatalogIcon, ControlService}; +use crate::shared::format_bytes; +use crate::terminal::{ExecResult, TerminalView, exec_answer}; + +/// How wide the dialog is: two columns of a label and a value, and the +/// journal's lines. +const DIALOG_WIDTH: f32 = 44.; + +/// Open the details of a service of the host of `terminal`, whose +/// connection reads its state and journal. `dispatch` is the workspace's +/// focus handle: the dialog's buttons close it and dispatch there. +pub fn open_service_dialog( + service: Service, + terminal: WeakEntity, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut App, +) { + let view = cx.new(|cx| ServiceDetailsView::new(service, terminal, cx)); + window.open_dialog(cx, move |dialog, window, cx| { + let this = view.read(cx); + let service = this.current(); + let color = state_color(service.active, cx); + let title = v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .child(div().truncate().child(service.name.clone())) + .child(soft_tag(service.active.label(), color)), + ) + .child( + div() + .text_sm() + .font_weight(FontWeight::NORMAL) + .text_color(cx.theme().muted_foreground) + .child(service.description.clone()), + ); + let button = |command: ServiceCommand| { + let (dispatch, name) = (dispatch.clone(), service.name.clone()); + let button = Button::new(SharedString::from(format!( + "service-dialog-{}", + command.verb() + ))) + .icon(command_icon(command)) + .label(command.label()) + .on_click(move |_, window, cx| { + // One dialog at a time: a confirmation takes its place. + window.close_dialog(cx); + dispatch.dispatch_action( + &ControlService { + name: name.clone(), + command, + }, + window, + cx, + ); + }); + match command { + ServiceCommand::Stop => button.danger(), + ServiceCommand::Start => button.primary(), + _ => button.outline(), + } + }; + let footer = DialogFooter::new() + .justify_start() + .children(service.commands().iter().map(|&command| button(command))) + .children(service.boot_command().map(button)); + 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) + }); +} + +/// Something read off the host for the dialog. +enum Read { + /// Not asked for yet: the journal until its tab is opened. + NotYet, + Reading, + Known(T), + /// Why not: 「读取失败:命令超时」. + Unknown(String), +} + +/// The dialog's body: the service's state in full, and its journal. +pub struct ServiceDetailsView { + service: Service, + terminal: WeakEntity, + status: Read, + journal: Read, + /// 0 the state, 1 the journal. + tab: usize, + journal_scroll: ScrollHandle, + _status_task: Task<()>, + _journal_task: Option>, +} + +impl ServiceDetailsView { + fn new(service: Service, terminal: WeakEntity, cx: &mut Context) -> Self { + let status = run( + &terminal, + status_command(&service.name), + cx, + |this, answer, _| { + this.status = match answer { + Ok(output) => match parse_status(&output) { + Some(status) => Read::Known(status), + None => Read::Unknown("读不到这个服务的状态".into()), + }, + Err(why) => Read::Unknown(why), + }; + }, + ); + Self { + service, + terminal, + status: Read::Reading, + journal: Read::NotYet, + tab: 0, + journal_scroll: ScrollHandle::new(), + _status_task: status, + _journal_task: None, + } + } + + /// The service as its state now says, which may have moved on since + /// the list read it. + fn current(&self) -> Service { + let mut service = self.service.clone(); + if let Read::Known(status) = &self.status { + service.active = ActiveState::parse(&status.active); + service.file_state = status.file_state.clone(); + if !status.description.is_empty() { + service.description = status.description.clone(); + } + } + service + } + + fn select(&mut self, tab: usize, cx: &mut Context) { + self.tab = tab; + if tab == 1 && matches!(self.journal, Read::NotYet) { + self.journal = Read::Reading; + self._journal_task = Some(run( + &self.terminal, + log_command(&self.service.name), + cx, + |this, answer, _| { + this.journal = match answer { + Ok(output) if output.trim().is_empty() => Read::Unknown("没有日志".into()), + Ok(output) => Read::Known(output.trim_end().to_owned()), + Err(why) => Read::Unknown(why), + }; + // The newest lines are the last. + this.journal_scroll.scroll_to_bottom(); + }, + )); + } + cx.notify(); + } + + fn render_status(&self, cx: &App) -> AnyElement { + let status = match &self.status { + Read::Known(status) => status, + Read::Unknown(why) => return message("service-status-unknown", why.clone(), cx), + Read::Reading | Read::NotYet => return reading("service-status-reading", cx), + }; + let active = ActiveState::parse(&status.active); + let number = |value: Option| value.map_or("—".to_owned(), |value| value.to_string()); + let fields: [(&str, String); 10] = [ + ("加载状态", load_state_label(&status.load)), + ( + "运行状态", + format!("{} / {}", active.label(), sub_state_label(&status.sub)), + ), + ( + "开机启动", + file_state_label(&status.file_state) + .unwrap_or("—") + .to_owned(), + ), + ( + "主进程 PID", + status.main_pid.map_or("—".into(), |pid| pid.to_string()), + ), + ("内存", status.memory.map_or("—".into(), format_bytes)), + ("任务数", number(status.tasks)), + ("重启次数", number(status.restarts)), + ( + "退出状态", + status + .exit_status + .map_or("—".into(), |status| status.to_string()), + ), + ("启动时间", status.started.clone().unwrap_or("—".into())), + ( + "停止时间", + // When it last went down; while it runs, that is history. + status + .stopped + .clone() + .filter(|_| active.kind() != ServiceKind::Running) + .unwrap_or("—".into()), + ), + ]; + v_flex() + .child( + div().grid().grid_cols(2).gap_x_8().children( + fields + .into_iter() + .map(|(label, value)| field(label, value, cx)), + ), + ) + .child(field( + "Unit 文件", + status.path.clone().unwrap_or("—".into()), + cx, + )) + .into_any_element() + } + + fn render_journal(&self, cx: &App) -> AnyElement { + match &self.journal { + Read::Known(journal) => div() + .id("service-journal") + .test_support() + .aria_label(journal.clone()) + .h(rems(20.)) + .overflow_y_scroll() + .track_scroll(&self.journal_scroll) + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().muted) + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .child(journal.clone()) + .into_any_element(), + Read::Unknown(why) => message("service-journal-unknown", why.clone(), cx), + Read::Reading | Read::NotYet => reading("service-journal-reading", cx), + } + } +} + +/// Run `command` on `terminal`'s connection, and hand the answer to +/// `record`: what it printed, or why it did not run. +fn run( + terminal: &WeakEntity, + command: Option, + cx: &mut Context, + record: impl FnOnce( + &mut ServiceDetailsView, + Result, + &mut Context, + ) + 'static, +) -> Task<()> { + let reply = command.and_then(|command| terminal.upgrade()?.read(cx).exec(command, cx)); + cx.spawn(async move |this, cx| { + let answer: ExecResult = match reply { + None => Err("终端没有连接".into()), + Some(reply) => match exec_answer(reply, cx).await { + None => Err("终端没有连接".into()), + Some(Err(error)) => Err(format!("读取失败:{error}")), + Some(Ok(output)) => Ok(output), + }, + }; + this.update(cx, |this, cx| { + record(this, answer, cx); + cx.notify(); + }) + .ok(); + }) +} + +/// A label and its value, over a line. +fn field(label: &'static str, value: String, cx: &App) -> impl IntoElement { + h_flex() + .id(SharedString::from(format!("service-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)) +} + +fn reading(id: &'static str, cx: &App) -> AnyElement { + h_flex() + .id(id) + .test_support() + .gap_2() + .py_8() + .justify_center() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child("正在读取…") + .into_any_element() +} + +fn message(id: &'static str, text: String, cx: &App) -> AnyElement { + div() + .id(id) + .test_support() + .aria_label(text.clone()) + .py_8() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element() +} + +impl Render for ServiceDetailsView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("service-details") + .test_support() + .gap_3() + .child( + div().id("service-tabs").test_support().child( + TabBar::new("service-tab-bar") + .underline() + .small() + .selected_index(self.tab) + .on_click(cx.listener(|this, index: &usize, _, cx| this.select(*index, cx))) + .child( + Tab::new() + .icon(Icon::new(CatalogIcon::CircleDot)) + .label("状态"), + ) + .child( + Tab::new() + .icon(Icon::new(CatalogIcon::FileText)) + .label("日志"), + ), + ), + ) + .child(if self.tab == 0 { + self.render_status(cx) + } else { + self.render_journal(cx) + }) + } +} diff --git a/src/services/service_panel.rs b/src/services/service_panel.rs new file mode 100644 index 0000000..38466c5 --- /dev/null +++ b/src/services/service_panel.rs @@ -0,0 +1,727 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; +use std::time::Duration; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, + button::{Button, ButtonCustomVariant, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState}, + menu::{ContextMenuExt as _, PopupMenu}, + scroll::ScrollableElement as _, + spinner::Spinner, + tab::{Tab, TabBar}, + tag::Tag, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::linux::{self, Parsed}; +use super::model::{ + ActiveState, Service, ServiceCommand, ServiceFilter, ServiceRow, ServiceTable, file_state_label, +}; +use crate::app::{CatalogIcon, ControlService, RefreshServices, ShowService}; +use crate::terminal::{ExecResult, ExecTarget, RemoteTerminalId, exec_answer}; + +/// How long a terminal not connected is left before it is asked again. +const RETRY: Duration = Duration::from_secs(2); + +/// 系统服务: the systemd services of the host of the SSH terminal in front, +/// the administrator's own first, read when the panel comes on screen, on +/// 刷新 and after a command; each can be started, stopped, restarted and +/// opened for its details and journal. +/// +/// Each reading runs `linux::command` beside the shell, on the terminal's +/// own connection. Services change seldom and a reading starts a handful +/// of `systemctl`s, so the list is not read on a timer. What each +/// terminal's host showed last is kept, so coming back to a terminal shows +/// its list at once while it is read again. The tab and the search stay as +/// they are from one terminal to the next. +pub struct ServicePanel { + target: Option, + active: bool, + hosts: HashMap, + /// The reading on its way; dropping it stops it. + reading: Option>, + /// Whether a reading is on its way. + loading: bool, + search: Entity, + query: String, + filter: ServiceFilter, + /// The list's lines differ in height (headings and cards), so it is a + /// list that measures them rather than a uniform one. + list: ListState, + /// The lines the list was last told about. + rows: Vec, + /// The tab or the search changed: the list goes back to its top. + back_to_top: bool, + /// The service a right click landed on, for the list's menu; `None` + /// off every service. + 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 { + table: Option>, + problem: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum Problem { + /// The terminal is not connected, so there is no connection to read on. + NotConnected, + /// Not a host systemd runs; `uname -s` when the host said it. + Unsupported(Option), + /// The command did not run, or did not finish. + Failed(String), +} + +impl ServicePanel { + pub fn new(dispatch: FocusHandle, window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("搜索服务名称或描述") + .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.back_to_top = true; + cx.notify(); + } + }), + ]; + Self { + target: None, + active: false, + hosts: HashMap::new(), + reading: None, + loading: false, + search, + query: String::new(), + filter: ServiceFilter::default(), + list: ListState::new(0, ListAlignment::Top, px(400.)), + rows: Vec::new(), + back_to_top: false, + menu_hit: Rc::new(RefCell::new(None)), + dispatch, + focus_handle: cx.focus_handle(), + _subscriptions: subscriptions, + } + } + + /// Read `target`'s host when `active`, the panel showing it, and was + /// not before. + pub fn set_target(&mut self, target: Option, active: bool, cx: &mut Context) { + if self.target == target && self.active == active { + return; + } + if self.target != target { + self.back_to_top = true; + } + self.target = target; + self.active = active; + self.read(cx); + cx.notify(); + } + + /// Read the host again: 刷新, and after a command. + pub fn refresh(&mut self, cx: &mut Context) { + self.read(cx); + cx.notify(); + } + + /// A service of the host on screen, as the list last showed it. + pub fn service(&self, name: &str) -> Option { + let target = self.target.as_ref()?; + self.hosts + .get(&target.terminal)? + .table + .as_ref()? + .service(name) + .cloned() + } + + fn show(&mut self, filter: ServiceFilter, cx: &mut Context) { + if self.filter != filter { + self.filter = filter; + self.back_to_top = true; + cx.notify(); + } + } + + /// Start reading the target's host over, or stop when there is nothing + /// to show. A terminal not connected is asked again every little while, + /// so its list comes once it connects. + fn read(&mut self, cx: &mut Context) { + self.reading = None; + self.loading = false; + let Some(target) = self.target.clone().filter(|_| self.active) else { + return; + }; + self.loading = true; + self.reading = Some(cx.spawn(async move |this, cx| { + loop { + let reply = this.update(cx, |_, cx| { + let view = target.view.upgrade()?; + view.read(cx).exec(linux::command(), cx) + }); + let Ok(reply) = reply else { return }; + let answer = match reply { + Some(reply) => exec_answer(reply, cx).await, + None => None, + }; + let connected = answer.is_some(); + if this + .update(cx, |this, cx| this.record(target.terminal, answer, cx)) + .is_err() + || connected + { + return; + } + cx.background_executor().timer(RETRY).await; + } + })); + } + + fn record( + &mut self, + terminal: RemoteTerminalId, + answer: Option, + cx: &mut Context, + ) { + let host = self.hosts.entry(terminal).or_default(); + host.problem = match answer { + None => Some(Problem::NotConnected), + Some(Err(error)) => Some(Problem::Failed(error)), + Some(Ok(output)) => match linux::parse(&output) { + Parsed::Table(table) => { + host.table = Some(Rc::new(table)); + None + } + Parsed::Unsupported(system) => Some(Problem::Unsupported(system)), + }, + }; + self.loading = false; + cx.notify(); + } + + /// Tell the list about `rows` when they are not the ones it has; back + /// at the top when the tab or the search changed. + fn sync_list(&mut self, rows: &[ServiceRow]) { + if std::mem::take(&mut self.back_to_top) { + self.list.reset(rows.len()); + } else if self.rows != rows { + self.list.splice(0..self.rows.len(), rows.len()); + } + self.rows = rows.to_vec(); + } + + fn render_list( + &mut self, + table: Rc, + problem: Option, + cx: &mut Context, + ) -> impl IntoElement { + let rows = table.rows(self.filter, &self.query); + self.sync_list(&rows); + let counts = table.counts(&self.query); + let summary = table.summary(); + let refresh = self.dispatch.clone(); + let header = + v_flex() + .flex_shrink_0() + .px_3() + .pt_3() + .gap_2() + .child( + h_flex() + .gap_2() + .child( + div() + .id("services-summary") + .test_support() + .aria_label(summary.clone()) + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(if table.degraded() { + cx.theme().warning + } else { + cx.theme().muted_foreground + }) + .child(summary), + ) + .child( + Button::new("services-refresh") + .ghost() + .xsmall() + .icon(Icon::new(CatalogIcon::RefreshCw)) + .loading(self.loading) + .tooltip("刷新") + .accessibility_label("刷新") + .on_click(move |_, window, cx| { + refresh.dispatch_action(&RefreshServices, window, cx) + }), + ), + ) + .when_some(problem, |header, problem| { + header.child(render_problem(&problem, cx)) + }) + .child( + Input::new(&self.search) + .id("services-search") + .small() + .cleanable(true) + .prefix(Icon::new(IconName::Search).small()), + ) + .child( + div().id("services-tabs").test_support().child( + TabBar::new("services-tab-bar") + .underline() + .small() + .selected_index( + ServiceFilter::ALL + .iter() + .position(|filter| *filter == self.filter) + .unwrap_or(0), + ) + .on_click(cx.listener(|this, index: &usize, _, cx| { + this.show(ServiceFilter::ALL[*index], cx) + })) + .children(ServiceFilter::ALL.iter().zip(counts).map( + |(filter, count)| { + Tab::new() + .label(filter.label()) + .aria_label(format!("{} {count}", filter.label())) + .suffix( + div() + .ml_1() + .px_1p5() + .rounded_full() + .bg(cx.theme().muted) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(count.to_string()), + ) + }, + )), + ), + ); + + let clear_hit = self.menu_hit.clone(); + let menu_hit = self.menu_hit.clone(); + let empty = if self.query.trim().is_empty() { + "没有服务" + } else { + "没有符合条件的服务" + }; + // The scrollbar goes on the list's box, which does not scroll: on + // the scrolled lines it would scroll away with them. The menu hangs + // off the box too, not off the cards, which are drawn after layout + // (see the host tree). + let list = div() + .id("services-list") + .test_support() + .relative() + .flex_1() + .min_h_0() + .map(|list| { + if rows.is_empty() { + list.child( + div() + .id("services-empty") + .test_support() + .aria_label(empty) + .py_8() + .text_sm() + .text_center() + .text_color(cx.theme().muted_foreground) + .child(empty), + ) + } else { + let rows = Rc::new(rows); + let (hit, focus, dispatch) = ( + self.menu_hit.clone(), + self.focus_handle.clone(), + self.dispatch.clone(), + ); + list.child( + gpui_kit::list(self.list.clone(), move |index, _, cx| { + match rows[index] { + ServiceRow::Group { custom, count } => { + render_group(custom, count, cx).into_any_element() + } + ServiceRow::Service(service) => div() + .px_3() + .pt_2() + // The last one clears the bottom. + .when(index + 1 == rows.len(), |row| row.pb_3()) + .child(render_service( + &table.services()[service], + hit.clone(), + focus.clone(), + dispatch.clone(), + cx, + )) + .into_any_element(), + } + }) + .size_full(), + ) + } + }) + .vertical_scrollbar(&self.list) + .capture_any_mouse_down(move |event, _, _| { + if matches!(event.button, MouseButton::Left | MouseButton::Right) { + clear_hit.replace(None); + } + }) + .context_menu(move |menu, _, _| build_context_menu(menu_hit.borrow().as_ref(), menu)); + v_flex().size_full().child(header).child(list) + } +} + +impl Focusable for ServicePanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ServicePanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let host = self + .target + .as_ref() + .and_then(|target| self.hosts.get(&target.terminal)); + let table = host.and_then(|host| host.table.clone()); + let problem = host.and_then(|host| host.problem.clone()); + let body = match (table, problem) { + // A list from before a failed reading still says something; + // the failure goes above it. + (Some(table), problem @ (None | Some(Problem::Failed(_)))) => { + self.render_list(table, problem, cx).into_any_element() + } + (_, Some(problem)) => div() + .p_3() + .child(render_problem(&problem, cx)) + .into_any_element(), + (None, None) => h_flex() + .id("services-loading") + .test_support() + .gap_2() + .justify_center() + .py_8() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child("正在读取…") + .into_any_element(), + }; + v_flex() + .id("services") + .test_support() + .track_focus(&self.focus_handle) + .size_full() + .child(body) + } +} + +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)) if system == "Linux" => ( + "这台主机没有使用 systemd,暂不支持管理它的服务。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Unsupported(Some(system)) => ( + format!("暂不支持管理 {system} 的服务,目前只支持使用 systemd 的 Linux 主机。"), + cx.theme().muted_foreground, + ), + Problem::Unsupported(None) => ( + "暂不支持管理这台主机的服务,目前只支持使用 systemd 的 Linux 主机。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger), + }; + div() + .id("services-message") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(color) + .child(text) +} + +fn build_context_menu(hit: Option<&Service>, menu: PopupMenu) -> PopupMenu { + let Some(service) = hit else { + return menu; + }; + let action = |command: ServiceCommand| -> Box { + Box::new(ControlService { + name: service.name.clone(), + command, + }) + }; + let menu = menu + .menu_with_icon( + "查看详情", + Icon::new(IconName::Info), + Box::new(ShowService(service.name.clone())), + ) + .separator(); + let menu = service.commands().iter().fold(menu, |menu, &command| { + menu.menu_with_icon(command.label(), command_icon(command), action(command)) + }); + match service.boot_command() { + Some(command) => { + menu.separator() + .menu_with_icon(command.label(), command_icon(command), action(command)) + } + None => menu, + } +} + +pub(super) fn command_icon(command: ServiceCommand) -> Icon { + Icon::new(match command { + ServiceCommand::Start => CatalogIcon::Play, + ServiceCommand::Stop => CatalogIcon::Square, + ServiceCommand::Restart => CatalogIcon::RotateCw, + ServiceCommand::Enable => CatalogIcon::CircleCheck, + ServiceCommand::Disable => CatalogIcon::Ban, + }) +} + +/// What a state looks like: green running, red failed, yellow on its way +/// up or down, the rest quiet. +pub(super) fn state_color(state: ActiveState, cx: &App) -> Hsla { + match state { + ActiveState::Active | ActiveState::Reloading => cx.theme().success, + ActiveState::Failed => cx.theme().danger, + ActiveState::Activating | ActiveState::Deactivating => cx.theme().warning, + ActiveState::Inactive | ActiveState::Other => cx.theme().muted_foreground, + } +} + +/// A tag in `color`'s tint: 「运行中」. +pub(super) fn soft_tag(text: &'static str, color: Hsla) -> Tag { + Tag::custom(color.opacity(0.12), color, color.opacity(0.3)) + .small() + .rounded_full() + .child(text) +} + +/// 「自定义服务 3」. +fn render_group(custom: bool, count: usize, cx: &App) -> impl IntoElement { + let label = if custom { + "自定义服务" + } else { + "系统服务" + }; + h_flex() + .id(if custom { + "services-group:custom" + } else { + "services-group:system" + }) + .test_support() + .aria_label(format!("{label} {count}")) + .px_4() + .pt_4() + .pb_1() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .child(label), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(count.to_string()), + ) +} + +/// A button on a card: the command in its own tint for starting and +/// stopping, quiet for the rest. +fn command_button( + service: &Service, + command: ServiceCommand, + dispatch: &FocusHandle, + cx: &App, +) -> Button { + let tint = |color: Hsla| { + ButtonCustomVariant::new(cx) + .color(color.opacity(0.1)) + .foreground(color) + .hover(color.opacity(0.2)) + .active(color.opacity(0.25)) + }; + let button = Button::new(SharedString::from(format!( + "service-{}:{}", + command.verb(), + service.name + ))) + .small() + .icon(command_icon(command)) + .tooltip(command.label()) + .accessibility_label(command.label()); + let button = match command { + ServiceCommand::Stop => button.custom(tint(cx.theme().danger)), + ServiceCommand::Start => button.custom(tint(cx.theme().success)), + _ => button.ghost(), + }; + let (dispatch, name) = (dispatch.clone(), service.name.clone()); + button.on_click(move |_, window, cx| { + // Not the card's click too, which opens the details. + cx.stop_propagation(); + dispatch.dispatch_action( + &ControlService { + name: name.clone(), + command, + }, + window, + cx, + ) + }) +} + +/// A service's card. A click opens its details. +fn render_service( + service: &Service, + menu_hit: Rc>>, + focus: FocusHandle, + dispatch: FocusHandle, + cx: &App, +) -> impl IntoElement { + let color = state_color(service.active, cx); + let boot = file_state_label(&service.file_state); + let label = [ + Some(service.name.as_str()), + Some(service.description.as_str()).filter(|text| !text.is_empty()), + Some(service.active.label()), + boot, + ] + .into_iter() + .flatten() + .collect::>() + .join(" · "); + let name = service.name.clone(); + let open = dispatch.clone(); + let hit = service.clone(); + v_flex() + .id(SharedString::from(format!("service:{}", service.name))) + .test_support() + .aria_label(label) + .p_3() + .rounded(cx.theme().radius) + .bg(cx.theme().tokens.group_box) + .text_color(cx.theme().group_box_foreground) + .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| open.dispatch_action(&ShowService(name.clone()), window, cx)) + // A right click puts the menu on this service. The menu's commands + // are dispatched from what is focused, which may be nothing. + .on_mouse_down(MouseButton::Right, move |_, window, cx| { + menu_hit.replace(Some(hit.clone())); + focus.focus(window, cx); + }) + .child( + h_flex() + .gap_3() + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + h_flex() + .gap_2() + .child(div().flex_shrink_0().size_2().rounded_full().bg(color)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(service.name.clone()), + ), + ) + .child( + // Under the name, past the dot. + div() + .pl(rems(1.)) + .truncate() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(if service.description.is_empty() { + "无描述".to_owned() + } else { + service.description.clone() + }), + ) + .child( + h_flex() + .pl(rems(1.)) + .pt_0p5() + .gap_1() + .child(soft_tag(service.active.label(), color)) + .when_some(boot, |tags, boot| { + let tint = if service.file_state.starts_with("enabled") { + cx.theme().success + } else { + cx.theme().muted_foreground + }; + tags.child(soft_tag(boot, tint)) + }), + ), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .children( + service + .commands() + .iter() + .map(|&command| command_button(service, command, &dispatch, cx)), + ) + .child({ + let (dispatch, name) = (dispatch.clone(), service.name.clone()); + Button::new(SharedString::from(format!( + "service-more:{}", + service.name + ))) + .ghost() + .small() + .icon(IconName::Ellipsis) + .tooltip("详情和日志") + .accessibility_label("详情和日志") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + dispatch.dispatch_action(&ShowService(name.clone()), window, cx) + }) + }), + ), + ) +} diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 7237e3d..32307b0 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -12,6 +12,7 @@ use crate::host::{HostId, HostOs, HostStore}; use crate::monitor::{MonitorDetail, MonitorPanel}; use crate::netstat::NetstatPanel; use crate::processes::{Process, ProcessDetails, ProcessPanel, ProcessSort}; +use crate::services::{Service, ServicePanel}; use crate::terminal::{ExecTarget, RemoteTerminalId, TerminalView}; impl ToolKind { @@ -20,6 +21,7 @@ impl ToolKind { ToolKind::Snippets => "命令片段", ToolKind::History => "历史命令", ToolKind::Docker => "Docker", + ToolKind::Services => "系统服务", ToolKind::Processes => "进程管理", ToolKind::Connections => "网络连接", ToolKind::Monitor => "系统监控", @@ -31,6 +33,7 @@ impl ToolKind { ToolKind::Snippets => CatalogIcon::CodeXml, ToolKind::History => CatalogIcon::RotateCcwClock, ToolKind::Docker => CatalogIcon::Container, + ToolKind::Services => CatalogIcon::ServerCog, ToolKind::Processes => CatalogIcon::ListFilter, ToolKind::Connections => CatalogIcon::Network, ToolKind::Monitor => CatalogIcon::Activity, @@ -41,9 +44,10 @@ 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::Processes | ToolKind::Connections | ToolKind::Monitor => { - os.is_none_or(HostOs::is_linux) - } + ToolKind::Services + | ToolKind::Processes + | ToolKind::Connections + | ToolKind::Monitor => os.is_none_or(HostOs::is_linux), ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true, } } @@ -54,6 +58,7 @@ impl ToolKind { ToolKind::Snippets => "tool-snippets", ToolKind::History => "tool-history", ToolKind::Docker => "tool-docker", + ToolKind::Services => "tool-services", ToolKind::Processes => "tool-processes", ToolKind::Connections => "tool-connections", ToolKind::Monitor => "tool-monitor", @@ -67,6 +72,7 @@ impl ToolKind { ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(), ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"), ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"), + ToolKind::Services => format!("{host} 上的 systemd 服务:启动、停止、日志。"), ToolKind::Processes => format!("{host} 上的进程:内存、CPU,结束进程。"), ToolKind::Connections => format!("{host} 上的 TCP、UDP 连接和监听端口。"), ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"), @@ -103,6 +109,7 @@ pub struct ToolSidebar { /// Whether the right dock is open. shown: bool, monitor: Entity, + services: Entity, processes: Entity, netstat: Entity, store: Entity, @@ -126,6 +133,7 @@ impl ToolSidebar { terminal: None, shown: false, monitor: cx.new(|_| MonitorPanel::new(dispatch.clone())), + services: cx.new(|cx| ServicePanel::new(dispatch.clone(), window, cx)), processes: cx.new(|cx| ProcessPanel::new(dispatch.clone(), window, cx)), netstat: cx.new(|cx| NetstatPanel::new(dispatch, window, cx)), store, @@ -164,6 +172,17 @@ impl ToolSidebar { self.netstat.update(cx, |netstat, cx| netstat.refresh(cx)); } + /// 系统服务's 刷新, and after a command. + pub fn refresh_services(&mut self, cx: &mut Context) { + self.services + .update(cx, |services, cx| services.refresh(cx)); + } + + /// A service of the host in front, as 系统服务 last showed it. + pub fn service(&self, name: &str, cx: &App) -> Option { + self.services.read(cx).service(name) + } + /// 进程管理's 刷新. pub fn refresh_processes(&mut self, cx: &mut Context) { self.processes @@ -199,11 +218,15 @@ impl ToolSidebar { view: terminal.view.clone(), }); let showing = |tool| self.shown && self.tool == tool; - let (monitor, netstat, processes) = ( + let (monitor, netstat, processes, services) = ( showing(ToolKind::Monitor), showing(ToolKind::Connections), showing(ToolKind::Processes), + showing(ToolKind::Services), ); + self.services.update(cx, |panel, cx| { + panel.set_target(target.clone(), services, cx) + }); self.monitor.update(cx, |panel, cx| { panel.set_target(target.clone(), monitor, cx) }); @@ -310,6 +333,9 @@ impl Render for ToolSidebar { ToolKind::Processes => { sidebar.child(div().flex_1().min_h_0().child(self.processes.clone())) } + ToolKind::Services => { + sidebar.child(div().flex_1().min_h_0().child(self.services.clone())) + } _ => sidebar.child(self.render_placeholder(terminal.host, cx)), } }) diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index efaeb7b..71d04f0 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -18,10 +18,12 @@ use gpui_kit::component::{ use gpui_kit::*; use crate::app::{ - CenterTab, EndProcess, RefreshConnections, RefreshProcesses, ShowProcess, SortProcesses, - ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind, + CenterTab, ControlService, EndProcess, RefreshConnections, RefreshProcesses, RefreshServices, + ShowProcess, ShowService, SortProcesses, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, + ToolKind, }; use crate::processes::{end_command, ended, open_process_dialog}; +use crate::services::{ServiceCommand, control_command, controlled, open_service_dialog}; use crate::shared::confirm_danger; use crate::terminal::exec_answer; @@ -167,6 +169,127 @@ impl Workspace { .update(cx, |tools, cx| tools.sort_processes(by, cx)); } + /// 系统服务's 刷新: read the host's services again. + pub(super) fn on_refresh_services( + &mut self, + _: &RefreshServices, + _: &mut Window, + cx: &mut Context, + ) { + self.tools + .update(cx, |tools, cx| tools.refresh_services(cx)); + } + + /// A service's details: its state in full and its journal. + pub(super) fn on_show_service( + &mut self, + action: &ShowService, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.tool_terminal(cx) else { + return; + }; + let Some(service) = self.tools.read(cx).service(&action.0, cx) else { + return; + }; + open_service_dialog( + service, + terminal.view, + self.focus_handle.clone(), + window, + cx, + ); + } + + /// Run a command on a service over the SSH terminal's own connection, + /// asking first before it stops or restarts one; how it went is a + /// notification, and the list is read again. + pub(super) fn on_control_service( + &mut self, + action: &ControlService, + window: &mut Window, + cx: &mut Context, + ) { + let Some(terminal) = self.tool_terminal(cx) else { + return; + }; + let ControlService { name, command } = action.clone(); + let workspace = cx.entity().downgrade(); + let run = Rc::new({ + let name = name.clone(); + move |window: &mut Window, cx: &mut App| { + let reply = control_command(&name, command) + .and_then(|script| terminal.view.upgrade()?.read(cx).exec(script, cx)); + let Some(reply) = reply else { + window.push_notification( + Notification::error(format!( + "无法{} {name}:终端没有连接", + command.label() + )), + 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)) => controlled(&output), + }; + cx.update(|window, cx| { + let notification = match outcome { + Ok(()) => { + Notification::success(format!("{name} {}", command.done())) + } + Err(why) => Notification::error(format!( + "无法{} {name}:{why}", + command.label() + )), + }; + window.push_notification(notification, cx); + workspace + .update(cx, |this, cx| { + this.tools + .update(cx, |tools, cx| tools.refresh_services(cx)) + }) + .ok(); + }) + .ok(); + }) + .detach(); + } + }); + // Stopping and restarting take something away, if only for a + // moment; starting and the boot settings do not. + let ssh = matches!(name.as_str(), "ssh.service" | "sshd.service"); + let (title, description) = match command { + ServiceCommand::Stop => ( + format!("停止服务“{name}”?"), + if ssh { + "这是 SSH 服务:停止后新的连接都连不上这台主机,直到它再次启动。" + } else { + "停止后它提供的功能就不可用了,直到再次启动。" + }, + ), + ServiceCommand::Restart => ( + format!("重启服务“{name}”?"), + "服务会先停止再启动,中间短暂不可用。", + ), + _ => return run(window, cx), + }; + confirm_danger( + title.into(), + Some(description.into()), + command.label(), + run, + window, + cx, + ); + } + /// A process's details, from 进程管理's list and its command line. pub(super) fn on_show_process( &mut self, diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 5c2228e..b33c715 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -2240,6 +2240,9 @@ impl Render for Workspace { .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_refresh_services)) + .on_action(cx.listener(Self::on_show_service)) + .on_action(cx.listener(Self::on_control_service)) .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 ea7db45..bf34283 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -15,6 +15,7 @@ mod host_tree; mod monitor; mod netstat; mod processes; +mod services; mod settings; mod sftp; mod terminal; diff --git a/tests/ui/monitor.rs b/tests/ui/monitor.rs index d0c1f7d..050401e 100644 --- a/tests/ui/monitor.rs +++ b/tests/ui/monitor.rs @@ -258,6 +258,7 @@ fn the_monitor_is_not_offered_on_a_host_known_not_to_run_linux(cx: &mut TestAppC 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.try_find("tool-services").is_none()); assert!(window.find("tool-history").visible()); assert!( window diff --git a/tests/ui/services.rs b/tests/ui/services.rs new file mode 100644 index 0000000..fb95c07 --- /dev/null +++ b/tests/ui/services.rs @@ -0,0 +1,316 @@ +//! 系统服务: the right sidebar's tool that lists the systemd services of the +//! SSH terminal's host, runs commands on them and shows their details, +//! over the terminal's own connection. + +use gpui_kit::{SharedString, Window}; + +use crate::support::*; + +/// A host with one service of the administrator's, two of the +/// distribution's and one that failed. +const LIST: &str = "\ +@@version +systemd 249 (249.11-0ubuntu3.12) +@@state +degraded +@@units +Id=socialc.service +Description=Social Callback Proxy Service +ActiveState=active +UnitFileState=enabled +FragmentPath=/etc/systemd/system/socialc.service +LoadState=loaded + +Id=nginx.service +Description=A high performance web server +ActiveState=active +UnitFileState=enabled +FragmentPath=/lib/systemd/system/nginx.service +LoadState=loaded + +Id=ModemManager.service +Description=Modem Manager +ActiveState=inactive +UnitFileState=disabled +FragmentPath=/lib/systemd/system/ModemManager.service +LoadState=loaded + +Id=certbot.service +Description=Certbot +ActiveState=failed +UnitFileState=static +FragmentPath=/lib/systemd/system/certbot.service +LoadState=loaded +"; + +/// nginx in full, as `systemctl show` gives it. +const NGINX: &str = "\ +Id=nginx.service +Description=A high performance web server +LoadState=loaded +ActiveState=active +SubState=running +UnitFileState=enabled +FragmentPath=/lib/systemd/system/nginx.service +MainPID=781 +MemoryCurrent=1990656 +TasksCurrent=5 +NRestarts=0 +ExecMainStatus=0 +ActiveEnterTimestamp=Tue 2026-09-15 17:49:07 CST +InactiveEnterTimestamp= +"; + +const JOURNAL: &str = "\ +Sep 15 17:49:07 web-01 systemd[1]: Starting A high performance web server... +Sep 15 17:49:07 web-01 systemd[1]: Started A high performance web server. +"; + +fn open_services(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-services", cx)); + handle +} + +async fn wait_for_label( + cx: &mut TestAppContext, + handle: WindowHandle, + id: &str, + label: &str, +) { + let id = SharedString::from(id.to_owned()); + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window + .try_find(id.clone()) + .is_some_and(|element| element.label() == Some(label)) + }) + .await; +} + +async fn wait_for_list(cx: &mut TestAppContext, handle: WindowHandle) { + wait_for_label(cx, handle, "services-summary", "systemd 249 · 降级运行").await; +} + +/// The lines from the top of the list down, of those named. +fn listed(window: &mut Window, ids: &[&str]) -> Vec { + let mut lines: Vec<(f32, String)> = ids + .iter() + .filter_map(|id| { + let element = window.try_find(SharedString::from(id.to_string()))?; + Some((f32::from(element.bounds().origin.y), id.to_string())) + }) + .collect(); + lines.sort_by(|a, b| a.0.total_cmp(&b.0)); + lines.into_iter().map(|(_, id)| id).collect() +} + +const LINES: [&str; 6] = [ + "services-group:custom", + "service:socialc.service", + "services-group:system", + "service:ModemManager.service", + "service:certbot.service", + "service:nginx.service", +]; + +#[gpui_kit::test] +async fn the_administrators_services_come_first(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[LIST])); + let handle = open_services(cx, factory.clone()); + wait_for_list(cx, handle).await; + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(), + Some("系统服务") + ); + assert_eq!(listed(window, &LINES), LINES); + assert_eq!( + window.find("services-group:custom").label(), + Some("自定义服务 1") + ); + assert_eq!( + window.find("services-group:system").label(), + Some("系统服务 3") + ); + assert_eq!( + window.find("service:socialc.service").label(), + Some("socialc.service · Social Callback Proxy Service · 运行中 · 已启用") + ); + // A running service can be stopped and restarted, a stopped one + // started. + assert!(window.try_find("service-stop:nginx.service").is_some()); + assert!(window.try_find("service-restart:nginx.service").is_some()); + assert!(window.try_find("service-start:nginx.service").is_none()); + assert!( + window + .try_find("service-start:ModemManager.service") + .is_some() + ); + }); + + // Read once: services change seldom. + cx.executor().advance_clock(Duration::from_secs(60)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), 1); +} + +#[gpui_kit::test] +async fn the_tabs_and_the_search_narrow_the_list(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[LIST])); + let handle = open_services(cx, factory); + wait_for_list(cx, handle).await; + + // 失败. + in_frame(cx, handle, |window, cx| { + window.within("services-tabs").click(3usize, cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + listed(window, &LINES), + ["services-group:system", "service:certbot.service"] + ); + }); + + // 全部, then a search. + in_frame(cx, handle, |window, cx| { + window.within("services-tabs").click(0usize, cx) + }); + in_frame(cx, handle, |window, cx| { + window.click("services-search", cx); + window.input("modem", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + listed(window, &LINES), + ["services-group:system", "service:ModemManager.service"] + ); + }); + + in_frame(cx, handle, |window, cx| { + window.press("cmd-a", cx); + window.input("nothing-like-it", cx); + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("services-empty").label(), + Some("没有符合条件的服务") + ); + }); +} + +#[gpui_kit::test] +async fn stopping_a_service_asks_first_and_starting_one_does_not(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[ + LIST, + "@@status 0\n", + LIST, + "@@status 0\n", + LIST, + ])); + let handle = open_services(cx, factory.clone()); + wait_for_list(cx, handle).await; + + in_frame(cx, handle, |window, cx| { + window.click("service-stop:nginx.service", cx) + }); + // Nothing runs before the answer, and the card's own click does not + // open the details. + assert_eq!(factory.exec_count(), 1); + in_frame(cx, handle, |window, cx| { + assert!(window.try_find("service-details").is_none()); + window.click("ok", cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| { + factory.exec_count() == 3 + }) + .await; + let commands = factory.exec_commands(); + assert!( + commands[1].contains("systemctl stop -- \"nginx.service\""), + "{}", + commands[1] + ); + + in_frame(cx, handle, |window, cx| { + window.click("service-start:ModemManager.service", cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| { + factory.exec_count() == 5 + }) + .await; + assert!(factory.exec_commands()[3].contains("systemctl start -- \"ModemManager.service\"")); +} + +#[gpui_kit::test] +async fn a_click_on_a_service_shows_its_state_and_journal(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[LIST, NGINX, JOURNAL])); + let handle = open_services(cx, factory.clone()); + wait_for_list(cx, handle).await; + + in_frame(cx, handle, |window, cx| { + window.click("service:nginx.service", cx) + }); + wait_for_label(cx, handle, "service-field:主进程 PID", "781").await; + in_frame(cx, handle, |window, _| { + let field = |label: &str| { + window + .find(SharedString::from(format!("service-field:{label}"))) + .label() + .map(str::to_owned) + }; + for (label, value) in [ + ("加载状态", "已加载"), + ("运行状态", "运行中 / 运行中"), + ("开机启动", "已启用"), + ("内存", "1.90 MB"), + ("任务数", "5"), + ("重启次数", "0"), + ("退出状态", "0"), + ("启动时间", "Tue 2026-09-15 17:49:07 CST"), + ("停止时间", "—"), + ("Unit 文件", "/lib/systemd/system/nginx.service"), + ] { + assert_eq!(field(label).as_deref(), Some(value), "{label}"); + } + assert!(window.try_find("service-dialog-stop").is_some()); + assert!(window.try_find("service-dialog-disable").is_some()); + }); + assert!(factory.exec_commands()[1].contains("systemctl show")); + + // The journal is read when its tab opens. + assert_eq!(factory.exec_count(), 2); + in_frame(cx, handle, |window, cx| { + window.within("service-tabs").click(1usize, cx) + }); + wait_for_label(cx, handle, "service-journal", JOURNAL.trim_end()).await; + assert!(factory.exec_commands()[2].contains("journalctl -u \"nginx.service\"")); +} + +#[gpui_kit::test] +async fn the_services_say_when_systemd_does_not_run_the_host(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nLinux\n"])); + let handle = open_services(cx, factory); + wait_for_label( + cx, + handle, + "services-message", + "这台主机没有使用 systemd,暂不支持管理它的服务。", + ) + .await; +} + +#[gpui_kit::test] +async fn the_services_wait_for_the_terminal_to_connect(cx: &mut TestAppContext) { + // web-01's terminal is the first started, and exits at once. + let handle = open_services(cx, Arc::new(FakeTerminalFactory::exit_first())); + wait_for_label( + cx, + handle, + "services-message", + "终端没有连接。连接后这里显示主机的系统服务。", + ) + .await; +}