From 8a0436caf66fa139f36ce0e01bb4dccb246ec7b6 Mon Sep 17 00:00:00 2001 From: Xuz Date: Fri, 2 Oct 2026 13:34:23 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=9B=91=E6=8E=A7=EF=BC=9A?= =?UTF-8?q?=E5=8F=B3=E4=BE=A7=E6=A0=8F=E7=9A=84=E7=AC=AC=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=EF=BC=8C=E7=BB=8F=E7=BB=88=E7=AB=AF=E8=87=AA?= =?UTF-8?q?=E5=B7=B1=E7=9A=84=20SSH=20=E8=BF=9E=E6=8E=A5=E8=AF=BB=E5=8F=96?= =?UTF-8?q?=20Linux=20=E4=B8=BB=E6=9C=BA=E7=9A=84=20CPU=E3=80=81=E5=86=85?= =?UTF-8?q?=E5=AD=98=E3=80=81=E7=BD=91=E7=BB=9C=E5=92=8C=E7=A3=81=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 右侧栏只跟远程终端出现,已知不是 Linux 的主机不显示监控图标,最窄 320 px - 端口转发、凭据列表和开始页的滚动条挂到不滚动的外层上,修正滑块位置 Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 11 +- docs/manual.md | 14 + src/app/actions.rs | 3 + src/app/assets.rs | 3 +- src/credential/credential_panel.rs | 6 +- src/forward/forward_panel.rs | 6 +- src/host/model.rs | 24 + src/lib.rs | 7 +- src/monitor/linux.rs | 502 +++++++++++++++++ src/monitor/mod.rs | 18 + src/monitor/model.rs | 459 ++++++++++++++++ src/monitor/monitor_panel.rs | 838 +++++++++++++++++++++++++++++ src/ssh/transport.rs | 139 ++++- src/terminal/engine.rs | 31 +- src/terminal/mod.rs | 2 +- src/terminal/terminal_view.rs | 15 + src/terminal/transport.rs | 22 + src/workspace/recent_hosts.rs | 27 +- src/workspace/tool_sidebar.rs | 139 +++-- src/workspace/tools.rs | 119 +++- src/workspace/workspace_view.rs | 32 +- tests/ui/main.rs | 1 + tests/ui/monitor.rs | 390 ++++++++++++++ tests/ui/support/terminal.rs | 48 ++ tests/ui/workspace.rs | 136 +++-- 25 files changed, 2835 insertions(+), 157 deletions(-) create mode 100644 src/monitor/linux.rs create mode 100644 src/monitor/mod.rs create mode 100644 src/monitor/model.rs create mode 100644 src/monitor/monitor_panel.rs create mode 100644 tests/ui/monitor.rs diff --git a/CLAUDE.md b/CLAUDE.md index 3f05bbe..bff3d59 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 显示或隐藏(显示上次的工具);**只跟着终端出现**(远程终端和本地终端都算):当前标签是 SFTP、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息),⌘⌥B 也不起作用;回到任一终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端(远程终端是它的主机,本地终端是「本机」);它是**工作区级**的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,和 Dock 布局一样不持久化;目前的工具(命令片段、历史命令、Docker、系统监控)都是占位,写着将来显示什么和「即将推出」。 +已确认的产品决定: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% 红,其余绿。 做 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` 为准。 @@ -57,6 +57,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `forward_panel.rs`(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、`forward_dialog.rs`(`ForwardForm` 与 `open_forward_dialog`)、`diagram.rs`(对话框里的示意图 `ForwardDiagram`,以及 `stops` / `explain` / `purpose` 等纯函数)。 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 - `credential/` — 凭据的界面,模型在 `host/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `host::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `HostStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 +- `monitor/` — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:`linux.rs`(在终端自己的连接上执行的命令 `COMMAND` 和解析 `parse`)、`model.rs`(读数、`Tracker` 算比率、格式化)、`monitor_panel.rs`(`MonitorPanel` 与 `MonitorTarget`:轮询读数、五张卡片),见下面「系统监控(`monitor/`)是纯解析加一个面板」。依赖 `terminal`(`TerminalView::exec`),不依赖 `workspace`。 - `workspace/` — `workspace_view.rs` 持有 `HostStore`、`DockArea`、按主机登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-hosts` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`tool_sidebar.rs`(右侧 Dock 唯一的面板 `ToolSidebar` 和窗口最右边的工具切换 `render_tool_switch`)、`tools.rs`(`impl Workspace` 的右侧栏部分:`set_active_tab`、`ToggleTool` / `ToggleToolSidebar` 和 `set_right_dock_open`,见下面「右侧栏是右侧 Dock 的一个面板」)、`recent_hosts.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 `host::host_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 - `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 @@ -86,6 +87,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **文件列表快捷键按焦点路由。** 一个动作 `ExplorerShortcut(ExplorerCommand)`,侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(`contains_focus`),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(`tooltip_with_action`),所以 WinSCP 的键放最后。菜单项派发 `ExplorerAction`,按动作相等查不到绑定,菜单里不显示快捷键。 - **框选同 WinSCP 关掉「整行选择」。** 拖文件去另一侧的 `on_drag` 只挂在名称单元格(图标加文件名,`FileListing::drag_from`)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(`FilePane::select_pressed`,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 `DragCopy` 光标,其余地方(包括自己的列表)不显示预览、光标是 `OperationNotAllowed`(WinSCP 同):载荷带一个 `DropSpot`,接收侧容器的 `on_drag_move` 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 `window.mouse_position()`。存位置而不是布尔值,是因为 `on_drag_move` 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 `set_window_cursor_style`:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 `file-drag-preview`(`aria_label` 是显示的文字)。列表容器 `("file-list", side)` 自己挂一个 `MarqueeDrag`,它带着开启它的面板的 `EntityId`:GPUI 的 `on_drag_move` 会把同一类型的拖动通知给**所有**挂了监听的元素,不管是谁开启的,所以监听里要核对 `event.drag(cx).pane == cx.entity_id()`,否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(`Pressed::Title`)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 `canvas`(**必须** `top_0().left_0()`)在 prepaint 记下「第一行顶端的位置和行高」(`ListGeometry::rows`),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 `selection::swept_rows` / `row_at` 算,`Selection::sweep` 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 `gpui_kit::base::AutoScroll` 滚动表格的 `vertical_scroll_handle`。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(`div.rs` 里 `has_active_drag` 时清 `pending_mouse_down`),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 `ends_a_drag`(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(`on_mouse_up` / `on_mouse_up_out`)时清掉选框并停止滚动。选框是容器里的绝对定位 `selection-rectangle`,夹在列表范围内,配色和选中的名称一套(`tokens.table_active` 底色加 `table_active_border` 边框,用户要的;原来是 `primary` 的灰底深色框,和浅蓝的选中不搭)。 - **列表右键菜单挂在列表容器 `("file-list", side)` 上**,和主机树一样:名称的右键处理器先把没选中的项选中、把 `MenuHit::Item(名称)` 写进 `menu_hit`(右键空白处则清空选择,给空白处菜单),列标题(委托的 `render_th`)写 `MenuHit::Column(列键)`,容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。**不要在标题或行上另挂 `context_menu`**:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 `explorer::FileSizeFormat` 存在 `AppSettings::file_size_format`(`settings.json`),由 `settings::apply` 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 `SetFileSizeFormat`,工作区写设置;委托的 `context_menu` 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。 +- **滚动条挂在不滚动的外层上。** `vertical_scrollbar(&handle)` 往元素里加一个绝对定位、铺满的滚动条图层;挂在 `overflow_y_scroll()` 的元素自己身上时,GPUI 给滚动容器的所有子元素(包括绝对定位的)加滚动偏移,滚动条跟着内容一起移走,滑块和实际位置对不上(用户在系统监控上报过,端口转发、凭据列表和开始页原来也是这样写的)。所以滚动区(`overflow_y_scroll().track_scroll(&handle)`)外面包一层 `.relative()`、不滚动的容器,`vertical_scrollbar` 挂在这一层上(gpui-kit 自己的 Tree、设置页都这样;主机树、端口转发和凭据列表的 `*-list`、开始页的 `recent-hosts` 包 `recent-host-page`);不需要自己的 `ScrollHandle` 时直接用 `overflow_y_scrollbar()`,它自己把两层摆好(系统监控)。滚动条不暴露给测试,改动后只能看真窗口。 - **`Select` 必须放进有尺寸的容器。** 它的外层是 `size_full()`,直接放进工具栏行会盖住整行,别的按钮全点不到。 - **SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。** 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 `flex_wrap` 加固定一行高(`h_6`,和 small 按钮、下拉框一样高)加 `overflow_hidden`:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 `frame` 上,行高才不含它们。`sftp_controls_fit_small_window_in_light_dark_and_zoom` 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 `resizable_panel` 都 `flex_basis(relative(0.5))`,只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 `rems(10.)`)约 404 px,放得下(`sftp_panes_open_half_and_half` 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。 - **断开时对远程侧的操作要有回应。** `ExplorerPanel::execute` 一进来先看 `needs_connection(command)`(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 `problem`),确定按钮「重新连接」派发 `ExplorerCommand::Reconnect`;`can_reconnect` 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(`FilePane::takes_commands`:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(`sync_available` 的 `transfer`)。 @@ -107,7 +109,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 外面,两者都只跟着终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind` 和它服务的终端 `ToolTerminal::{Remote(RemoteTerminalId, HostId), Local(LocalTerminalId)}`(`set_terminal`;根元素的 id 跟着终端,是 `("tool-sidebar", 远程终端 id)` 或 `("local-tool-sidebar", 本地终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。工具切换 `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`(用户最后一次开 / 关的意愿)且有终端在前;切换按钮列只在有终端在前时画。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_terminals_only` 守着)。切换按钮是 `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` 查标签栏里没有那个按钮)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。以后做真的工具:每个工具是一个功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(保留状态,同左侧三个列表),按 `set_terminal` 给的终端换内容,数据按终端 / 主机分开,只在显示时轮询;要连服务器的(监控、Docker、历史)优先借用这个终端已有的 SSH 连接(像探测系统、量延迟那样另开 exec 通道),不为侧栏另起登录;本地终端的直接在本机取。 +- **右侧栏是右侧 Dock 的一个面板,切换画在 Dock 外面,两者都只跟着 SSH 终端出现。** 右侧 Dock 里只有 `workspace::ToolSidebar`,持有当前工具 `ToolKind`、它服务的终端 `ToolTerminal { id: RemoteTerminalId, host, view: WeakEntity }` 和 Dock 是否开着(`set_terminal(terminal, shown)`;根元素的 id 跟着终端,是 `("tool-sidebar", 终端 id)`),标题是工具名,理由同左边的 `Sidebar`(换面板会把焦点带走、两个面板必然画出标签栏)。做好的工具是功能模块的实体,由 `ToolSidebar` 持有、隐藏时也活着(`MonitorPanel`),`sync_tools` 把终端交给它、并且只让正显示的那个工具跑(Dock 开着且当前工具是它);没做的工具画占位。工具切换 `render_tool_switch` 由工作区画在 `DockArea` 右边(`h_flex` 里 `DockArea` 占 `flex_1().min_w_0()`),因为收起的 Dock 整个不画。**当前标签只经 `Workspace::set_active_tab` 改**(四种标签的 `Activated` / `Closed` 订阅都是 `subscribe_in`,要拿到 `window`):它记下标签再 `sync_tool_sidebar`,`tool_terminal(cx)` 是「当前标签是还在的远程终端」,右侧 Dock 开着 = `tool_sidebar_wanted`(用户最后一次开 / 关的意愿)且有远程终端在前;切换按钮列只在有远程终端在前时画,只画这台主机能用的工具(`offered_tools`:`ToolKind::works_on(host.os)`,系统监控要求 `HostOs::is_linux` 或还没探测出系统),当前工具这台用不了时右侧栏也收起(意愿不变);系统探测的结果在连接后才回来,所以工作区 `observe_in` 主机 store 时也 `sync_tool_sidebar`。新加会改当前标签的地方要走 `set_active_tab`,直接赋值 `active_tab` 右侧栏就不跟了。没有远程终端在前时 `ToggleTool` / `ToggleToolSidebar` 什么也不做,也不改意愿(`the_tool_sidebar_goes_with_ssh_terminals_only` 守着);`ToggleTool` 对这台主机用不了的工具也不做;`ToggleToolSidebar` 在右侧栏收着时打开上次的工具,那个工具这台用不了就换成第一个能用的(`the_monitor_is_not_offered_on_a_host_known_not_to_run_linux`)。切换按钮是 `toggled` 的开关(`checked()` 就是当前显示的工具),`tooltip_placement(Placement::Left)` 朝窗口里弹;按钮按下不抢焦点(gpui-kit 的 `Button` 在 mouse down 时 `prevent_default`),终端一直接着键盘,派发走工作区的 focus handle(同标题栏)。**右侧 Dock 不可折叠**:可折叠的 Dock 会在中间区的标签栏末尾多出 gpui-kit 自己的折叠按钮(`toggle-dock:Right`),和切换重复;而不可折叠的 Dock `toggle_dock` 拒绝关闭,所以 `set_right_dock_open` 只在关闭的那一下把它设成可折叠、关完再设回去(`the_tool_switch_shows_a_tool_and_hides_it_again` 查标签栏里没有那个按钮)。**最窄 320 px(`TOOL_SIDEBAR_WIDTH`,也是打开时的宽度)**:Dock 没有单独设最小宽度的接口,`DockContext` 的拖动回调是私有字段、包不了,所以工作区 `observe_in` DockArea,拖动每一步它都会通知,`hold_tool_sidebar_width` 发现比 320 px 窄就在同一轮更新里 `set_dock_size` 设回去,画出来的帧不会更窄(`the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens` 用鼠标事件拖 `resize-handle-right`:拖动条横跨 Dock 左边缘左右各 4 px,按在边缘左侧会被中间的终端挡住,要按在边缘右侧;Dock 按指针的绝对位置算宽度)。收起时焦点要是在右侧栏里,`Workspace::focus_center` 把它交还给最近显示的中间标签(中间区为空时给开始页),否则所有快捷键失去派发路径;换工具时焦点在旧工具里就先交给 `ToolSidebar` 自己。`ToolSidebar` 观察 store,主机改名跟着变。 +- **右侧栏的工具在终端自己的连接上执行命令,不另起登录。** `TerminalTransportCommand::Exec(ExecRequest { command, reply })`:SSH 传输在主循环里另开 exec 通道执行(和系统探测、量延迟同一个循环),**一次一条**(`exec_queue` 排队,免得往服务器上堆通道),10 秒(`EXEC_TIMEOUT`)没结束就答「命令超时」,只收标准输出、最多 1 MB,`ChannelMsg::Failure` 答「服务器拒绝执行命令」;回答走 `reply`(`std::sync::mpsc`,`ExecResult = Result`)。本地 PTY 和其他传输丢掉请求,问的一方看到通道断开。`TerminalEngine::exec` / `TerminalView::exec` 只在 shell 运行中(`TerminalLifecycle::Running`)才发,否则返回 `None`。回答来自传输线程,所以工具**轮询** `try_recv`(每 50 ms),不在 GPUI 任务里直接等它(同「禁止后台线程直接唤醒 GPUI 前台任务」)。命令要能经过任何登录 shell(bash、zsh、fish、csh):写成一行 `sh -c '…'`,脚本里不出现单引号和 `!`(`the_command_survives_any_login_shell` 守着)。协议测试 `commands_beside_the_shell_run_on_the_terminals_own_connection` 查按顺序回答、不再登录。 +- **系统监控(`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()`(见下面「滚动条挂在不滚动的外层上」)。 + - **侧栏是一个面板、三个列表。** 左侧 Dock 里只有 `workspace::Sidebar`,它持有 `HostPanel`、`ForwardPanel` 和 `CredentialPanel` 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 `Sidebar` 里、两种模式都有。不用 `set_dock` 换面板(被换下的面板会收到 `on_removed`、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 `Sidebar::show` 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 `set_active(true)` 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。`FocusSearch` 交给 `Sidebar::focus_search`。 - **转发列表的命令经工作区的 focus handle 派发。** 行上的启停按钮、工具栏的「+」、空状态的按钮都用 `target.dispatch_action`(同标题栏和开始页)。用 `window.dispatch_action` 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(`a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward` 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 `on_click` 要 `cx.stop_propagation()`,否则双击它会连行的双击一起触发。 - **`ListItem` 里会增长的文字要零宽加裁剪。** `ListItem` 把内容包在两层 `w_full` 里、`suffix` 是它们的兄弟,整行靠内容的最小宽度收缩来给 `suffix` 让位。`flex_1().min_w_0()` 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 `suffix`(状态和启停按钮)挤出列表。转发行的文字列因此是 `w_0().flex_grow(1.).overflow_x_hidden()`(`the_forward_dialog_and_a_long_row_fit_the_smallest_window` 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:`RadioGroup::horizontal` 的那一行是 `flex_wrap`,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 `flex_1().min_w_0().self_stretch()`(那一行是 `items_center`,不拉伸的话高度不齐),卡片里那句用途同样是 `w_0().flex_grow(1.).overflow_x_hidden()`,放不下时在卡片内折行。`Radio` 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(`RADIO_DOT_COLUMN`:small 圆点 `size_3p5` 加 `gap_x_2`)拉回到圆点下面,占满卡片的整个宽度。对话框宽 `rems(42.)` 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 `Radio` 的圆点尺寸或间距时 `RADIO_DOT_COLUMN` 要跟着改,`the_three_kinds_of_forward_share_one_row` 查用途这一行的两端离卡片边缘的距离。 @@ -178,4 +183,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`(标签页和关闭、开始页、标题栏、对话框的共性)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)` / `("local-tool-sidebar", 本地终端 id)`(`aria_label` 是工具名,收起或当前标签不是终端时不存在)与占位说明 `tool-placeholder`(`aria_label` 是说明文字);当前标签不是终端时 `tool-switch` 也不存在、`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`(系统监控)、`settings`、`sftp`(SFTP 标签的面板、导航、书签、文件操作、选择)、`transfer`(上传、下载、拖动传输、队列)、`forward`、`credential`、`update`;共用的夹具和假实现在 `support/`:`support/mod.rs` 是公共的 `use`(`pub use`,所以各模块开头只写 `use crate::support::*;`)、种子 id 和 `open_workspace` / `in_frame` 这类通用助手,`support/{connection,terminal,sftp,forward,credential}.rs` 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 `support`(项、字段和方法都要 `pub`)。**不要在 `tests/` 下另开测试文件**:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-` 与 `skill-status-`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)` 与 SFTP 标签的 `("sftp-latency", explorer_id)`(`aria_label` 是「32 ms」或「超时」;SFTP 测试往 `FakeSftpProvider::events` 里送 `SftpEvent::Latency`)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、侧栏行的 tooltip `host-tooltip` / `forward-tooltip` / `credential-tooltip`(`aria_label` 是第一行的文字,主机是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的补充 `host-tooltip-note` / `credential-tooltip-note`(`aria_label` 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、右侧栏的切换 `tool-switch` 与其中的 `tool-snippets` / `tool-history` / `tool-docker` / `tool-monitor`(`checked()` 是当前显示的那个,右侧栏收起时都不是)、右侧栏 `("tool-sidebar", 远程终端 id)`(`aria_label` 是工具名,收起或当前标签不是远程终端时不存在)与占位说明 `tool-placeholder`(`aria_label` 是说明文字);当前标签不是远程终端时 `tool-switch` 也不存在。系统监控的有:面板 `monitor`、展开按钮 `monitor-cores-toggle` / `monitor-interfaces-toggle`(`checked()` 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 `monitor-loading`、问题说明 `monitor-message`(`aria_label` 是文字:没连接、不支持、读取失败)、`monitor-host` / `monitor-arch` / `monitor-os` / `monitor-uptime`(`aria_label` 是值,运行时长形如「62 天 00:39:24」)、`monitor-cpu-usage`(`aria_label` 形如「50.0%」,第一次读数时是「—」)、每核 `monitor-cpu-cores`(多于 1 核就有,`aria_label` 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 `("monitor-core", 序号)`(`sixty_four_cores_show_one_row_until_unfolded` 量它们的位置;收起时一行的个数要等第二帧,测试里先 `simulate_next_frame`)、`monitor-memory-usage` / `monitor-memory-amount`(「421.02 MB / 973.25 MB」)、有交换空间时的 `monitor-swap-usage` / `monitor-swap-amount`、网卡 `monitor-interface:<名称>`(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 `monitor-disk:<挂载点>`(「35.1%,3.09 GB / 9.21 GB」)。假终端 `FakeTerminalFactory::answering(&[输出…])` 依次回答旁路命令(最后一个重复,不看命令内容),`exec_commands()` / `exec_count()` 是执行过的命令,没给输出就丢掉请求、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`(对话框的校验错误,在错误通知里,`aria_label` 是错误文字)、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/docs/manual.md b/docs/manual.md index fc992aa..fe1ba16 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -135,6 +135,20 @@ 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)不显示系统监控的图标。 + +**系统监控**显示远程 Linux 主机的: + +- **系统**:主机名、架构、发行版、运行时长(如「32 天 23:40:26」)。 +- **CPU**:型号、核数和平均使用率;多核时下面每核一根小竖条,鼠标停在竖条上显示这个核的编号和使用率。默认只显示一行,核多到一行放不下时点右上角的「N 核」展开全部。 +- **内存**:物理内存的使用率和用量;有交换空间时另有一行。 +- **网络**:主网卡(默认路由所在的网卡)的上传、下载速率;有多块网卡时点右上角的「N 个网卡」展开全部(不列回环网卡和从没收发过数据的网卡)。 +- **磁盘**:每个本地挂载点的占用(不含 tmpfs 等内存文件系统和系统目录下的挂载)。 + +数据通过这个终端已有的 SSH 连接读取,不需要再次登录,也不安装任何东西;每 2 秒刷新一次(磁盘每 30 秒一次),只在系统监控显示时读取。每次刷新只读取几个系统文件,除了执行它的 shell 不在服务器上启动其他进程。使用率达到 70% 显示为黄色,达到 90% 显示为红色。终端未连接时显示提示;还没识别出系统的主机如果不是 Linux,会写明暂不支持。 + ## SFTP 打开主机的「SFTP」标签即可浏览本地与远程目录;和终端一样,每次「打开 SFTP」都新开一个标签,同一台主机可以开多个,各自独立连接、各看各的目录。操作方式对标 WinSCP Commander。SFTP 使用独立 SSH 连接,仅请求 SFTP 子系统,不要求服务器提供 Shell;关闭终端不会中止传输。 diff --git a/src/app/actions.rs b/src/app/actions.rs index d61864b..74377d6 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -7,6 +7,7 @@ use crate::{ cli::AgentKind, explorer::{ExplorerId, FileSizeFormat}, host::{CredentialId, ForwardId, GroupId, HostId, HostNode, NodeDrop}, + monitor::MonitorDetail, terminal::{LocalTerminalId, RemoteTerminalId}, }; @@ -183,6 +184,8 @@ id_actions! { /// Show a tool in the right sidebar, or hide the sidebar if it is /// already showing that tool. ToggleTool(ToolKind); + /// Unfold a part of the system monitor, or fold it away again. + ToggleMonitorDetail(MonitorDetail); /// Start a port forward over a connection of its own. StartForward(ForwardId); diff --git a/src/app/assets.rs b/src/app/assets.rs index 2bd42b4..70ba9b0 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -51,7 +51,8 @@ icon_assets!( CodeXml, RotateCcwClock, Container, - Activity + Activity, + Monitor ] ); diff --git a/src/credential/credential_panel.rs b/src/credential/credential_panel.rs index ce0d40e..bd2e83f 100644 --- a/src/credential/credential_panel.rs +++ b/src/credential/credential_panel.rs @@ -442,6 +442,7 @@ impl Render for CredentialPanel { div() .id("credential-list") .test_support() + .relative() .flex_1() .min_h_0() .child( @@ -452,7 +453,6 @@ impl Render for CredentialPanel { .gap_0p5() .overflow_y_scroll() .track_scroll(&self.scroll_handle) - .vertical_scrollbar(&self.scroll_handle) .map(|list| { if rows.is_empty() { list.child(self.render_empty(cx)) @@ -463,6 +463,10 @@ impl Render for CredentialPanel { } }), ) + // On the list, which does not scroll, rather than on + // the rows, which do: there the bar would scroll away + // with them. + .vertical_scrollbar(&self.scroll_handle) .capture_any_mouse_down(move |event, _, _| { if matches!(event.button, MouseButton::Left | MouseButton::Right) { clear_hit.set(None); diff --git a/src/forward/forward_panel.rs b/src/forward/forward_panel.rs index b096153..5a89204 100644 --- a/src/forward/forward_panel.rs +++ b/src/forward/forward_panel.rs @@ -558,6 +558,7 @@ impl Render for ForwardPanel { div() .id("forward-list") .test_support() + .relative() .flex_1() .min_h_0() .child( @@ -568,7 +569,6 @@ impl Render for ForwardPanel { .gap_0p5() .overflow_y_scroll() .track_scroll(&self.scroll_handle) - .vertical_scrollbar(&self.scroll_handle) .map(|list| { if rows.is_empty() { list.child(self.render_empty(cx)) @@ -579,6 +579,10 @@ impl Render for ForwardPanel { } }), ) + // On the list, which does not scroll, rather than on + // the rows, which do: there the bar would scroll away + // with them. + .vertical_scrollbar(&self.scroll_handle) .capture_any_mouse_down(move |event, _, _| { if matches!(event.button, MouseButton::Left | MouseButton::Right) { clear_hit.set(None); diff --git a/src/host/model.rs b/src/host/model.rs index 87e159a..719763c 100644 --- a/src/host/model.rs +++ b/src/host/model.rs @@ -332,6 +332,17 @@ host_os! { NetBsd => "netbsd", "NetBSD", "netbsd", Some(0xFF_6600); } +impl HostOs { + /// Whether the host runs Linux, any distribution of it: what the system + /// monitor can read. + pub fn is_linux(self) -> bool { + !matches!( + self, + HostOs::MacOs | HostOs::Windows | HostOs::FreeBsd | HostOs::OpenBsd | HostOs::NetBsd + ) + } +} + /// Drawn on top of a light brand colour. const BRAND_DARK_MARK: u32 = 0x00_0000; /// Drawn on top of every other brand colour. @@ -578,6 +589,19 @@ impl HostDraft { mod tests { use super::*; + #[test] + fn the_distributions_are_linux_and_the_rest_are_not() { + let not_linux: Vec<_> = HostOs::ALL + .iter() + .filter(|os| !os.is_linux()) + .map(|os| os.label()) + .collect(); + assert_eq!( + not_linux, + ["macOS", "Windows", "FreeBSD", "OpenBSD", "NetBSD"] + ); + } + #[test] fn public_ids_are_sixteen_letters_and_digits() { let id = PublicId::generate(); diff --git a/src/lib.rs b/src/lib.rs index b4565b1..b40b023 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,9 +3,9 @@ //! Modules are organized by capability so they can become crates later: //! `host` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` -//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` -//! (在线升级), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app` -//! (动作、快捷键、资源). +//! (凭据), `monitor` (系统监控), `secrets` (系统钥匙串), `settings` (设置), +//! `cli` (外部 CLI), `update` (在线升级), `connection` 与 `shared` (跨模块共用), +//! `workspace` (窗口壳), `app` (动作、快捷键、资源). pub mod app; pub mod cli; @@ -14,6 +14,7 @@ pub mod credential; pub mod explorer; pub mod forward; pub mod host; +pub mod monitor; pub mod secrets; pub mod settings; pub mod sftp; diff --git a/src/monitor/linux.rs b/src/monitor/linux.rs new file mode 100644 index 0000000..0224248 --- /dev/null +++ b/src/monitor/linux.rs @@ -0,0 +1,502 @@ +//! Reading a Linux host: one command, and the parser for what it prints. + +use std::collections::HashMap; +use std::time::Duration; + +use super::model::{CpuTimes, DiskUsage, InterfaceCounters, Memory, Reading, SystemInfo}; + +/// Which parts a reading asks for besides the load, which every reading +/// does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Parts { + /// Host name, architecture, distribution and CPU model: once per + /// connection, as they do not change while connected. + pub system: bool, + /// Every half minute or so: disks fill slowly, and `df` is the slowest + /// part of a reading. + pub disks: bool, +} + +/// The parts asked once per connection. +const SYSTEM: &str = "echo @@host; uname -n; echo @@arch; uname -m; \ +echo @@os; (. /etc/os-release && echo \"$PRETTY_NAME\") 2>/dev/null || uname -sr; \ +echo @@cpu; grep -E \"^(model name|Hardware|Processor|cpu model)\" /proc/cpuinfo | head -n 1; "; + +/// The load, every reading. Shell built-ins only (`read`, `case`, `echo`), +/// so the one process a reading starts on the host is the `sh` running it; +/// `/proc/stat` and `/proc/meminfo` are read only as far as the lines it +/// needs, which come first. +const LOAD: &str = "echo @@uptime; read -r line < /proc/uptime; echo \"$line\"; \ +echo @@stat; while read -r line; do case \"$line\" in cpu*) echo \"$line\";; *) break;; esac; \ +done < /proc/stat; \ +echo @@memory; while read -r line; do case \"$line\" in \ +MemTotal:*|MemFree:*|MemAvailable:*|Buffers:*|Cached:*|SwapTotal:*) echo \"$line\";; \ +SwapFree:*) echo \"$line\"; break;; esac; done < /proc/meminfo; \ +echo @@net; while read -r line; do echo \"$line\"; done < /proc/net/dev; \ +echo @@route; while read -r line; do echo \"$line\"; done < /proc/net/route; "; + +/// `df -l` keeps to local file systems, so a dead network mount cannot hold +/// it up; a `df` without `-l` (BusyBox) runs plain. +const DISKS: &str = "echo @@df; df -kPl 2>/dev/null || df -kP 2>/dev/null; "; + +/// What the monitor runs on the host for a reading: `/proc` and `df`, each +/// part under an `@@` line of its own. +/// +/// One line, in single quotes for `sh -c`, so that whatever the login shell +/// is (bash, zsh, fish, csh) it hands the script to `sh` untouched: the +/// script has no single quote, and no `!` for csh to expand. +pub fn command(parts: Parts) -> String { + format!( + "sh -c 'export LC_ALL=C; if test -r /proc/stat; then {}{}{}\ + else echo @@unsupported; uname -s; fi'", + if parts.system { SYSTEM } else { "" }, + LOAD, + if parts.disks { DISKS } else { "" }, + ) +} + +/// What the command's output said. +#[derive(Debug, PartialEq)] +pub enum Parsed { + Reading(Box), + /// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」. + Unsupported(Option), +} + +pub fn parse(output: &str) -> Parsed { + let mut sections: HashMap<&str, Vec<&str>> = HashMap::new(); + let mut current = None; + for line in output.lines() { + if let Some(name) = line.strip_prefix("@@") { + current = Some(name.trim()); + sections.entry(name.trim()).or_default(); + } else if let Some(name) = current { + sections.entry(name).or_default().push(line); + } + } + let section = |name: &str| sections.get(name).map(Vec::as_slice).unwrap_or_default(); + let first = |name: &str| { + section(name) + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty()) + .map(str::to_owned) + }; + + let (cpu, cores) = parse_stat(section("stat")); + let Some(cpu) = cpu else { + // A shell other than sh's (cmd.exe) prints nothing we know. + return Parsed::Unsupported(first("unsupported")); + }; + Parsed::Reading(Box::new(Reading { + // Only when the reading asked for it. + system: sections.contains_key("host").then(|| SystemInfo { + host_name: first("host").unwrap_or_default(), + arch: first("arch").unwrap_or_default(), + os: first("os").unwrap_or_else(|| "Linux".into()), + cpu_model: first("cpu") + .and_then(|line| Some(line.split_once(':')?.1.trim().to_owned())) + .unwrap_or_default(), + }), + uptime: first("uptime") + .and_then(|line| line.split_whitespace().next()?.parse::().ok()) + .map(Duration::from_secs_f64), + cpu, + cores, + memory: parse_memory(section("memory")), + interfaces: parse_interfaces(section("net")), + default_route: parse_default_route(section("route")), + disks: sections + .contains_key("df") + .then(|| parse_disks(section("df"))), + })) +} + +/// `/proc/stat`'s `cpu` lines: the machine, then each core. +fn parse_stat(lines: &[&str]) -> (Option, Vec) { + let mut total = None; + let mut cores = Vec::new(); + for line in lines { + let mut fields = line.split_whitespace(); + let Some(name) = fields.next() else { continue }; + // user nice system idle iowait irq softirq steal; guest time is + // already counted in user. + let ticks: Vec = fields + .take(8) + .filter_map(|field| field.parse().ok()) + .collect(); + if ticks.len() < 4 { + continue; + } + let tick = |index: usize| ticks.get(index).copied().unwrap_or(0); + let times = CpuTimes { + busy: tick(0) + tick(1) + tick(2) + tick(5) + tick(6) + tick(7), + idle: tick(3) + tick(4), + }; + if name == "cpu" { + total = Some(times); + } else if name.starts_with("cpu") { + cores.push(times); + } + } + (total, cores) +} + +/// `/proc/meminfo`, whose sizes are in KiB. +fn parse_memory(lines: &[&str]) -> Memory { + let values: HashMap<&str, u64> = lines + .iter() + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + let kib = value.split_whitespace().next()?.parse::().ok()?; + Some((name.trim(), kib * 1024)) + }) + .collect(); + let value = |name: &str| values.get(name).copied().unwrap_or(0); + let total = value("MemTotal"); + // Kernels before 3.14 have no MemAvailable; free, buffers and cache come + // closest. + let available = values + .get("MemAvailable") + .copied() + .unwrap_or_else(|| value("MemFree") + value("Buffers") + value("Cached")); + Memory { + total, + used: total.saturating_sub(available), + swap_total: value("SwapTotal"), + swap_used: value("SwapTotal").saturating_sub(value("SwapFree")), + } +} + +/// `/proc/net/dev`: received bytes first, transmitted bytes ninth. +fn parse_interfaces(lines: &[&str]) -> Vec { + lines + .iter() + .filter_map(|line| { + let (name, counters) = line.split_once(':')?; + let counters: Vec = counters + .split_whitespace() + .map(|field| field.parse().ok()) + .collect::>()?; + Some(InterfaceCounters { + name: name.trim().to_owned(), + received: *counters.first()?, + transmitted: *counters.get(8)?, + }) + }) + .collect() +} + +/// `/proc/net/route`: the interface of the route to everywhere (destination +/// and mask all zeros), the lowest metric when there are several. +fn parse_default_route(lines: &[&str]) -> Option { + lines + .iter() + .filter_map(|line| { + let fields: Vec<&str> = line.split_whitespace().collect(); + let (name, destination, metric, mask) = ( + fields.first()?, + fields.get(1)?, + fields.get(6)?, + fields.get(7)?, + ); + (*destination == "00000000" && *mask == "00000000") + .then(|| (metric.parse::().unwrap_or(u32::MAX), name.to_string())) + }) + .min() + .map(|(_, name)| name) +} + +/// `df -kP`, without what is not a disk: memory file systems, the kernel's +/// own, snaps and container layers. +fn parse_disks(lines: &[&str]) -> Vec { + let mut disks: Vec = Vec::new(); + for line in lines { + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 6 { + continue; + } + let kib = |index: usize| fields[index].parse::().ok().map(|kib| kib * 1024); + let (Some(total), Some(used), Some(available)) = (kib(1), kib(2), kib(3)) else { + // The heading. + continue; + }; + // A mount point can hold spaces. + let mount = fields[5..].join(" "); + if total == 0 || !is_disk(fields[0], &mount) || disks.iter().any(|d| d.mount == mount) { + continue; + } + disks.push(DiskUsage { + mount, + total, + used, + available, + }); + } + disks +} + +fn is_disk(source: &str, mount: &str) -> bool { + const NOT_DISKS: [&str; 8] = [ + "tmpfs", "devtmpfs", "udev", "overlay", "shm", "none", "run", "efivarfs", + ]; + const SYSTEM_MOUNTS: [&str; 6] = [ + "/dev", + "/run", + "/sys", + "/proc", + "/snap/", + "/var/lib/docker/", + ]; + !NOT_DISKS.contains(&source) + && !source.starts_with("/dev/loop") + && !SYSTEM_MOUNTS.iter().any(|prefix| mount.starts_with(prefix)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// What a small Debian VPS answers. + const DEBIAN: &str = "\ +@@host +VM236A61A0D4D4D22 +@@arch +x86_64 +@@os +Debian GNU/Linux 12 (bookworm) +@@uptime +5359162.33 5293480.12 +@@cpu +model name\t: AMD EPYC-Milan Processor +@@stat +cpu 4705 356 584 3699176 2311 0 158 77 0 0 +cpu0 4705 356 584 3699176 2311 0 158 77 0 0 +@@memory +MemTotal: 996608 kB +MemFree: 120000 kB +MemAvailable: 565480 kB +Buffers: 40000 kB +Cached: 400000 kB +SwapTotal: 1048572 kB +SwapFree: 996144 kB +@@net +Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 12345 67 0 0 0 0 0 0 12345 67 0 0 0 0 0 0 + eth0: 987654321 1000 0 0 0 0 0 0 123456789 900 0 0 0 0 0 0 +docker0: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +@@route +Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 0101A8C0 0003 0 0 100 00000000 0 0 0 +wg0 00000000 00000000 0001 0 0 50 80000000 0 0 0 +eth0 0001A8C0 00000000 0001 0 0 100 00FFFFFF 0 0 0 +@@df +Filesystem 1024-blocks Used Available Capacity Mounted on +udev 483152 0 483152 0% /dev +tmpfs 99664 560 99104 1% /run +/dev/vda1 9656904 3240180 5994708 36% / +tmpfs 498304 0 498304 0% /dev/shm +/dev/vda15 126678 12068 114610 10% /boot/efi +/dev/loop0 65536 65536 0 100% /snap/core20/2105 +/dev/sdb1 20511312 102400 19343952 1% /mnt/My Data +"; + + #[test] + fn a_debian_host_reads_whole() { + let Parsed::Reading(reading) = parse(DEBIAN) else { + panic!("a Linux host is read"); + }; + assert_eq!( + reading.system, + Some(SystemInfo { + host_name: "VM236A61A0D4D4D22".into(), + arch: "x86_64".into(), + os: "Debian GNU/Linux 12 (bookworm)".into(), + cpu_model: "AMD EPYC-Milan Processor".into(), + }) + ); + assert_eq!(reading.uptime, Some(Duration::from_secs_f64(5359162.33))); + assert_eq!( + reading.cpu, + CpuTimes { + busy: 4705 + 356 + 584 + 158 + 77, + idle: 3699176 + 2311, + } + ); + assert_eq!(reading.cores.len(), 1); + assert_eq!(reading.memory.total, 996608 * 1024); + assert_eq!(reading.memory.used, (996608 - 565480) * 1024); + assert_eq!(reading.memory.swap_used, (1048572 - 996144) * 1024); + let names: Vec<_> = reading.interfaces.iter().map(|i| i.name.as_str()).collect(); + assert_eq!(names, ["lo", "eth0", "docker0"]); + assert_eq!(reading.interfaces[1].received, 987654321); + assert_eq!(reading.interfaces[1].transmitted, 123456789); + // wg0's route covers half the addresses only. + assert_eq!(reading.default_route.as_deref(), Some("eth0")); + let disks = reading.disks.expect("asked for"); + let mounts: Vec<_> = disks.iter().map(|d| d.mount.as_str()).collect(); + assert_eq!(mounts, ["/", "/boot/efi", "/mnt/My Data"]); + assert_eq!(disks[0].used, 3240180 * 1024); + } + + #[test] + fn an_old_kernel_without_mem_available_counts_free_buffers_and_cache() { + let memory = parse_memory(&[ + "MemTotal: 1000 kB", + "MemFree: 100 kB", + "Buffers: 50 kB", + "Cached: 250 kB", + ]); + assert_eq!(memory.used, 600 * 1024); + assert_eq!(memory.swap_total, 0); + } + + #[test] + fn a_host_without_proc_says_what_it_is() { + assert_eq!( + parse("@@unsupported\nDarwin\n"), + Parsed::Unsupported(Some("Darwin".into())) + ); + // cmd.exe has nothing to say to `sh -c`. + assert_eq!(parse(""), Parsed::Unsupported(None)); + } + + /// Every way a reading can be asked for. + fn commands() -> Vec<(Parts, String)> { + [(false, false), (false, true), (true, false), (true, true)] + .into_iter() + .map(|(system, disks)| { + let parts = Parts { system, disks }; + (parts, command(parts)) + }) + .collect() + } + + fn script(command: &str) -> &str { + command + .strip_prefix("sh -c '") + .and_then(|rest| rest.strip_suffix('\'')) + .expect("one single-quoted script") + } + + #[test] + fn the_commands_survive_any_login_shell() { + for (_, command) in commands() { + // Inside the single quotes there is nothing for the login shell. + let script = script(&command); + assert!(!script.contains('\''), "{command}"); + assert!(!script.contains('!'), "{command}"); + assert!(!script.contains('\n'), "{command}"); + } + } + + #[test] + fn a_reading_asks_only_for_its_parts() { + let light = command(Parts { + system: false, + disks: false, + }); + assert!(light.contains("@@stat")); + assert!(!light.contains("@@host")); + assert!(!light.contains("@@df")); + // The load starts no process of its own. + for program in ["cat ", "grep ", "head ", "uname -n", "df "] { + assert!(!light.contains(program), "{program} in {light}"); + } + let full = command(Parts { + system: true, + disks: true, + }); + assert!(full.contains("@@host") && full.contains("@@df")); + } + + /// The script run for real by `sh`, against `/proc` files laid out in a + /// directory of the test's own: the built-ins must read what `cat` and + /// `grep` would have. + #[cfg(unix)] + #[test] + fn the_script_reads_proc_with_shell_built_ins() { + let directory = tempfile::tempdir().unwrap(); + let proc = directory.path().join("proc"); + std::fs::create_dir_all(proc.join("net")).unwrap(); + let files = [ + ("uptime", "5359162.33 5293480.12\n"), + ( + "cpuinfo", + "processor\t: 0\nmodel name\t: AMD EPYC-Milan Processor\nflags\t: fpu\n", + ), + ( + "stat", + "cpu 4705 356 584 3699176 2311 0 158 77 0 0\n\ + cpu0 4705 356 584 3699176 2311 0 158 77 0 0\n\ + intr 123 4 5 6\nctxt 999\ncpu9 1 1 1 1 1 1 1 1 0 0\n", + ), + ( + "meminfo", + "MemTotal: 996608 kB\nMemFree: 120000 kB\n\ + MemAvailable: 565480 kB\nBuffers: 40000 kB\n\ + Cached: 400000 kB\nSwapCached: 0 kB\n\ + SwapTotal: 1048572 kB\nSwapFree: 996144 kB\n\ + MemTotal: 1 kB\n", + ), + ( + "net/route", + "Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask\n\ + eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000\n", + ), + ( + "net/dev", + "Inter-| Receive | Transmit\n face |bytes packets|bytes packets\n\ + \x20 lo: 10 1 0 0 0 0 0 0 10 1 0 0 0 0 0 0\n\ + \x20 eth0: 987654321 1000 0 0 0 0 0 0 123456789 900 0 0 0 0 0 0\n", + ), + ]; + for (name, content) in files { + std::fs::write(proc.join(name), content).unwrap(); + } + let full = command(Parts { + system: true, + disks: false, + }); + let script = script(&full).replace("/proc/", &format!("{}/", proc.display())); + let output = std::process::Command::new("sh") + .args(["-c", &script]) + .output() + .expect("sh runs"); + let output = String::from_utf8(output.stdout).unwrap(); + + let Parsed::Reading(reading) = parse(&output) else { + panic!("read as Linux: {output}"); + }; + let system = reading.system.expect("asked for"); + assert_eq!(system.cpu_model, "AMD EPYC-Milan Processor"); + assert_eq!(reading.uptime, Some(Duration::from_secs_f64(5359162.33))); + // The cpu lines at the top, and nothing after the first other line. + assert_eq!(reading.cores.len(), 1); + assert_eq!(reading.cpu.idle, 3699176 + 2311); + // Up to SwapFree, and no further. + assert_eq!(reading.memory.total, 996608 * 1024); + assert_eq!(reading.memory.used, (996608 - 565480) * 1024); + assert_eq!(reading.memory.swap_used, (1048572 - 996144) * 1024); + let eth0 = &reading.interfaces[1]; + assert_eq!( + (eth0.name.as_str(), eth0.received, eth0.transmitted), + ("eth0", 987654321, 123456789) + ); + assert_eq!(reading.default_route.as_deref(), Some("eth0")); + assert_eq!(reading.disks, None, "not asked for"); + } + + #[cfg(unix)] + #[test] + fn every_command_is_valid_sh() { + for (parts, command) in commands() { + let status = std::process::Command::new("sh") + .args(["-n", "-c", script(&command)]) + .status() + .expect("sh runs"); + assert!(status.success(), "{parts:?}: {command}"); + } + } +} diff --git a/src/monitor/mod.rs b/src/monitor/mod.rs new file mode 100644 index 0000000..d735b1f --- /dev/null +++ b/src/monitor/mod.rs @@ -0,0 +1,18 @@ +//! 系统监控: the right sidebar's tool that shows the CPU, memory, network +//! and disks of the host of the SSH terminal in front, read over that +//! terminal's own connection. Linux hosts only, for now. + +mod linux; +mod model; +mod monitor_panel; + +pub use monitor_panel::{MonitorPanel, MonitorTarget}; + +/// A part of the system monitor that is folded away until asked for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MonitorDetail { + /// Every core's load, under the average. + Cores, + /// Every network interface, not only the main one. + Interfaces, +} diff --git a/src/monitor/model.rs b/src/monitor/model.rs new file mode 100644 index 0000000..af5aa55 --- /dev/null +++ b/src/monitor/model.rs @@ -0,0 +1,459 @@ +//! What the monitor reads off a host, and the shares and rates it shows. + +use std::time::{Duration, Instant}; + +/// One look at a host: its counters at that moment, and the parts read +/// less often when this reading asked for them. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Reading { + /// Read once per connection. + pub system: Option, + pub uptime: Option, + /// CPU time since boot: the whole machine, then each core. + pub cpu: CpuTimes, + pub cores: Vec, + pub memory: Memory, + pub interfaces: Vec, + /// The interface the default route goes out of: the host's main one. + pub default_route: Option, + /// Read every half minute or so. + pub disks: Option>, +} + +/// What does not change while connected. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SystemInfo { + pub host_name: String, + pub arch: String, + /// The distribution's own name, 「Debian GNU/Linux 12 (bookworm)」. + pub os: String, + pub cpu_model: String, +} + +/// CPU time since boot, in the kernel's ticks. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CpuTimes { + pub busy: u64, + pub idle: u64, +} + +/// In bytes. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Memory { + pub total: u64, + pub used: u64, + pub swap_total: u64, + pub swap_used: u64, +} + +impl Memory { + pub fn percent(&self) -> f32 { + share(self.used, self.total) + } + + pub fn swap_percent(&self) -> f32 { + share(self.swap_used, self.swap_total) + } +} + +/// Bytes through a network interface since it came up. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InterfaceCounters { + pub name: String, + pub received: u64, + pub transmitted: u64, +} + +/// A mounted file system, in bytes. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DiskUsage { + pub mount: String, + pub total: u64, + pub used: u64, + pub available: u64, +} + +impl DiskUsage { + /// As `df` counts it: the share taken of the space ordinary users can + /// have, which leaves out what is reserved for root. + pub fn percent(&self) -> f32 { + share(self.used, self.used + self.available) + } +} + +fn share(part: u64, whole: u64) -> f32 { + if whole == 0 { + 0. + } else { + (part as f64 * 100. / whole as f64) as f32 + } +} + +/// The load the panel shows: a reading, and how it moved since the one +/// before. +#[derive(Clone, Debug, PartialEq)] +pub struct Snapshot { + pub uptime: Option, + pub cores: usize, + /// Percent busy since the reading before; `None` for the first. + pub cpu: Option, + pub memory: Memory, + pub interfaces: Vec, + pub default_route: Option, +} + +impl Snapshot { + /// The interfaces worth a row: not loopback, and not one that never + /// carried a byte. + pub fn busy_interfaces(&self) -> Vec<&InterfaceRate> { + self.interfaces + .iter() + .filter(|interface| !interface.idle) + .collect() + } + + /// The host's main interface, the one shown while the rest are folded + /// away: where the default route goes out, or else the one that carried + /// the most. + pub fn main_interface(&self) -> Option<&InterfaceRate> { + let busy = self.busy_interfaces(); + busy.iter() + .find(|interface| Some(&interface.name) == self.default_route.as_ref()) + .or_else(|| busy.iter().max_by_key(|interface| interface.bytes)) + .copied() + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CpuLoad { + pub total: f32, + pub cores: Vec, +} + +/// Bytes per second through a network interface since the reading before; +/// `None` for the first. +#[derive(Clone, Debug, PartialEq)] +pub struct InterfaceRate { + pub name: String, + pub received: Option, + pub transmitted: Option, + /// Bytes both ways since the interface came up. + pub bytes: u64, + /// Loopback, or never carried a byte: not worth a row. + pub idle: bool, +} + +/// Readings further apart than this are no base for rates: what they would +/// show is an average over minutes, not what the host is doing. +const STALE: Duration = Duration::from_secs(10); + +/// Turns readings into snapshots. Rates need the reading before, which is +/// what this keeps. +#[derive(Default)] +pub struct Tracker { + last: Option<(Reading, Instant)>, +} + +impl Tracker { + pub fn update(&mut self, reading: Reading, at: Instant) -> Snapshot { + let before = self.last.take().and_then(|(reading, then)| { + let elapsed = at.checked_duration_since(then)?; + (!elapsed.is_zero() && elapsed <= STALE).then_some((reading, elapsed)) + }); + let snapshot = snapshot(&reading, before.as_ref().map(|(r, e)| (r, *e))); + self.last = Some((reading, at)); + snapshot + } +} + +fn snapshot(reading: &Reading, before: Option<(&Reading, Duration)>) -> Snapshot { + let cpu = before.and_then(|(before, _)| { + let total = busy_percent(reading.cpu, before.cpu)?; + // Per core only when the cores are the same ones as before. + let cores = if reading.cores.len() == before.cores.len() { + reading + .cores + .iter() + .zip(&before.cores) + .map(|(now, then)| busy_percent(*now, *then).unwrap_or(0.)) + .collect() + } else { + Vec::new() + }; + Some(CpuLoad { total, cores }) + }); + let interfaces = reading + .interfaces + .iter() + .map(|interface| { + let then = before.and_then(|(before, elapsed)| { + let then = before + .interfaces + .iter() + .find(|then| then.name == interface.name)?; + Some((then, elapsed.as_secs_f64())) + }); + // A counter that went down was reset: the interface came up again. + let rate = |now: u64, then: u64, seconds: f64| { + now.checked_sub(then).map(|bytes| bytes as f64 / seconds) + }; + InterfaceRate { + name: interface.name.clone(), + received: then + .and_then(|(then, seconds)| rate(interface.received, then.received, seconds)), + transmitted: then.and_then(|(then, seconds)| { + rate(interface.transmitted, then.transmitted, seconds) + }), + bytes: interface.received + interface.transmitted, + idle: interface.name == "lo" + || (interface.received == 0 && interface.transmitted == 0), + } + }) + .collect(); + Snapshot { + uptime: reading.uptime, + cores: reading.cores.len(), + cpu, + memory: reading.memory, + interfaces, + default_route: reading.default_route.clone(), + } +} + +/// `None` when the counters went down, which they only do across a reboot. +fn busy_percent(now: CpuTimes, then: CpuTimes) -> Option { + let busy = now.busy.checked_sub(then.busy)?; + let idle = now.idle.checked_sub(then.idle)?; + let total = busy + idle; + (total > 0).then(|| (busy as f64 * 100. / total as f64) as f32) +} + +/// How hard a resource is worked, for its colour. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Load { + Normal, + High, + Critical, +} + +impl Load { + pub fn of(percent: f32) -> Self { + if percent >= 90. { + Load::Critical + } else if percent >= 70. { + Load::High + } else { + Load::Normal + } + } +} + +const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"]; + +/// 「421.02 MB」, in units of 1024. +pub fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + return format!("{bytes} B"); + } + let mut value = bytes as f64 / 1024.; + let mut unit = 0; + while value >= 1024. && unit + 1 < UNITS.len() { + value /= 1024.; + unit += 1; + } + format!("{value:.2} {}", UNITS[unit]) +} + +/// 「5.44 KB/s」. +pub fn format_rate(bytes_per_second: f64) -> String { + format!( + "{}/s", + format_bytes(bytes_per_second.max(0.).round() as u64) + ) +} + +/// 「43.3%」. +pub fn format_percent(percent: f32) -> String { + format!("{percent:.1}%") +} + +/// 「32 天 23:40:26」, or 「23:40:26」 within the first day. +pub fn format_uptime(uptime: Duration) -> String { + let seconds = uptime.as_secs(); + let days = seconds / 86_400; + let clock = format!( + "{:02}:{:02}:{:02}", + seconds / 3600 % 24, + seconds / 60 % 60, + seconds % 60 + ); + if days > 0 { + format!("{days} 天 {clock}") + } else { + clock + } +} + +/// How many of the per-core bars fit on a row `width` wide, at the +/// interface's `rem`: bars are 0.375 rem with 0.125 rem between. +pub fn bars_per_row(width: f32, rem: f32) -> usize { + let (bar, gap) = (0.375 * rem, 0.125 * rem); + // Layout rounds; half a pixel to spare keeps a bar that fits exactly. + (((width + gap + 0.5) / (bar + gap)).floor() as usize).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reading(cpu: (u64, u64), received: u64) -> Reading { + Reading { + cpu: CpuTimes { + busy: cpu.0, + idle: cpu.1, + }, + cores: vec![CpuTimes { + busy: cpu.0, + idle: cpu.1, + }], + interfaces: vec![ + InterfaceCounters { + name: "eth0".into(), + received, + transmitted: received / 2, + }, + InterfaceCounters { + name: "lo".into(), + received: 10, + transmitted: 10, + }, + ], + ..Reading::default() + } + } + + #[test] + fn the_first_reading_has_no_rates_and_the_next_one_does() { + let mut tracker = Tracker::default(); + let start = Instant::now(); + let first = tracker.update(reading((100, 900), 1_000), start); + assert_eq!(first.cpu, None); + assert_eq!(first.interfaces[0].received, None); + + // 25 busy ticks of 100, and 4 KB in 2 seconds. + let second = tracker.update( + reading((125, 975), 1_000 + 4096), + start + Duration::from_secs(2), + ); + let cpu = second.cpu.expect("a load against the first reading"); + assert_eq!(cpu.total, 25.); + assert_eq!(cpu.cores, vec![25.]); + assert_eq!(second.interfaces[0].received, Some(2048.)); + assert_eq!(second.interfaces[0].transmitted, Some(1024.)); + assert!(!second.interfaces[0].idle); + assert!(second.interfaces[1].idle, "loopback is never worth a row"); + } + + #[test] + fn the_main_interface_is_the_default_routes_or_else_the_busiest() { + let interface = |name: &str, bytes: u64| InterfaceRate { + name: name.into(), + received: None, + transmitted: None, + bytes, + idle: name == "lo" || bytes == 0, + }; + let mut snapshot = Snapshot { + uptime: None, + cores: 1, + cpu: None, + memory: Memory::default(), + interfaces: vec![ + interface("lo", 900), + interface("docker0", 5000), + interface("eth0", 3000), + interface("wg0", 0), + ], + default_route: Some("eth0".into()), + }; + let names = |snapshot: &Snapshot| { + snapshot + .busy_interfaces() + .iter() + .map(|interface| interface.name.clone()) + .collect::>() + }; + assert_eq!(names(&snapshot), ["docker0", "eth0"]); + assert_eq!(snapshot.main_interface().unwrap().name, "eth0"); + // No default route, or one through an interface not worth a row. + snapshot.default_route = Some("wg0".into()); + assert_eq!(snapshot.main_interface().unwrap().name, "docker0"); + snapshot.default_route = None; + assert_eq!(snapshot.main_interface().unwrap().name, "docker0"); + } + + #[test] + fn a_reading_long_after_the_last_starts_over() { + let mut tracker = Tracker::default(); + let start = Instant::now(); + tracker.update(reading((100, 900), 0), start); + let later = tracker.update(reading((200, 1800), 0), start + Duration::from_secs(60)); + assert_eq!(later.cpu, None); + } + + #[test] + fn counters_that_went_down_give_no_rate() { + let mut tracker = Tracker::default(); + let start = Instant::now(); + tracker.update(reading((500, 5000), 9_000), start); + let rebooted = tracker.update(reading((10, 90), 100), start + Duration::from_secs(2)); + assert_eq!(rebooted.cpu, None); + assert_eq!(rebooted.interfaces[0].received, None); + } + + #[test] + fn disks_count_like_df_without_the_reserved_space() { + let disk = DiskUsage { + mount: "/".into(), + total: 100, + used: 33, + available: 62, + }; + assert!((disk.percent() - 34.7).abs() < 0.1); + } + + #[test] + fn sizes_rates_and_uptimes_read_naturally() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(441_470_976), "421.02 MB"); + assert_eq!(format_bytes(9_889_644_544), "9.21 GB"); + assert_eq!(format_rate(5570.), "5.44 KB/s"); + assert_eq!(format_percent(43.27), "43.3%"); + assert_eq!( + format_uptime(Duration::from_secs(32 * 86_400 + 23 * 3600 + 40 * 60 + 26)), + "32 天 23:40:26" + ); + assert_eq!( + format_uptime(Duration::from_secs_f64(5. * 3600. + 12. * 60. + 9.7)), + "05:12:09" + ); + assert_eq!(format_uptime(Duration::from_secs(30)), "00:00:30"); + } + + #[test] + fn a_row_of_the_usual_width_holds_twenty_three_cores() { + // The column beside the gauge in the default 320 px sidebar. + assert_eq!(bars_per_row(184., 16.), 23); + assert_eq!(bars_per_row(183.9, 16.), 23); + assert_eq!(bars_per_row(400., 16.), 50); + assert_eq!(bars_per_row(0., 16.), 1); + } + + #[test] + fn load_goes_high_at_seventy_and_critical_at_ninety() { + assert_eq!(Load::of(69.9), Load::Normal); + assert_eq!(Load::of(70.), Load::High); + assert_eq!(Load::of(90.), Load::Critical); + } +} diff --git a/src/monitor/monitor_panel.rs b/src/monitor/monitor_panel.rs new file mode 100644 index 0000000..19daa45 --- /dev/null +++ b/src/monitor/monitor_panel.rs @@ -0,0 +1,838 @@ +use std::cell::Cell; +use std::collections::HashMap; +use std::rc::Rc; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use gpui_kit::component::{ + ActiveTheme as _, Icon, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + group_box::{GroupBox, GroupBoxVariants as _}, + h_flex, + progress::{Progress, ProgressCircle}, + scroll::ScrollableElement as _, + spinner::Spinner, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use super::MonitorDetail; +use super::linux::{self, Parsed, Parts}; +use super::model::{ + DiskUsage, InterfaceRate, Load, Memory, Snapshot, SystemInfo, Tracker, bars_per_row, + format_bytes, format_percent, format_rate, format_uptime, +}; +use crate::app::{CatalogIcon, ToggleMonitorDetail}; +use crate::terminal::{ExecResult, RemoteTerminalId, TerminalView}; + +/// Time between readings of the load. The second comes sooner: the CPU's +/// load and the network's rates need two, and the panel should not sit on +/// dashes. +const INTERVAL: Duration = Duration::from_secs(2); +const SECOND_READING: Duration = Duration::from_secs(1); +/// Disks are read this seldom: they fill slowly, and `df` is the slowest +/// part of a reading. +const DISK_INTERVAL: Duration = Duration::from_secs(30); +/// How often a reading on its way is looked for. The answer comes from the +/// terminal's transport thread, which must not wake the UI itself. +const POLL: Duration = Duration::from_millis(50); + +/// The terminal whose host the monitor reads, over that terminal's own +/// connection. +#[derive(Clone, PartialEq)] +pub struct MonitorTarget { + pub terminal: RemoteTerminalId, + pub view: WeakEntity, +} + +/// 系统监控: the CPU, memory, network and disks of the host of the SSH +/// terminal in front, read every two seconds while the panel shows. +/// +/// Each reading runs `linux::command` beside the shell, on the terminal's +/// own connection: no second login, nothing asked again. What does not +/// change while connected is asked once per connection, the disks every +/// half minute, so most readings start no process on the host but the `sh` +/// running them. Nothing runs on the host while the panel is hidden. What +/// each terminal's host showed last is kept, so coming back to a terminal +/// shows its numbers at once. +pub struct MonitorPanel { + target: Option, + active: bool, + hosts: HashMap, + /// Every core's load, not only the first row of it. + cores_unfolded: bool, + /// How wide the row of per-core bars was last frame, which decides how + /// many make the first row. + cores_width: Rc>>, + /// Every interface, not only the main one. + interfaces_unfolded: bool, + /// The workspace's focus handle: the fold buttons dispatch on it, so + /// they work whatever is focused. + dispatch: FocusHandle, + sampling: Option>, +} + +#[derive(Default)] +struct HostState { + tracker: Tracker, + snapshot: Option, + /// With the terminal's connection it was read on: a reconnect may land + /// on another machine. + system: Option<(u64, SystemInfo)>, + /// With when they were read. + disks: Option<(Instant, Vec)>, + problem: Option, +} + +impl HostState { + /// What the next reading on `connection` asks for besides the load. + fn parts_due(&self, connection: u64, now: Instant) -> Parts { + let system = self + .system + .as_ref() + .is_none_or(|(read_on, _)| *read_on != connection); + let disks = system + || self + .disks + .as_ref() + .is_none_or(|(at, _)| now.saturating_duration_since(*at) >= DISK_INTERVAL); + Parts { system, disks } + } +} + +#[derive(Clone, Debug, PartialEq)] +enum Problem { + /// The terminal is not connected, so there is no connection to read on. + NotConnected, + /// Not Linux; `uname -s` when the host said it. + Unsupported(Option), + /// The command did not run, or did not finish. + Failed(String), +} + +/// What one try at a reading came back with. +enum Outcome { + Output(ExecResult), + NotConnected, +} + +impl MonitorPanel { + pub fn new(dispatch: FocusHandle) -> Self { + Self { + target: None, + active: false, + hosts: HashMap::new(), + cores_unfolded: false, + cores_width: Rc::new(Cell::new(None)), + interfaces_unfolded: false, + dispatch, + sampling: None, + } + } + + /// Unfold a folded part, or fold it away again. Stays so for every + /// terminal's host. + pub fn toggle(&mut self, detail: MonitorDetail, cx: &mut Context) { + let unfolded = match detail { + MonitorDetail::Cores => &mut self.cores_unfolded, + MonitorDetail::Interfaces => &mut self.interfaces_unfolded, + }; + *unfolded = !*unfolded; + cx.notify(); + } + + /// Read `target`'s host while `active`, the panel showing it. + pub fn set_target( + &mut self, + target: Option, + active: bool, + cx: &mut Context, + ) { + if self.target == target && self.active == active { + return; + } + self.target = target; + self.active = active; + self.restart(cx); + cx.notify(); + } + + /// Start reading the target's host over, or stop when there is nothing + /// to show. Dropping the task stops it; a reading still on its way is + /// answered to no one. + fn restart(&mut self, cx: &mut Context) { + self.sampling = None; + let Some(target) = self.target.clone().filter(|_| self.active) else { + return; + }; + self.sampling = Some(cx.spawn(async move |this, cx| { + let mut first = true; + loop { + let request = this.update(cx, |this, cx| { + let view = target.view.upgrade()?; + let view = view.read(cx); + let connection = view.generation(cx); + let now = cx.background_executor().now(); + let parts = this.hosts.get(&target.terminal).map_or( + Parts { + system: true, + disks: true, + }, + |host| host.parts_due(connection, now), + ); + let reply = view.exec(linux::command(parts), cx)?; + Some((reply, connection)) + }); + let Ok(request) = request else { break }; + let (outcome, connection) = match request { + None => (Outcome::NotConnected, 0), + Some((reply, connection)) => (wait_for(reply, cx).await, connection), + }; + if this + .update(cx, |this, cx| { + this.record(target.terminal, connection, outcome, cx) + }) + .is_err() + { + break; + } + let pause = if first { SECOND_READING } else { INTERVAL }; + first = false; + cx.background_executor().timer(pause).await; + } + })); + } + + fn record( + &mut self, + terminal: RemoteTerminalId, + connection: u64, + outcome: Outcome, + cx: &mut Context, + ) { + let now = cx.background_executor().now(); + let host = self.hosts.entry(terminal).or_default(); + host.problem = match outcome { + Outcome::NotConnected => Some(Problem::NotConnected), + Outcome::Output(Err(error)) => Some(Problem::Failed(error)), + Outcome::Output(Ok(output)) => match linux::parse(&output) { + Parsed::Reading(mut reading) => { + if let Some(system) = reading.system.take() { + host.system = Some((connection, system)); + } + if let Some(disks) = reading.disks.take() { + host.disks = Some((now, disks)); + } + host.snapshot = Some(host.tracker.update(*reading, now)); + None + } + Parsed::Unsupported(system) => Some(Problem::Unsupported(system)), + }, + }; + cx.notify(); + } +} + +/// Look for the answer until it comes, or until the transport drops the +/// request: the terminal went away. +async fn wait_for(reply: mpsc::Receiver, cx: &mut AsyncApp) -> Outcome { + loop { + match reply.try_recv() { + Ok(result) => return Outcome::Output(result), + Err(mpsc::TryRecvError::Empty) => cx.background_executor().timer(POLL).await, + Err(mpsc::TryRecvError::Disconnected) => return Outcome::NotConnected, + } + } +} + +impl Render for MonitorPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let host = self + .target + .as_ref() + .and_then(|target| self.hosts.get(&target.terminal)); + let snapshot = host.and_then(|host| host.snapshot.as_ref()); + let system = host.and_then(|host| host.system.as_ref().map(|(_, system)| system)); + let disks = host.and_then(|host| host.disks.as_ref().map(|(_, disks)| disks.as_slice())); + let problem = host.and_then(|host| host.problem.clone()); + let interfaces_unfolded = self.interfaces_unfolded; + let cores = CoresLayout { + unfolded: self.cores_unfolded, + per_row: self + .cores_width + .get() + .map(|width| bars_per_row(f32::from(width), f32::from(window.rem_size()))), + width: self.cores_width.clone(), + panel: cx.entity_id(), + }; + let dispatch = &self.dispatch; + // The scrollbar goes on a layer beside the scrolled content, which + // `overflow_y_scrollbar` lays out: on the content itself it would + // scroll away with it, and stop matching where the content is. + let content = v_flex() + .size_full() + .p_3() + .gap_3() + .map(|panel| match (snapshot, problem) { + // Numbers from before a failed reading still say something; + // the failure goes above them. + (Some(snapshot), problem @ (None | Some(Problem::Failed(_)))) => panel + .when_some(problem, |panel, problem| { + panel.child(render_problem(&problem, cx)) + }) + .when_some(system, |panel, system| { + panel.child(render_system(system, snapshot.uptime, cx)) + }) + .child(render_cpu(system, snapshot, &cores, dispatch, cx)) + .child(render_memory(&snapshot.memory, cx)) + .child(render_network(snapshot, interfaces_unfolded, dispatch, cx)) + .when_some(disks, |panel, disks| panel.child(render_disks(disks, cx))), + (_, Some(problem)) => panel.child(render_problem(&problem, cx)), + (None, None) => panel.child( + h_flex() + .id("monitor-loading") + .test_support() + .gap_2() + .justify_center() + .py_8() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child("正在读取…"), + ), + }) + .overflow_y_scrollbar(); + div() + .id("monitor") + .test_support() + .size_full() + .child(content) + } +} + +fn render_problem(problem: &Problem, cx: &App) -> impl IntoElement { + let (text, color) = match problem { + Problem::NotConnected => ( + "终端没有连接。连接后这里显示主机的 CPU、内存、网络和磁盘。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Unsupported(Some(system)) => ( + format!("暂不支持 {system} 的监控,目前只支持 Linux 主机。"), + cx.theme().muted_foreground, + ), + Problem::Unsupported(None) => ( + "暂不支持这台主机的监控,目前只支持 Linux 主机。".to_string(), + cx.theme().muted_foreground, + ), + Problem::Failed(error) => (format!("读取失败:{error}"), cx.theme().danger), + }; + div() + .id("monitor-message") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(color) + .child(text) +} + +/// A card: its icon and title, with `meta` at the end of the title row. +fn card( + id: &'static str, + icon: impl Into, + title: &'static str, + meta: Option, + cx: &App, +) -> GroupBox { + GroupBox::new().id(id).fill().child( + h_flex() + .gap_2() + .child(icon.into().small().text_color(cx.theme().muted_foreground)) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(title), + ) + .children(meta), + ) +} + +/// A card's quiet note at the end of its title row: 「1 核」. +fn note(text: String, cx: &App) -> AnyElement { + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element() +} + +/// In place of the note, when the card folds something away: 「8 核 ⌄」. +fn unfold_button( + id: &'static str, + label: String, + detail: MonitorDetail, + unfolded: bool, + tooltip: &'static str, + dispatch: &FocusHandle, +) -> AnyElement { + let dispatch = dispatch.clone(); + Button::new(id) + .ghost() + .xsmall() + .label(label) + .child(Icon::new(if unfolded { + IconName::ChevronUp + } else { + IconName::ChevronDown + })) + .toggled(unfolded) + .tooltip(tooltip) + .on_click(move |_, window, cx| { + dispatch.dispatch_action(&ToggleMonitorDetail(detail), window, cx) + }) + .into_any_element() +} + +fn load_color(percent: f32, cx: &App) -> Hsla { + match Load::of(percent) { + Load::Normal => cx.theme().success, + Load::High => cx.theme().warning, + Load::Critical => cx.theme().danger, + } +} + +/// Numbers that change every reading: monospaced, so they hold still. +fn numeric(text: impl Into, cx: &App) -> Div { + div() + .font_family(cx.theme().mono_font_family.clone()) + .child(text.into()) +} + +fn render_system(system: &SystemInfo, uptime: Option, cx: &App) -> impl IntoElement { + let field = |id: &'static str, label: &'static str, value: String| { + v_flex() + .min_w_0() + .gap_0p5() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child( + div() + .id(id) + .test_support() + .aria_label(value.clone()) + .text_sm() + .truncate() + .child(value), + ) + }; + card("monitor-system", CatalogIcon::Monitor, "系统", None, cx).child( + div() + .grid() + .grid_cols(2) + .gap_x_4() + .gap_y_3() + .child(field("monitor-host", "主机", system.host_name.clone())) + .child(field("monitor-arch", "架构", system.arch.clone())) + .child(field("monitor-os", "系统", system.os.clone())) + .child(field( + "monitor-uptime", + "运行时长", + uptime.map(format_uptime).unwrap_or_default(), + )), + ) +} + +/// A ring with the share in its middle, as big as the eye needs to catch a +/// change across the room. +fn gauge(id: &'static str, percent: Option, cx: &App) -> impl IntoElement { + let color = load_color(percent.unwrap_or(0.), cx); + ProgressCircle::new(id) + .value(percent.unwrap_or(0.)) + .color(color) + .size_16() + .child( + v_flex() + .items_center() + .text_color(color) + .child( + numeric( + percent.map_or("—".into(), |p| format!("{}", p.round() as u32)), + cx, + ) + .text_lg() + .font_weight(FontWeight::SEMIBOLD), + ) + .child(div().text_xs().child("%")), + ) +} + +/// A labelled bar: the label and the share above it. +fn meter( + id: &'static str, + label: &'static str, + percent: Option, + cx: &App, +) -> impl IntoElement { + let color = load_color(percent.unwrap_or(0.), cx); + let text = percent.map_or("—".into(), format_percent); + v_flex() + .gap_1p5() + .child( + h_flex().justify_between().text_sm().child(label).child( + numeric(text.clone(), cx) + .id(id) + .test_support() + .aria_label(text) + .text_color(color) + .font_weight(FontWeight::SEMIBOLD), + ), + ) + .child( + Progress::new(SharedString::from(format!("{id}-bar"))) + .small() + .value(percent.unwrap_or(0.)) + .color(color), + ) +} + +/// How the per-core bars lay out this frame. +struct CoresLayout { + /// Every row, not only the first. + unfolded: bool, + /// How many bars make a row, from last frame's width; `None` until the + /// row has been laid out once. + per_row: Option, + /// Where the row records its width for the next frame. + width: Rc>>, + /// The panel to redraw when that width changes. + panel: EntityId, +} + +fn render_cpu( + system: Option<&SystemInfo>, + snapshot: &Snapshot, + layout: &CoresLayout, + dispatch: &FocusHandle, + cx: &App, +) -> impl IntoElement { + let model = system + .map(|system| system.cpu_model.clone()) + .filter(|model| !model.is_empty()); + let total = snapshot.cpu.as_ref().map(|load| load.total); + let cores = snapshot + .cpu + .as_ref() + .map(|load| load.cores.as_slice()) + .filter(|cores| cores.len() > 1); + // A host whose cores fill more than the one row shown can unfold the + // rest; most hosts fit, and have no button. + let more = layout + .per_row + .filter(|per_row| cores.is_some_and(|cores| cores.len() > *per_row)); + let count = format!("{} 核", snapshot.cores); + let meta = if more.is_some() { + unfold_button( + "monitor-cores-toggle", + count, + MonitorDetail::Cores, + layout.unfolded, + if layout.unfolded { + "收起更多核的占用" + } else { + "显示更多核的占用" + }, + dispatch, + ) + } else { + note(count, cx) + }; + let shown = cores.map(|cores| match more { + Some(per_row) if !layout.unfolded => &cores[..per_row], + _ => cores, + }); + card("monitor-cpu", IconName::Cpu, "CPU", Some(meta), cx) + .when_some(model, |card, model| { + card.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .truncate() + .child(model), + ) + }) + .child( + h_flex() + .gap_4() + .child(gauge("monitor-cpu-gauge", total, cx)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_3() + .child(meter("monitor-cpu-usage", "平均使用率", total, cx)) + .when_some(shown, |column, shown| { + column.child(render_cores(shown, layout, cx)) + }), + ), + ) +} + +/// The cores' loads as a column each, wrapping onto more rows the more +/// there are: 64 take three rows at the sidebar's usual width. Folded, only +/// the first row; it measures its width for the next frame to know how many +/// make one. Pointing at a bar tells its core's number and share. +fn render_cores(cores: &[f32], layout: &CoresLayout, cx: &App) -> impl IntoElement { + let label = cores + .iter() + .enumerate() + .map(|(index, load)| format!("核 {index}:{}", format_percent(*load))) + .collect::>() + .join(","); + let (width, panel) = (layout.width.clone(), layout.panel); + h_flex() + .id("monitor-cpu-cores") + .test_support() + .aria_label(label) + .relative() + .w_full() + .flex_wrap() + .gap_0p5() + // Before the first measurement, a row's height keeps the rest out. + .when(!layout.unfolded, |row| { + row.max_h(rems(1.5)).overflow_hidden() + }) + .children(cores.iter().enumerate().map(|(index, load)| { + let color = load_color(*load, cx); + let tooltip: SharedString = format!("核 {index}:{}", format_percent(*load)).into(); + div() + .id(("monitor-core", index as u64)) + .test_support() + .relative() + .w(rems(0.375)) + .h(rems(1.5)) + .rounded_sm() + .bg(color.opacity(0.2)) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .child( + div() + .absolute() + .bottom_0() + .left_0() + .w_full() + .h(relative(load.clamp(0., 100.) / 100.)) + .rounded_sm() + .bg(color), + ) + })) + .child( + canvas( + move |bounds, window, _| { + if width.get() != Some(bounds.size.width) { + width.set(Some(bounds.size.width)); + window.on_next_frame(move |_, cx| cx.notify(panel)); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} + +/// 「421.02 MB / 973.25 MB」, the total quieter. +fn amount(id: &'static str, used: u64, total: u64, cx: &App) -> impl IntoElement { + let (used, total) = (format_bytes(used), format_bytes(total)); + h_flex() + .id(id) + .test_support() + .aria_label(format!("{used} / {total}")) + .gap_1() + .text_xs() + .child(numeric(used, cx)) + .child(numeric(format!("/ {total}"), cx).text_color(cx.theme().muted_foreground)) +} + +fn render_memory(memory: &Memory, cx: &App) -> impl IntoElement { + card("monitor-memory", IconName::MemoryStick, "内存", None, cx).child( + h_flex() + .gap_4() + .child(gauge("monitor-memory-gauge", Some(memory.percent()), cx)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_3() + .child( + v_flex() + .gap_1p5() + .child(meter( + "monitor-memory-usage", + "物理内存", + Some(memory.percent()), + cx, + )) + .child(amount( + "monitor-memory-amount", + memory.used, + memory.total, + cx, + )), + ) + .when(memory.swap_total > 0, |column| { + column.child( + v_flex() + .gap_1p5() + .child(meter( + "monitor-swap-usage", + "交换空间", + Some(memory.swap_percent()), + cx, + )) + .child(amount( + "monitor-swap-amount", + memory.swap_used, + memory.swap_total, + cx, + )), + ) + }), + ), + ) +} + +fn render_network( + snapshot: &Snapshot, + unfolded: bool, + dispatch: &FocusHandle, + cx: &App, +) -> impl IntoElement { + let busy = snapshot.busy_interfaces(); + let meta = (busy.len() > 1).then(|| { + unfold_button( + "monitor-interfaces-toggle", + format!("{} 个网卡", busy.len()), + MonitorDetail::Interfaces, + unfolded, + if unfolded { + "只显示主网卡" + } else { + "显示全部网卡" + }, + dispatch, + ) + }); + let shown: Vec<&InterfaceRate> = if unfolded { + busy + } else { + snapshot.main_interface().into_iter().collect() + }; + let rate = |rate: Option| rate.map_or("—".into(), format_rate); + card("monitor-network", IconName::Network, "网络", meta, cx) + .when(shown.is_empty(), |card| { + card.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("没有在用的网卡"), + ) + }) + .children(shown.into_iter().map(|interface| { + let (up, down) = (rate(interface.transmitted), rate(interface.received)); + h_flex() + .id(SharedString::from(format!( + "monitor-interface:{}", + interface.name + ))) + .test_support() + .aria_label(format!("上传 {up},下载 {down}")) + .gap_3() + .text_sm() + .child( + numeric(interface.name.clone(), cx) + .flex_1() + .min_w_0() + .truncate(), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_3() + .text_xs() + .child(direction(IconName::ArrowUp, up, cx)) + .child(direction(IconName::ArrowDown, down, cx)), + ) + })) +} + +fn direction(icon: IconName, rate: String, cx: &App) -> impl IntoElement { + h_flex() + .gap_0p5() + .child( + Icon::new(icon) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .child(numeric(rate, cx)) +} + +fn render_disks(disks: &[DiskUsage], cx: &App) -> impl IntoElement { + card("monitor-disks", IconName::HardDrive, "磁盘", None, cx) + .when(disks.is_empty(), |card| { + card.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("没有读到磁盘"), + ) + }) + .children(disks.iter().map(|disk| { + let percent = disk.percent(); + let color = load_color(percent, cx); + let (used, total) = (format_bytes(disk.used), format_bytes(disk.total)); + v_flex() + .id(SharedString::from(format!("monitor-disk:{}", disk.mount))) + .test_support() + .aria_label(format!("{},{used} / {total}", format_percent(percent))) + .gap_1p5() + .child( + h_flex() + .gap_3() + .text_sm() + .child( + numeric(disk.mount.clone(), cx) + .flex_1() + .min_w_0() + .truncate(), + ) + .child( + numeric(format_percent(percent), cx) + .flex_shrink_0() + .text_color(color) + .font_weight(FontWeight::SEMIBOLD), + ), + ) + .child( + Progress::new(SharedString::from(format!( + "monitor-disk-bar:{}", + disk.mount + ))) + .small() + .value(percent) + .color(color), + ) + .child( + numeric(format!("{used} / {total}"), cx) + .text_xs() + .text_color(cx.theme().muted_foreground), + ) + })) +} diff --git a/src/ssh/transport.rs b/src/ssh/transport.rs index 405be17..56bec9c 100644 --- a/src/ssh/transport.rs +++ b/src/ssh/transport.rs @@ -7,7 +7,7 @@ use crate::{ connection::Latency, host::HostLogin, terminal::{ - RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, + ExecRequest, RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }, @@ -16,6 +16,7 @@ use anyhow::{Context as _, Result, anyhow, bail}; use async_channel::Sender; use russh::{ChannelMsg, client}; use std::{ + collections::VecDeque, future::Future, pin::Pin, sync::{ @@ -29,6 +30,12 @@ use std::{ use tokio::sync::{mpsc as tokio_mpsc, watch}; use tokio::time::MissedTickBehavior; const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a command run beside the shell may take. Generous: a slow disk +/// can hold `df` up for seconds. +const EXEC_TIMEOUT: Duration = Duration::from_secs(10); +/// The most output kept from a command run beside the shell; the rest is +/// dropped. +const EXEC_OUTPUT_LIMIT: usize = 1 << 20; /// Production remote-terminal adapter for the shared SSH connector. pub struct SshTerminalTransportProvider { @@ -190,9 +197,20 @@ impl SshTerminalTransport { latency_ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut ping: Option + Send + '_>>> = None; + // Commands for the right sidebar's tools, one at a time: a tool + // asking again before its last answer came back waits its turn + // rather than piling channels onto the server. + let mut exec_queue: VecDeque = VecDeque::new(); + let mut exec: Option + Send + '_>>> = None; + let mut exit_code = 0; let mut exit_signal = None; loop { + if exec.is_none() + && let Some(request) = exec_queue.pop_front() + { + exec = Some(Box::pin(run_exec(&handle, request))); + } tokio::select! { command = io_rx.recv() => match command { Some(TerminalTransportCommand::Write(bytes)) => { @@ -218,8 +236,12 @@ impl SshTerminalTransport { router.abort(); return Ok(()); } + Some(TerminalTransportCommand::Exec(request)) => exec_queue.push_back(request), Some(TerminalTransportCommand::PromptReply { .. }) => {} }, + () = async { exec.as_mut().expect("guarded").await }, if exec.is_some() => { + exec = None; + }, // Only armed while a probe channel is open, so a host that // never answers costs one idle channel and nothing else. message = async { probe_channel.as_mut().expect("guarded").wait().await }, @@ -289,6 +311,39 @@ fn pixel_dimension(cells: usize, cell_size: u16) -> u32 { .min(u32::MAX as usize) as u32 } +/// Run one command beside the shell and answer its request: what it printed +/// on standard output, or why it did not run. Its standard error is dropped. +async fn run_exec(handle: &SshHandle, request: ExecRequest) { + let ExecRequest { command, reply } = request; + let result = tokio::time::timeout(EXEC_TIMEOUT, async { + let mut channel = handle + .channel_open_session() + .await + .map_err(|_| "无法打开 SSH 通道".to_string())?; + channel + .exec(true, command) + .await + .map_err(|_| "无法发送命令".to_string())?; + let mut output = Vec::new(); + loop { + match channel.wait().await { + Some(ChannelMsg::Data { data }) => { + let room = EXEC_OUTPUT_LIMIT.saturating_sub(output.len()); + output.extend_from_slice(&data[..data.len().min(room)]); + } + Some(ChannelMsg::Failure) => return Err("服务器拒绝执行命令".to_string()), + Some(ChannelMsg::Eof | ChannelMsg::Close) | None => break, + _ => {} + } + } + Ok(String::from_utf8_lossy(&output).into_owned()) + }) + .await + .unwrap_or_else(|_| Err("命令超时".to_string())); + // The asker may have stopped waiting. + let _ = reply.send(result); +} + /// Open a channel and run one probe command on it. Best effort throughout: a /// server that refuses the channel or the command just leaves the host's /// recorded operating system as it was. @@ -651,6 +706,7 @@ mod tests { known_hosts, Arc::new(NoSecretStore), false, + &[], answer, ); } @@ -666,6 +722,7 @@ mod tests { known_hosts, secrets, false, + &[], answer, ) } @@ -682,6 +739,7 @@ mod tests { known_hosts, Arc::new(NoSecretStore), true, + &[], answer, ) } @@ -692,6 +750,8 @@ mod tests { prompts: Vec, host_os: Option, latency: Option, + /// The answers to the commands run beside the shell, in order. + execs: Vec, } /// Connect, answer whatever is asked, then shut down. The report says what @@ -701,6 +761,7 @@ mod tests { known_hosts: &Path, secrets: SharedSecretStore, wait_for_host_os: bool, + execs: &[&str], mut answer: impl FnMut(&ConnectionPromptKind) -> ConnectionPromptReply, ) -> ConnectionReport { let mut report = ConnectionReport::default(); @@ -760,6 +821,27 @@ mod tests { } } } + // All sent at once: the transport runs them one after another. + let replies: Vec<_> = execs + .iter() + .map(|command| { + let (reply, receiver) = mpsc::channel(); + command_tx + .send(TerminalTransportCommand::Exec(ExecRequest { + command: command.to_string(), + reply, + })) + .unwrap(); + receiver + }) + .collect(); + for receiver in replies { + report.execs.push( + receiver + .recv_timeout(EXEC_TIMEOUT) + .expect("a command beside the shell was never answered"), + ); + } command_tx.send(TerminalTransportCommand::Shutdown).unwrap(); done_rx .recv_timeout(SHUTDOWN_TIMEOUT + Duration::from_secs(1)) @@ -1549,6 +1631,60 @@ mod tests { ); } + /// A host whose shell answers the right sidebar's commands. + fn tool_reply(command: &str) -> Option<&'static str> { + match command { + "echo one" => Some("one\n"), + "echo two" => Some("two\n"), + _ => None, + } + } + + #[test] + fn commands_beside_the_shell_run_on_the_terminals_own_connection() { + let Some(server) = start_server_replying(TestAuth::Password, tool_reply) else { + eprintln!( + "loopback sockets are unavailable in this sandbox; skipping integration body" + ); + return; + }; + let directory = tempfile::tempdir().unwrap(); + let known_hosts = directory.path().join("known_hosts"); + + let report = connect( + HostLogin::of(&password_host(server.port), None), + &known_hosts, + Arc::new(NoSecretStore), + false, + &["echo one", "echo two"], + trust_and_type_password, + ); + + // Answered in order, each with its own output, and with no second + // login: the password was asked for once. + assert_eq!( + report.execs, + vec![Ok("one\n".to_string()), Ok("two\n".to_string())] + ); + let authentications = report + .prompts + .iter() + .filter(|kind| matches!(kind, ConnectionPromptKind::Authentication(_))) + .count(); + assert_eq!(authentications, 1); + // The operating-system probe runs alongside; these two came in order. + let execs: Vec = server + .state + .lock() + .unwrap_or_else(|error| error.into_inner()) + .execs + .iter() + .filter(|command| command.starts_with("echo")) + .cloned() + .collect(); + assert_eq!(execs, ["echo one", "echo two"]); + } + #[test] fn a_host_that_has_no_uname_is_asked_again_the_windows_way() { let Some(server) = start_server_replying(TestAuth::Password, windows_probe_reply) else { @@ -1638,6 +1774,7 @@ mod tests { &directory.path().join("key-known-hosts"), Arc::new(NoSecretStore), false, + &[], |prompt| match prompt { ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave, other => panic!("unexpected key-auth prompt: {other:?}"), diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 28fc254..248acb0 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -20,8 +20,8 @@ use crate::host::HostOs; use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch}; use super::{ - SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, TerminalStatus, - TerminalTransportCommand, TerminalTransportEvent, + ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalLifecycle, TerminalSize, + TerminalStatus, TerminalTransportCommand, TerminalTransportEvent, }; pub type AlacrittyTerm = Term; @@ -396,6 +396,22 @@ impl TerminalEngine { self.latency } + /// Counts the transports started: every reconnect is a new connection, + /// possibly to another machine. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Run `command` beside the shell, on the terminal's own connection; see + /// [`ExecRequest`]. `None` while the shell is not running, and so there + /// is no connection to run it on. + pub fn exec(&self, command: String) -> Option> { + if !matches!(self.lifecycle, TerminalLifecycle::Running) { + return None; + } + self.runtime.exec(command) + } + pub fn status(&self) -> TerminalStatus { TerminalStatus::new( self.lifecycle.clone(), @@ -607,6 +623,17 @@ impl TerminalRuntime { let _ = self.commands.send(TerminalTransportCommand::Shutdown); } + fn exec(&self, command: String) -> Option> { + let (reply, receiver) = mpsc::channel(); + self.commands + .send(TerminalTransportCommand::Exec(ExecRequest { + command, + reply, + })) + .ok()?; + Some(receiver) + } + fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply) { let _ = self .commands diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 41e9713..d5f47e0 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -28,7 +28,7 @@ pub use terminal_view::{ }; pub(crate) use transport::send_event; pub use transport::{ - FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider, + ExecRequest, ExecResult, FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider, SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }; diff --git a/src/terminal/terminal_view.rs b/src/terminal/terminal_view.rs index 1acb372..8aab81d 100644 --- a/src/terminal/terminal_view.rs +++ b/src/terminal/terminal_view.rs @@ -308,6 +308,21 @@ impl TerminalView { self.engine.read(cx).latency() } + /// Which connection the terminal is on: counts up with every reconnect. + pub fn generation(&self, cx: &App) -> u64 { + self.engine.read(cx).generation() + } + + /// Run `command` beside the shell, on this terminal's own connection; + /// see [`super::ExecRequest`]. `None` while the shell is not running. + pub fn exec( + &self, + command: String, + cx: &App, + ) -> Option> { + self.engine.read(cx).exec(command) + } + pub fn screen_text(&self, cx: &App) -> String { self.engine.read(cx).snapshot().visible_text() } diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index 001be7d..7e43cce 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -16,9 +16,27 @@ pub enum TerminalTransportCommand { request_id: u64, reply: ConnectionPromptReply, }, + /// Run a command beside the shell. Only a transport with a connection of + /// its own runs it; the others drop the request. + Exec(ExecRequest), Shutdown, } +/// What a command run beside the shell printed, or why it did not run. +pub type ExecResult = Result; + +/// A command to run beside the shell, on the terminal's own SSH connection: +/// how the right sidebar's tools read the host, with no login of their own. +/// The command gets no terminal and no input. +/// +/// Answered once on `reply` with the command's standard output, or with +/// why it could not run. A transport without a connection (a local PTY) +/// drops the request, which the asker sees as the reply channel closing. +pub struct ExecRequest { + pub command: String, + pub reply: mpsc::Sender, +} + impl std::fmt::Debug for TerminalTransportCommand { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -29,6 +47,10 @@ impl std::fmt::Debug for TerminalTransportCommand { .field("request_id", request_id) .field("reply", reply) .finish(), + Self::Exec(request) => formatter + .debug_tuple("Exec") + .field(&request.command.len()) + .finish(), Self::Shutdown => formatter.write_str("Shutdown"), } } diff --git a/src/workspace/recent_hosts.rs b/src/workspace/recent_hosts.rs index bc08ffd..95c0045 100644 --- a/src/workspace/recent_hosts.rs +++ b/src/workspace/recent_hosts.rs @@ -235,18 +235,11 @@ impl Render for RecentHosts { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let rows = self.rows(cx); - div() - .id("recent-hosts") - .test_support() - .track_focus(&self.focus_handle) - .key_context(RECENT_HOSTS_CONTEXT) - .on_action(cx.listener(Self::on_connect_selected)) + let page = div() + .id("recent-host-page") .size_full() .overflow_y_scroll() .track_scroll(&self.scroll_handle) - .vertical_scrollbar(&self.scroll_handle) - .bg(cx.theme().background) - .text_color(cx.theme().foreground) .flex() .flex_col() .items_center() @@ -264,6 +257,20 @@ impl Render for RecentHosts { page.child(self.render_list(rows, cx)) } }), - ) + ); + // The bar goes on this, which does not scroll, rather than on the + // page, which does: there it would scroll away with it. + div() + .id("recent-hosts") + .test_support() + .track_focus(&self.focus_handle) + .key_context(RECENT_HOSTS_CONTEXT) + .on_action(cx.listener(Self::on_connect_selected)) + .relative() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(page) + .vertical_scrollbar(&self.scroll_handle) } } diff --git a/src/workspace/tool_sidebar.rs b/src/workspace/tool_sidebar.rs index 71ce49f..a58c05d 100644 --- a/src/workspace/tool_sidebar.rs +++ b/src/workspace/tool_sidebar.rs @@ -8,8 +8,9 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{CatalogIcon, ToggleTool, ToolKind}; -use crate::host::{HostId, HostStore}; -use crate::terminal::{LocalTerminalId, RemoteTerminalId}; +use crate::host::{HostId, HostOs, HostStore}; +use crate::monitor::{MonitorDetail, MonitorPanel, MonitorTarget}; +use crate::terminal::{RemoteTerminalId, TerminalView}; impl ToolKind { pub fn label(self) -> &'static str { @@ -30,6 +31,15 @@ impl ToolKind { } } + /// Whether the tool works on a host running `os`. A host not identified + /// yet gets every tool, and is told if one cannot work there. + pub fn works_on(self, os: Option) -> bool { + match self { + ToolKind::Monitor => os.is_none_or(HostOs::is_linux), + ToolKind::Snippets | ToolKind::History | ToolKind::Docker => true, + } + } + /// The id of the tool's button in the switch. fn button_id(self) -> &'static str { match self { @@ -40,68 +50,63 @@ impl ToolKind { } } - /// What the tool is going to show for the terminal in front, said while - /// it is a placeholder. `machine` names the terminal's machine, spaced - /// for the Chinese after it: 「web-01 」 or 「本机」. - fn about(self, machine: &str) -> String { + /// What a tool still to come is going to show for the terminal in + /// front of `host`, said in its place. + fn about(self, host: &str) -> String { match self { ToolKind::Snippets => "常用的命令存在这里,点一下就发送到当前终端。".into(), - ToolKind::History => format!("{machine}上执行过的命令,可以搜索、再次执行。"), - ToolKind::Docker => format!("{machine}上的容器:状态、日志,启动和停止。"), - ToolKind::Monitor => format!("{machine}的 CPU、内存、网络和磁盘。"), + ToolKind::History => format!("{host} 上执行过的命令,可以搜索、再次执行。"), + ToolKind::Docker => format!("{host} 上的容器:状态、日志,启动和停止。"), + ToolKind::Monitor => format!("{host} 的 CPU、内存、网络和磁盘。"), } } } -/// The terminal the right sidebar works on: a host's over SSH, or a local -/// one on this machine. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ToolTerminal { - Remote(RemoteTerminalId, HostId), - Local(LocalTerminalId), -} - -impl ToolTerminal { - /// The sidebar's id, after the terminal's, the way the terminals' - /// own are `("terminal", id)` and `("local-terminal", id)`. - fn sidebar_id(self) -> ElementId { - match self { - ToolTerminal::Remote(id, _) => ("tool-sidebar", id.0).into(), - ToolTerminal::Local(id) => ("local-tool-sidebar", id.0).into(), - } - } +/// The SSH terminal the right sidebar works on. +#[derive(Clone, PartialEq)] +pub struct ToolTerminal { + pub id: RemoteTerminalId, + pub host: HostId, + /// Runs the tools' commands on the terminal's own connection. + pub view: WeakEntity, } /// The right dock's one panel: the tool the switch at the window's edge -/// picked, working on the terminal in front, remote or local. It is there -/// only while one is; the workspace hides it, switch and all, with any -/// other tab. +/// picked, working on the SSH terminal in front. It is there only while one +/// is; the workspace hides it, switch and all, with any other tab. /// /// One panel showing either tool, like the left dock's `Sidebar`, rather /// than a panel per tool swapped in the dock: swapping takes the displaced /// panel out of the dock and the focus with it, and two panels in one tab /// group always draw a tab bar. /// -/// The tools are placeholders for now. Each real one is to be a feature -/// module's entity held here, alive while hidden so it keeps its state, and -/// told which terminal is in front the way the placeholders are. +/// Each tool is a feature module's entity held here, alive while hidden so +/// it keeps its state, told which terminal is in front and whether it is on +/// screen; the tools still to come are placeholders. pub struct ToolSidebar { tool: ToolKind, /// The terminal in front; `None` while the sidebar is hidden for /// another kind of tab. terminal: Option, + /// Whether the right dock is open. + shown: bool, + monitor: Entity, store: Entity, focus_handle: FocusHandle, _subscription: Subscription, } impl ToolSidebar { - pub fn new(store: Entity, cx: &mut Context) -> Self { + /// `dispatch` is the workspace's focus handle, which the tools' buttons + /// dispatch on. + pub fn new(store: Entity, dispatch: FocusHandle, cx: &mut Context) -> Self { // A host renamed is named anew. let subscription = cx.observe(&store, |_, _, cx| cx.notify()); Self { tool: ToolKind::default(), terminal: None, + shown: false, + monitor: cx.new(|_| MonitorPanel::new(dispatch)), store, focus_handle: cx.focus_handle(), _subscription: subscription, @@ -112,14 +117,38 @@ impl ToolSidebar { self.tool } - /// Work on the terminal now in front. - pub fn set_terminal(&mut self, terminal: Option, cx: &mut Context) { - if self.terminal != terminal { + /// Work on the SSH terminal now in front, and say whether the dock is + /// open: a tool reads the host only while it is on screen. + pub fn set_terminal( + &mut self, + terminal: Option, + shown: bool, + cx: &mut Context, + ) { + if self.terminal != terminal || self.shown != shown { self.terminal = terminal; + self.shown = shown; + self.sync_tools(cx); cx.notify(); } } + pub fn toggle_monitor_detail(&mut self, detail: MonitorDetail, cx: &mut Context) { + self.monitor + .update(cx, |monitor, cx| monitor.toggle(detail, cx)); + } + + /// Point each tool at the terminal, running only the one on screen. + fn sync_tools(&mut self, cx: &mut Context) { + let target = self.terminal.as_ref().map(|terminal| MonitorTarget { + terminal: terminal.id, + view: terminal.view.clone(), + }); + let active = self.shown && self.tool == ToolKind::Monitor; + self.monitor + .update(cx, |monitor, cx| monitor.set_target(target, active, cx)); + } + /// Show another tool. A focus inside the one going away moves to the /// sidebar itself: left on an element no longer drawn, it would take /// every window-level shortcut's dispatch path with it. @@ -131,19 +160,14 @@ impl ToolSidebar { window.focus(&self.focus_handle, cx); } self.tool = tool; + self.sync_tools(cx); cx.notify(); } - fn render_placeholder(&self, terminal: ToolTerminal, cx: &App) -> impl IntoElement { - let machine = match terminal { - ToolTerminal::Remote(_, host) => { - let store = self.store.read(cx); - let name = store.host(host).map(|host| host.name.clone()); - format!("{} ", name.unwrap_or_default()) - } - ToolTerminal::Local(_) => "本机".into(), - }; - let about = self.tool.about(&machine); + fn render_placeholder(&self, host: HostId, cx: &App) -> impl IntoElement { + let store = self.store.read(cx); + let host = store.host(host).map(|host| host.name.clone()); + let about = self.tool.about(host.as_deref().unwrap_or_default()); v_flex() .id("tool-placeholder") .test_support() @@ -201,25 +225,29 @@ impl Panel for ToolSidebar { impl Render for ToolSidebar { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { // Named after the terminal it works on. - let id = self - .terminal - .map_or_else(|| "tool-sidebar".into(), ToolTerminal::sidebar_id); + let id = self.terminal.as_ref().map_or(0, |terminal| terminal.id.0); v_flex() - .id(id) + .id(("tool-sidebar", id)) .test_support() .aria_label(self.tool.label()) .track_focus(&self.focus_handle) .size_full() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) - .when_some(self.terminal, |sidebar, terminal| { - sidebar.child(self.render_placeholder(terminal, cx)) + .when_some(self.terminal.as_ref(), |sidebar, terminal| { + match self.tool { + ToolKind::Monitor => { + sidebar.child(div().flex_1().min_h_0().child(self.monitor.clone())) + } + _ => sidebar.child(self.render_placeholder(terminal.host, cx)), + } }) } } -/// The switch at the window's right edge: a button per tool, the one the -/// sidebar is showing marked, none while it is hidden. +/// The switch at the window's right edge: a button per tool `offered` for +/// the host in front, the one the sidebar is showing marked, none while it +/// is hidden. /// /// The workspace draws it beside the dock area rather than in the right /// dock, which goes off screen whole when it is hidden. The buttons do not @@ -227,6 +255,7 @@ impl Render for ToolSidebar { /// `target`, the workspace's focus handle, so they work whatever is focused. pub fn render_tool_switch( showing: Option, + offered: &[ToolKind], target: &FocusHandle, cx: &App, ) -> impl IntoElement { @@ -240,7 +269,7 @@ pub fn render_tool_switch( .bg(cx.theme().sidebar) .border_l_1() .border_color(cx.theme().sidebar_border) - .children(ToolKind::ALL.into_iter().map(|tool| { + .children(offered.iter().map(|&tool| { let target = target.clone(); Button::new(tool.button_id()) .ghost() diff --git a/src/workspace/tools.rs b/src/workspace/tools.rs index 9408503..81ef5e0 100644 --- a/src/workspace/tools.rs +++ b/src/workspace/tools.rs @@ -1,32 +1,53 @@ -//! The right sidebar's part of `Workspace`: the tools for the terminal in -//! front, and the switch that picks one. +//! The right sidebar's part of `Workspace`: the tools for the SSH terminal +//! in front, and the switch that picks one. //! -//! The sidebar goes with terminals, remote or local: with an SFTP tab, the -//! settings or the start page in front it is not there at all, switch +//! The sidebar goes with SSH terminals: with an SFTP tab, a local terminal, +//! the settings or the start page in front it is not there at all, switch //! included. Whether it is open and which tool it shows stay as they were, -//! so the next terminal brings it back the same. +//! so the next SSH terminal brings it back the same. A tool that cannot work +//! on the host in front (the monitor on a host known not to run Linux) is +//! not offered there: no button, and no sidebar while it is the one picked. use gpui_kit::component::dock::{DockArea, DockPlacement}; use gpui_kit::*; -use crate::app::{CenterTab, ToggleTool, ToggleToolSidebar, ToolKind}; +use crate::app::{CenterTab, ToggleMonitorDetail, ToggleTool, ToggleToolSidebar, ToolKind}; use super::{Workspace, tool_sidebar::ToolTerminal}; +/// How wide the right sidebar opens, and the narrowest it can be dragged: +/// the monitor's cards are laid out for this width. +pub(super) const TOOL_SIDEBAR_WIDTH: Pixels = px(320.); + impl Workspace { - /// The terminal in front, which the right sidebar works on. + /// The SSH terminal in front, which the right sidebar works on. pub(super) fn tool_terminal(&self, cx: &App) -> Option { - match self.active_tab? { - CenterTab::Terminal(id) => { - let host = self.terminals.get(&id)?.read(cx).host_id(); - Some(ToolTerminal::Remote(id, host)) - } - CenterTab::LocalTerminal(id) => self - .local_terminals - .contains_key(&id) - .then_some(ToolTerminal::Local(id)), - CenterTab::Explorer(_) | CenterTab::Settings => None, - } + let CenterTab::Terminal(id) = self.active_tab? else { + return None; + }; + let panel = self.terminals.get(&id)?.read(cx); + Some(ToolTerminal { + id, + host: panel.host_id(), + view: panel.terminal().downgrade(), + }) + } + + /// The tools the switch offers for the SSH terminal in front; none + /// without one. + pub(super) fn offered_tools(&self, cx: &App) -> Vec { + let Some(terminal) = self.tool_terminal(cx) else { + return Vec::new(); + }; + let os = self + .store + .read(cx) + .host(terminal.host) + .and_then(|host| host.os); + ToolKind::ALL + .into_iter() + .filter(|tool| tool.works_on(os)) + .collect() } /// The tool the right sidebar is showing, `None` while it is hidden. @@ -57,10 +78,10 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) { - if self.tool_terminal(cx).is_none() { + let tool = action.0; + if !self.offered_tools(cx).contains(&tool) { return; } - let tool = action.0; if self.tool_showing(cx) == Some(tool) { self.tool_sidebar_wanted = false; } else { @@ -71,27 +92,69 @@ impl Workspace { self.sync_tool_sidebar(window, cx); } - /// Without a terminal in front there is nothing to show or hide. + /// Undo a drag that took the right sidebar below its narrowest. Runs + /// whenever the dock area changes; the dock has no minimum of its own + /// to set, and its drag callback is not ours to wrap. Set back within + /// the same update, so no frame shows it narrower. + pub(super) fn hold_tool_sidebar_width(&mut self, window: &mut Window, cx: &mut Context) { + let narrower = self + .dock_area + .read(cx) + .dock_size(DockPlacement::Right) + .is_some_and(|width| width < TOOL_SIDEBAR_WIDTH); + if narrower { + self.dock_area.update(cx, |area, cx| { + area.set_dock_size(DockPlacement::Right, TOOL_SIDEBAR_WIDTH, window, cx); + }); + } + } + + /// The system monitor's fold buttons. + pub(super) fn on_toggle_monitor_detail( + &mut self, + action: &ToggleMonitorDetail, + _: &mut Window, + cx: &mut Context, + ) { + let detail = action.0; + self.tools + .update(cx, |tools, cx| tools.toggle_monitor_detail(detail, cx)); + } + + /// Hide the sidebar, or show the tool shown last; the first tool on + /// offer when that one does not work on this host. Without an SSH + /// terminal in front there is nothing to show or hide. pub(super) fn on_toggle_tool_sidebar( &mut self, _: &ToggleToolSidebar, window: &mut Window, cx: &mut Context, ) { - if self.tool_terminal(cx).is_none() { + let offered = self.offered_tools(cx); + let Some(&first) = offered.first() else { return; + }; + if self.dock_area.read(cx).is_dock_open(DockPlacement::Right) { + self.tool_sidebar_wanted = false; + } else { + if !offered.contains(&self.tools.read(cx).tool()) { + self.tools + .update(cx, |tools, cx| tools.show(first, window, cx)); + } + self.tool_sidebar_wanted = true; } - self.tool_sidebar_wanted = !self.tool_sidebar_wanted; self.sync_tool_sidebar(window, cx); } - /// Point the sidebar at the terminal in front, and open it if one is and - /// it is wanted, close it otherwise. - fn sync_tool_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + /// Point the sidebar at the SSH terminal in front, and open it if one is, + /// its tool works on the host and the sidebar is wanted; close it + /// otherwise. Also when the host turns out to run something else. + pub(super) fn sync_tool_sidebar(&mut self, window: &mut Window, cx: &mut Context) { let terminal = self.tool_terminal(cx); + let offered = self.offered_tools(cx).contains(&self.tools.read(cx).tool()); + let open = self.tool_sidebar_wanted && terminal.is_some() && offered; self.tools - .update(cx, |tools, cx| tools.set_terminal(terminal, cx)); - let open = self.tool_sidebar_wanted && terminal.is_some(); + .update(cx, |tools, cx| tools.set_terminal(terminal, open, cx)); if self.dock_area.read(cx).is_dock_open(DockPlacement::Right) != open { // A focus inside the sidebar would go off screen with it. let focus_inside = self diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index c0c5362..58f27c9 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -69,7 +69,7 @@ use super::{ status_bar::WorkspaceStatus, title_bar::render_title_bar, tool_sidebar::{ToolSidebar, render_tool_switch}, - tools::set_right_dock_open, + tools::{TOOL_SIDEBAR_WIDTH, set_right_dock_open}, }; const DOCK_ID: &str = "shellrs-dock"; @@ -173,8 +173,8 @@ pub struct Workspace { /// Changed through `set_active_tab`, which shows or hides the right /// sidebar with it. pub(super) active_tab: Option, - /// Whether the right sidebar is to show while a terminal is in front. - /// It hides with any other tab and comes back with the next terminal. + /// Whether the right sidebar is to show while an SSH terminal is in + /// front. It hides with any other tab and comes back with the next one. pub(super) tool_sidebar_wanted: bool, pub(super) prompt_queue: VecDeque<(PromptOwner, HostId, ConnectionPrompt)>, pub(super) active_prompt: Option<(PromptOwner, HostId, u64)>, @@ -330,7 +330,7 @@ impl Workspace { let credential_panel = cx.new(|cx| CredentialPanel::new(store.clone(), focus_handle.clone(), window, cx)); let sidebar = cx.new(|_| Sidebar::new(host_panel.clone(), forward_panel, credential_panel)); - let tools = cx.new(|cx| ToolSidebar::new(store.clone(), cx)); + let tools = cx.new(|cx| ToolSidebar::new(store.clone(), focus_handle.clone(), cx)); // Start with focus in the host panel so window-level actions have a // dispatch path. The workspace's own handle is never focused: the // dialog layer is its child, and a focused ancestor would keep the @@ -341,8 +341,14 @@ impl Workspace { crate::settings::apply(settings.read(cx).settings(), window, cx); let mut subscriptions = vec![ - cx.observe(&store, |this, _, cx| { + // The right sidebar is never dragged narrower than it opens. + cx.observe_in(&dock_area, window, |this, _, window, cx| { + this.hold_tool_sidebar_width(window, cx) + }), + cx.observe_in(&store, window, |this, _, window, cx| { this.sync_cli_server(cx); + // The system a host runs decides which tools it is offered. + this.sync_tool_sidebar(window, cx); cx.notify() }), cx.subscribe_in( @@ -461,7 +467,7 @@ impl Workspace { window, cx, ); - area.set_dock_size(DockPlacement::Right, px(320.), window, cx); + area.set_dock_size(DockPlacement::Right, TOOL_SIDEBAR_WIDTH, window, cx); // Hidden until a tool is picked; see `set_right_dock_open`. set_right_dock_open(area, false, window, cx); area.set_dock_collapsible(DockPlacement::Right, false, window, cx); @@ -2167,7 +2173,7 @@ impl Render for Workspace { let sidebar = self.sidebar_showing(cx); let running_forwards = self.forwards.read(cx).active_count(); let tool = self.tool_showing(cx); - let terminal_in_front = self.tool_terminal(cx).is_some(); + let offered_tools = self.offered_tools(cx); div() .id("workspace") @@ -2228,6 +2234,7 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_toggle_host_panel)) .on_action(cx.listener(Self::on_toggle_tool_sidebar)) .on_action(cx.listener(Self::on_toggle_tool)) + .on_action(cx.listener(Self::on_toggle_monitor_detail)) .on_action(cx.listener(Self::on_show_hosts)) .on_action(cx.listener(Self::on_show_forwards)) .on_action(cx.listener(Self::on_new_forward)) @@ -2270,9 +2277,14 @@ impl Render for Workspace { .h_full() .child(self.dock_area.clone()), ) - // The switch comes and goes with the terminals. - .when(terminal_in_front, |area| { - area.child(render_tool_switch(tool, &self.focus_handle, cx)) + // The switch comes and goes with the SSH terminals. + .when(!offered_tools.is_empty(), |area| { + area.child(render_tool_switch( + tool, + &offered_tools, + &self.focus_handle, + cx, + )) }), ) .child(status) diff --git a/tests/ui/main.rs b/tests/ui/main.rs index 7b41318..d1d256b 100644 --- a/tests/ui/main.rs +++ b/tests/ui/main.rs @@ -12,6 +12,7 @@ mod credential; mod forward; mod host_dialog; mod host_tree; +mod monitor; mod settings; mod sftp; mod terminal; diff --git a/tests/ui/monitor.rs b/tests/ui/monitor.rs new file mode 100644 index 0000000..bbc9880 --- /dev/null +++ b/tests/ui/monitor.rs @@ -0,0 +1,390 @@ +//! 系统监控: the right sidebar's tool that reads the SSH terminal's host +//! over the terminal's own connection. + +use crate::support::*; + +/// A two-core Debian host, then the same host a moment later: 100 more busy +/// ticks of 200, and some traffic on eth0. +const FIRST: &str = "\ +@@host +web-01-vm +@@arch +x86_64 +@@os +Debian GNU/Linux 12 (bookworm) +@@uptime +5359162.33 1.00 +@@cpu +model name\t: AMD EPYC-Milan Processor +@@stat +cpu 100 0 100 800 0 0 0 0 +cpu0 50 0 50 400 0 0 0 0 +cpu1 50 0 50 400 0 0 0 0 +@@memory +MemTotal: 996608 kB +MemAvailable: 565480 kB +SwapTotal: 0 kB +SwapFree: 0 kB +@@net + lo: 10 0 0 0 0 0 0 0 10 0 0 0 0 0 0 0 + eth0: 1000 0 0 0 0 0 0 0 500 0 0 0 0 0 0 0 +docker0: 50000 0 0 0 0 0 0 0 50000 0 0 0 0 0 0 0 +@@route +Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask +eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000 +@@df +Filesystem 1024-blocks Used Available Capacity Mounted on +/dev/vda1 9656904 3240180 5994708 36% / +tmpfs 99664 560 99104 1% /run +"; + +const SECOND: &str = "\ +@@host +web-01-vm +@@arch +x86_64 +@@os +Debian GNU/Linux 12 (bookworm) +@@uptime +5359164.33 1.00 +@@cpu +model name\t: AMD EPYC-Milan Processor +@@stat +cpu 150 0 150 900 0 0 0 0 +cpu0 100 0 50 400 0 0 0 0 +cpu1 50 0 100 500 0 0 0 0 +@@memory +MemTotal: 996608 kB +MemAvailable: 565480 kB +SwapTotal: 0 kB +SwapFree: 0 kB +@@net + lo: 10 0 0 0 0 0 0 0 10 0 0 0 0 0 0 0 + eth0: 9192 0 0 0 0 0 0 0 4596 0 0 0 0 0 0 0 +docker0: 60000 0 0 0 0 0 0 0 60000 0 0 0 0 0 0 0 +@@route +Iface\tDestination\tGateway\tFlags\tRefCnt\tUse\tMetric\tMask +eth0\t00000000\t0101A8C0\t0003\t0\t0\t100\t00000000 +@@df +Filesystem 1024-blocks Used Available Capacity Mounted on +/dev/vda1 9656904 3240180 5994708 36% / +tmpfs 99664 560 99104 1% /run +"; + +/// The seeded workspace with web-01's terminal in front and the monitor +/// open; every terminal's commands are answered by `factory`. +fn open_monitor(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-monitor", cx)); + handle +} + +async fn wait_for_label( + cx: &mut TestAppContext, + handle: WindowHandle, + id: &'static str, + label: &str, +) { + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window + .try_find(id) + .is_some_and(|element| element.label() == Some(label)) + }) + .await; +} + +#[gpui_kit::test] +async fn the_monitor_shows_the_hosts_cpu_memory_network_and_disks(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST, SECOND])); + let handle = open_monitor(cx, factory.clone()); + + // The load is the second reading's against the first. + wait_for_label(cx, handle, "monitor-cpu-usage", "50.0%").await; + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("monitor-host").label(), Some("web-01-vm")); + assert_eq!(window.find("monitor-arch").label(), Some("x86_64")); + assert_eq!( + window.find("monitor-os").label(), + Some("Debian GNU/Linux 12 (bookworm)") + ); + // The second reading's uptime: 62 days and 2364 seconds. + assert_eq!( + window.find("monitor-uptime").label(), + Some("62 天 00:39:24") + ); + // Two cores fit the row shown at once: nothing to unfold. + assert_eq!( + window.find("monitor-cpu-cores").label(), + Some("核 0:100.0%,核 1:33.3%") + ); + assert!(window.try_find("monitor-cores-toggle").is_none()); + assert_eq!( + window.find("monitor-memory-amount").label(), + Some("421.02 MB / 973.25 MB") + ); + // No swap, no swap row. + assert!(window.try_find("monitor-swap-usage").is_none()); + // The main interface only, though docker0 carried more: the + // default route goes out of eth0. + let eth0 = window.find("monitor-interface:eth0"); + assert!( + eth0.label() + .is_some_and(|label| label.starts_with("上传 ") && !label.contains('—')), + "{eth0:?}" + ); + assert!(window.try_find("monitor-interface:docker0").is_none()); + assert_eq!( + window.find("monitor-interfaces-toggle").checked(), + Some(false) + ); + // Loopback is not worth a row, nor is a memory file system a disk. + assert!(window.try_find("monitor-interface:lo").is_none()); + assert_eq!( + window.find("monitor-disk:/").label(), + Some("35.1%,3.09 GB / 9.21 GB") + ); + assert!(window.try_find("monitor-disk:/run").is_none()); + }); + + // Unfolded, every interface; and folded away again. + in_frame(cx, handle, |window, cx| { + window.click("monitor-interfaces-toggle", cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("monitor-interfaces-toggle").checked(), + Some(true) + ); + assert!(window.try_find("monitor-interface:eth0").is_some()); + assert!(window.try_find("monitor-interface:docker0").is_some()); + }); + in_frame(cx, handle, |window, cx| { + window.click("monitor-interfaces-toggle", cx) + }); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("monitor-interface:docker0").is_none()); + }); + + // The first reading asked for everything; the next one for the load + // only, which starts no process on the host but its `sh`. + let commands = factory.exec_commands(); + assert!(commands[0].contains("@@host") && commands[0].contains("@@df")); + assert!(!commands[1].contains("@@host") && !commands[1].contains("@@df")); +} + +#[gpui_kit::test] +async fn the_monitor_reads_the_host_only_while_it_shows(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&[FIRST])); + let handle = open_monitor(cx, factory.clone()); + wait_for_label(cx, handle, "monitor-host", "web-01-vm").await; + + // Hidden: nothing more runs on the host. + in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + let hidden = factory.exec_count(); + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), hidden); + + // Back on screen, it reads again, and shows what it had meanwhile. + in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("monitor-host").label(), Some("web-01-vm")); + }); + cx.wait_for(handle.into(), Duration::from_secs(5), |_, _| { + factory.exec_count() > hidden + }) + .await; + + // Nor while another tool is showing. + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + let other = factory.exec_count(); + cx.executor().advance_clock(Duration::from_secs(10)); + cx.run_until_parked(); + assert_eq!(factory.exec_count(), other); +} + +#[gpui_kit::test] +async fn the_monitor_says_when_the_terminal_is_not_connected(cx: &mut TestAppContext) { + // web-01's terminal is the first started, and exits at once. + let handle = open_monitor(cx, Arc::new(FakeTerminalFactory::exit_first())); + wait_for_label( + cx, + handle, + "monitor-message", + "终端没有连接。连接后这里显示主机的 CPU、内存、网络和磁盘。", + ) + .await; +} + +#[gpui_kit::test] +async fn the_monitor_says_it_reads_linux_hosts_only(cx: &mut TestAppContext) { + let factory = Arc::new(FakeTerminalFactory::answering(&["@@unsupported\nDarwin\n"])); + let handle = open_monitor(cx, factory); + wait_for_label( + cx, + handle, + "monitor-message", + "暂不支持 Darwin 的监控,目前只支持 Linux 主机。", + ) + .await; +} + +#[gpui_kit::test] +fn the_monitor_is_not_offered_on_a_host_known_not_to_run_linux(cx: &mut TestAppContext) { + let mut store = HostStore::seed(); + store.set_host_os_unnotified(HostId(WEB_01), Some(HostOs::Debian)); + store.set_host_os_unnotified(HostId(STAGING_API), Some(HostOs::MacOs)); + let (handle, _) = open_workspace_with_store(cx, store); + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find(("tool-sidebar", INITIAL_WEB_TERMINAL)).label(), + Some("系统监控") + ); + }); + + // On the Mac the monitor has no button, and the sidebar showing it + // goes; the other tools stay on offer. + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_STAGING_TERMINAL), cx) + }); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("tool-monitor").is_none()); + assert!(window.find("tool-history").visible()); + assert!( + window + .try_find(("tool-sidebar", INITIAL_STAGING_TERMINAL)) + .is_none() + ); + }); + + // Back on the Linux host it is as it was. + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!(window.find("tool-monitor").checked(), Some(true)); + assert!( + window + .try_find(("tool-sidebar", INITIAL_WEB_TERMINAL)) + .is_some() + ); + }); + + // The shortcut on the Mac shows the first tool it has instead. + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_STAGING_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(ToggleToolSidebar), cx) + }); + in_frame(cx, handle, |window, _| { + assert_eq!( + window + .find(("tool-sidebar", INITIAL_STAGING_TERMINAL)) + .label(), + Some("命令片段") + ); + }); +} + +#[gpui_kit::test] +async fn the_monitor_goes_once_the_host_turns_out_not_to_run_linux(cx: &mut TestAppContext) { + // Every terminal reports a Mac as soon as it connects. + let (handle, workspace) = open_workspace_with_remote_factory( + cx, + HostStore::seed(), + Arc::new(FakeTerminalFactory::reports_os(HostOs::MacOs)), + ); + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, _| { + window.try_find("tool-monitor").is_none() + }) + .await; + in_frame(cx, handle, |window, _| { + assert!(window.find("tool-snippets").visible()); + }); + let os = workspace.read_with(cx, |workspace, cx| { + workspace + .store() + .read(cx) + .host(HostId(WEB_01)) + .and_then(|host| host.os) + }); + assert_eq!(os, Some(HostOs::MacOs)); +} + +/// A host with `cores` cores, every one a little busier than the last. +fn many_cores(cores: u64, busy: u64) -> String { + let lines: String = (0..cores) + .map(|core| format!("cpu{core} {} 0 0 1000 0 0 0 0\n", busy * core)) + .collect(); + FIRST.replace( + "cpu 100 0 100 800 0 0 0 0\ncpu0 50 0 50 400 0 0 0 0\ncpu1 50 0 50 400 0 0 0 0\n", + &format!( + "cpu {} 0 0 {} 0 0 0 0\n{lines}", + busy * cores * cores / 2, + 1000 * cores + ), + ) +} + +#[gpui_kit::test] +async fn sixty_four_cores_show_one_row_until_unfolded(cx: &mut TestAppContext) { + let (first, second) = (many_cores(64, 1), many_cores(64, 2)); + let factory = Arc::new(FakeTerminalFactory::answering(&[&first, &second])); + let handle = open_monitor(cx, factory); + // A reading against the one before, so there are loads to draw. + cx.wait_for(handle.into(), Duration::from_secs(5), |window, _| { + window + .try_find("monitor-cpu-usage") + .is_some_and(|usage| usage.label() != Some("—")) + }) + .await; + // The first frame measures the row; the next one fills it. + in_frame(cx, handle, |window, cx| window.simulate_next_frame(cx)); + let bars = |cx: &mut TestAppContext| { + in_frame(cx, handle, |window, _| { + (0..64_u64) + .filter_map(|core| window.try_find(("monitor-core", core))) + .map(|bar| bar.bounds()) + .collect::>() + }) + }; + let rows = |bars: &[gpui_kit::Bounds]| { + let mut tops: Vec<_> = bars.iter().map(|bar| bar.top()).collect(); + tops.dedup(); + tops.len() + }; + + // One row at first: as many as fit beside the gauge. + let first_row = bars(cx); + assert_eq!(first_row.len(), 23); + assert_eq!(rows(&first_row), 1); + in_frame(cx, handle, |window, _| { + let toggle = window.find("monitor-cores-toggle"); + assert_eq!(toggle.label(), Some("64 核")); + assert_eq!(toggle.checked(), Some(false)); + }); + + // Unfolded, all of them, wrapping onto three rows inside the column; + // the share sits at the column's right edge. + in_frame(cx, handle, |window, cx| { + window.click("monitor-cores-toggle", cx) + }); + let all = bars(cx); + assert_eq!(all.len(), 64); + assert_eq!(rows(&all), 3); + in_frame(cx, handle, |window, _| { + let column_right = window.find("monitor-cpu-usage").bounds().right(); + assert!(all.iter().all(|bar| bar.right() <= column_right)); + }); +} diff --git a/tests/ui/support/terminal.rs b/tests/ui/support/terminal.rs index 11d9068..fa7e8f5 100644 --- a/tests/ui/support/terminal.rs +++ b/tests/ui/support/terminal.rs @@ -18,9 +18,35 @@ pub struct FakeTerminalFactory { pub behavior: FakeBehavior, pub writes: Arc>>>, pub resizes: Arc>>, + /// What the commands run beside the shell print, one after another; the + /// last one repeats. With none the requests are dropped, the way a + /// local PTY drops them. + pub exec_outputs: Arc>>, + /// The commands run beside the shell, in order. + pub exec_commands: Arc>>, } impl FakeTerminalFactory { + pub fn answering(outputs: &[&str]) -> Self { + Self { + exec_outputs: Arc::new(Mutex::new( + outputs.iter().map(|output| output.to_string()).collect(), + )), + ..Self::default() + } + } + + pub fn exec_commands(&self) -> Vec { + self.exec_commands + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + } + + pub fn exec_count(&self) -> usize { + self.exec_commands().len() + } + pub fn exit_first() -> Self { Self { behavior: FakeBehavior::ExitFirst, @@ -74,6 +100,8 @@ impl TerminalTransportFactory for FakeTerminalFactory { behavior: self.behavior, writes: self.writes.clone(), resizes: self.resizes.clone(), + exec_outputs: self.exec_outputs.clone(), + exec_commands: self.exec_commands.clone(), }) } } @@ -83,6 +111,8 @@ pub struct FakeTerminalTransport { pub behavior: FakeBehavior, pub writes: Arc>>>, pub resizes: Arc>>, + pub exec_outputs: Arc>>, + pub exec_commands: Arc>>, } impl TerminalTransport for FakeTerminalTransport { @@ -131,6 +161,24 @@ impl TerminalTransport for FakeTerminalTransport { .unwrap_or_else(|error| error.into_inner()) .push(size); } + TerminalTransportCommand::Exec(request) => { + self.exec_commands + .lock() + .unwrap_or_else(|error| error.into_inner()) + .push(request.command.clone()); + let mut outputs = self + .exec_outputs + .lock() + .unwrap_or_else(|error| error.into_inner()); + let output = if outputs.len() > 1 { + outputs.pop_front() + } else { + outputs.front().cloned() + }; + if let Some(output) = output { + let _ = request.reply.send(Ok(output)); + } + } TerminalTransportCommand::PromptReply { .. } => {} TerminalTransportCommand::Shutdown => break, } diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index 56780bd..f01a922 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -757,17 +757,17 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) { in_frame(cx, handle, |window, _| { // Hidden at first: the switch is there, none of it pressed. assert!(window.try_find(sidebar).is_none()); - assert_eq!(window.find("tool-monitor").checked(), Some(false)); + assert_eq!(window.find("tool-history").checked(), Some(false)); }); - in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); in_frame(cx, handle, |window, _| { - assert_eq!(window.find(sidebar).label(), Some("系统监控")); + assert_eq!(window.find(sidebar).label(), Some("历史命令")); assert_eq!( window.find("tool-placeholder").label(), - Some("web-01 的 CPU、内存、网络和磁盘。") + Some("web-01 上执行过的命令,可以搜索、再次执行。") ); - assert_eq!(window.find("tool-monitor").checked(), Some(true)); + assert_eq!(window.find("tool-history").checked(), Some(true)); // Between the center and the switch. let bounds = window.find(sidebar).bounds(); let switch = window.find("tool-switch").bounds(); @@ -778,18 +778,18 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) { }); // Another tool takes its place. - in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-docker", cx)); in_frame(cx, handle, |window, _| { - assert_eq!(window.find(sidebar).label(), Some("历史命令")); - assert_eq!(window.find("tool-monitor").checked(), Some(false)); - assert_eq!(window.find("tool-history").checked(), Some(true)); + assert_eq!(window.find(sidebar).label(), Some("Docker")); + assert_eq!(window.find("tool-history").checked(), Some(false)); + assert_eq!(window.find("tool-docker").checked(), Some(true)); }); // The tool showing hides the sidebar. - in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-docker", cx)); in_frame(cx, handle, |window, _| { assert!(window.try_find(sidebar).is_none()); - assert_eq!(window.find("tool-history").checked(), Some(false)); + assert_eq!(window.find("tool-docker").checked(), Some(false)); }); // The shortcut brings back the tool shown last. @@ -797,7 +797,7 @@ fn the_tool_switch_shows_a_tool_and_hides_it_again(cx: &mut TestAppContext) { window.dispatch_action(Box::new(ToggleToolSidebar), cx) }); in_frame(cx, handle, |window, _| { - assert_eq!(window.find(sidebar).label(), Some("历史命令")); + assert_eq!(window.find(sidebar).label(), Some("Docker")); }); } @@ -808,7 +808,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test window.activate_window(); window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx); }); - in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); in_frame(cx, handle, |window, _| { assert!( window @@ -830,7 +830,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test assert!(window.try_find(sidebar).is_some()); assert_eq!( window.find("tool-placeholder").label(), - Some("staging-api 的 CPU、内存、网络和磁盘。") + Some("staging-api 上执行过的命令,可以搜索、再次执行。") ); }); @@ -842,7 +842,7 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test in_frame(cx, handle, |window, _| { assert_eq!(window.find(sidebar).focused(), Some(true)); }); - in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); in_frame(cx, handle, |window, _| { assert!(window.try_find(sidebar).is_none()); assert_eq!( @@ -855,21 +855,20 @@ fn a_tool_follows_the_terminal_in_front_and_leaves_it_the_keyboard(cx: &mut Test } #[gpui_kit::test] -async fn the_tool_sidebar_goes_with_terminals_only(cx: &mut TestAppContext) { +async fn the_tool_sidebar_goes_with_ssh_terminals_only(cx: &mut TestAppContext) { let (handle, _) = open_workspace_with_sftp(cx, Arc::new(FakeSftpProvider::default())); cx.run_until_parked(); in_frame(cx, handle, |window, cx| { window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) }); - in_frame(cx, handle, |window, cx| window.click("tool-monitor", cx)); + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); let shown = |cx: &mut TestAppContext| { in_frame(cx, handle, |window, _| { let switch = window.try_find("tool-switch").is_some(); - let remote = [INITIAL_WEB_TERMINAL, INITIAL_STAGING_TERMINAL] + let sidebar = [INITIAL_WEB_TERMINAL, INITIAL_STAGING_TERMINAL] .into_iter() .any(|id| window.try_find(("tool-sidebar", id)).is_some()); - let local = window.try_find(("local-tool-sidebar", 1_u64)).is_some(); - (switch, remote || local) + (switch, sidebar) }) }; assert_eq!(shown(cx), (true, true)); @@ -878,40 +877,28 @@ async fn the_tool_sidebar_goes_with_terminals_only(cx: &mut TestAppContext) { open_test_explorer(cx, handle).await; assert_eq!(shown(cx), (false, false)); - // Nor the settings, which the shortcut leaves alone too. + // Nor the settings, nor a local terminal, which the shortcut leaves + // alone too. in_frame(cx, handle, |window, cx| { window.dispatch_action(Box::new(OpenSettings), cx) }); + assert_eq!(shown(cx), (false, false)); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(NewLocalTerminal), cx) + }); in_frame(cx, handle, |window, cx| { window.dispatch_action(Box::new(ToggleToolSidebar), cx) }); assert_eq!(shown(cx), (false, false)); - // A local terminal brings it back as it was, working on this machine. - in_frame(cx, handle, |window, cx| { - window.dispatch_action(Box::new(NewLocalTerminal), cx) - }); - in_frame(cx, handle, |window, _| { - let sidebar = window.find(("local-tool-sidebar", 1_u64)); - assert_eq!(sidebar.label(), Some("系统监控")); - assert_eq!( - window.find("tool-placeholder").label(), - Some("本机的 CPU、内存、网络和磁盘。") - ); - assert_eq!(window.find("tool-monitor").checked(), Some(true)); - }); - - // So does an SSH terminal, working on its host. + // The next SSH terminal brings it back as it was, working on that one. in_frame(cx, handle, |window, cx| { window.click(("terminal-tab", INITIAL_STAGING_TERMINAL), cx) }); in_frame(cx, handle, |window, _| { let sidebar = window.find(("tool-sidebar", INITIAL_STAGING_TERMINAL)); - assert_eq!(sidebar.label(), Some("系统监控")); - assert_eq!( - window.find("tool-placeholder").label(), - Some("staging-api 的 CPU、内存、网络和磁盘。") - ); + assert_eq!(sidebar.label(), Some("历史命令")); + assert_eq!(window.find("tool-history").checked(), Some(true)); }); } @@ -923,3 +910,70 @@ fn the_start_page_has_no_tool_switch(cx: &mut TestAppContext) { assert!(window.try_find("tool-switch").is_none()); }); } + +#[gpui_kit::test] +fn the_tool_sidebar_drags_wider_but_never_narrower_than_it_opens(cx: &mut TestAppContext) { + let (handle, _) = open_workspace(cx); + in_frame(cx, handle, |window, cx| { + window.click(("terminal-tab", INITIAL_WEB_TERMINAL), cx) + }); + in_frame(cx, handle, |window, cx| window.click("tool-history", cx)); + let sidebar = ("tool-sidebar", INITIAL_WEB_TERMINAL); + let bounds = + |cx: &mut TestAppContext| in_frame(cx, handle, |window, _| window.find(sidebar).bounds()); + // Drag the sidebar's edge, the dock's resize handle, from `from` to `to`. + let drag = |cx: &mut TestAppContext, from: gpui_kit::Point, by: f32| { + let to = point(from.x + px(by), from.y); + in_frame(cx, handle, |window, cx| { + window.dispatch_event( + gpui_kit::PlatformInput::MouseDown(gpui_kit::MouseDownEvent { + button: MouseButton::Left, + position: from, + modifiers: Default::default(), + click_count: 1, + first_mouse: false, + }), + cx, + ); + // Past the drag threshold, then where the drag goes. + for position in [point(from.x + px(by.signum() * 6.), from.y), to] { + window.dispatch_event( + gpui_kit::PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: Some(MouseButton::Left), + modifiers: Default::default(), + }), + cx, + ); + } + window.dispatch_event( + gpui_kit::PlatformInput::MouseUp(gpui_kit::MouseUpEvent { + button: MouseButton::Left, + position: to, + modifiers: Default::default(), + click_count: 1, + }), + cx, + ); + }); + }; + + let opened = bounds(cx); + let edge = point(opened.left() + px(2.), opened.center().y); + drag(cx, edge, -100.); + let wider = bounds(cx); + // The dock goes by where the pointer is, which was pressed a little + // inside the edge. + assert!( + (wider.size.width - opened.size.width - px(100.)).abs() < px(3.), + "{opened:?} → {wider:?}" + ); + + // Dragged back past where it opened: it stops there. + drag(cx, point(wider.left() + px(2.), wider.center().y), 250.); + let narrowed = bounds(cx); + assert!( + (narrowed.size.width - opened.size.width).abs() < px(1.), + "{opened:?} → {narrowed:?}" + ); +}