系统监控:右侧栏的第一个工具,经终端自己的 SSH 连接读取 Linux 主机的 CPU、内存、网络和磁盘

- 右侧栏只跟远程终端出现,已知不是 Linux 的主机不显示监控图标,最窄 320 px
- 端口转发、凭据列表和开始页的滚动条挂到不滚动的外层上,修正滑块位置

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-02 13:34:23 +08:00
1 parent 616bcead0f
commit 8a0436caf6
25 files changed
+2835 -157

No files matched your search

+8 -3
View File
@@ -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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
已确认的产品决定: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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
做 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/<name>.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`,写 `<data_dir>/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<TerminalView> }` 和 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<String, String>`)。本地 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<Reading>)` 或 `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<HostStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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()` 是当前显示的那个,侧栏隐Line truncated
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<HostStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(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`(`checkedLine truncated
+14
View File
@@ -135,6 +135,20 @@ shellrs download <ID> <远程路径> <本地路径>
- **查找**(⌘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;关闭终端不会中止传输。
+3
View File
@@ -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);
+2 -1
View File
@@ -51,7 +51,8 @@ icon_assets!(
CodeXml,
RotateCcwClock,
Container,
Activity
Activity,
Monitor
]
);
+5 -1
View File
@@ -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);
+5 -1
View File
@@ -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);
+24
View File
@@ -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();
+4 -3
View File
@@ -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;
+502
View File
@@ -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<Reading>),
/// Not a Linux host. Holds `uname -s` when the host has one: 「Darwin」.
Unsupported(Option<String>),
}
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::<f64>().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<CpuTimes>, Vec<CpuTimes>) {
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<u64> = 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::<u64>().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<InterfaceCounters> {
lines
.iter()
.filter_map(|line| {
let (name, counters) = line.split_once(':')?;
let counters: Vec<u64> = counters
.split_whitespace()
.map(|field| field.parse().ok())
.collect::<Option<_>>()?;
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<String> {
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::<u32>().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<DiskUsage> {
let mut disks: Vec<DiskUsage> = 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::<u64>().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}");
}
}
}
+18
View File
@@ -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,
}
+459
View File
@@ -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<SystemInfo>,
pub uptime: Option<Duration>,
/// CPU time since boot: the whole machine, then each core.
pub cpu: CpuTimes,
pub cores: Vec<CpuTimes>,
pub memory: Memory,
pub interfaces: Vec<InterfaceCounters>,
/// The interface the default route goes out of: the host's main one.
pub default_route: Option<String>,
/// Read every half minute or so.
pub disks: Option<Vec<DiskUsage>>,
}
/// 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<Duration>,
pub cores: usize,
/// Percent busy since the reading before; `None` for the first.
pub cpu: Option<CpuLoad>,
pub memory: Memory,
pub interfaces: Vec<InterfaceRate>,
pub default_route: Option<String>,
}
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<f32>,
}
/// 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<f64>,
pub transmitted: Option<f64>,
/// 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<f32> {
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::<Vec<_>>()
};
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);
}
}
+838
View File
@@ -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<TerminalView>,
}
/// 系统监控: 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<MonitorTarget>,
active: bool,
hosts: HashMap<RemoteTerminalId, HostState>,
/// 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<Cell<Option<Pixels>>>,
/// 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<Task<()>>,
}
#[derive(Default)]
struct HostState {
tracker: Tracker,
snapshot: Option<Snapshot>,
/// 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<DiskUsage>)>,
problem: Option<Problem>,
}
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<String>),
/// 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<Self>) {
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<MonitorTarget>,
active: bool,
cx: &mut Context<Self>,
) {
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>) {
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<Self>,
) {
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<ExecResult>, 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<Self>) -> 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<Icon>,
title: &'static str,
meta: Option<AnyElement>,
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<SharedString>, cx: &App) -> Div {
div()
.font_family(cx.theme().mono_font_family.clone())
.child(text.into())
}
fn render_system(system: &SystemInfo, uptime: Option<Duration>, 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<f32>, 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<f32>,
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<usize>,
/// Where the row records its width for the next frame.
width: Rc<Cell<Option<Pixels>>>,
/// 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::<Vec<_>>()
.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<f64>| 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),
)
}))
}
+138 -1
View File
@@ -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<Pin<Box<dyn Future<Output = Latency> + 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<ExecRequest> = VecDeque::new();
let mut exec: Option<Pin<Box<dyn Future<Output = ()> + 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<ConnectionPromptKind>,
host_os: Option<HostOs>,
latency: Option<Latency>,
/// The answers to the commands run beside the shell, in order.
execs: Vec<crate::terminal::ExecResult>,
}
/// 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<String> = 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:?}"),
+29 -2
View File
@@ -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<TerminalEventProxy>;
@@ -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<mpsc::Receiver<ExecResult>> {
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<mpsc::Receiver<ExecResult>> {
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
+1 -1
View File
@@ -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,
};
+15
View File
@@ -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<std::sync::mpsc::Receiver<super::ExecResult>> {
self.engine.read(cx).exec(command)
}
pub fn screen_text(&self, cx: &App) -> String {
self.engine.read(cx).snapshot().visible_text()
}
+22
View File
@@ -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<String, String>;
/// 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<ExecResult>,
}
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"),
}
}
+17 -10
View File
@@ -235,18 +235,11 @@ impl Render for RecentHosts {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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)
}
}
+84 -55
View File
@@ -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<HostOs>) -> 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<TerminalView>,
}
/// 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<ToolTerminal>,
/// Whether the right dock is open.
shown: bool,
monitor: Entity<MonitorPanel>,
store: Entity<HostStore>,
focus_handle: FocusHandle,
_subscription: Subscription,
}
impl ToolSidebar {
pub fn new(store: Entity<HostStore>, cx: &mut Context<Self>) -> Self {
/// `dispatch` is the workspace's focus handle, which the tools' buttons
/// dispatch on.
pub fn new(store: Entity<HostStore>, dispatch: FocusHandle, cx: &mut Context<Self>) -> 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<ToolTerminal>, cx: &mut Context<Self>) {
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<ToolTerminal>,
shown: bool,
cx: &mut Context<Self>,
) {
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>) {
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<Self>) {
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<Self>) -> 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<ToolKind>,
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()
+91 -28
View File
@@ -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<ToolTerminal> {
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<ToolKind> {
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<Self>,
) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<Self>) {
/// 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<Self>) {
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
+22 -10
View File
@@ -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<CenterTab>,
/// 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)
+1
View File
@@ -12,6 +12,7 @@ mod credential;
mod forward;
mod host_dialog;
mod host_tree;
mod monitor;
mod settings;
mod sftp;
mod terminal;
+390
View File
@@ -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<FakeTerminalFactory>) -> WindowHandle<Root> {
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<Root>,
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::<Vec<_>>()
})
};
let rows = |bars: &[gpui_kit::Bounds<gpui_kit::Pixels>]| {
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));
});
}
+48
View File
@@ -18,9 +18,35 @@ pub struct FakeTerminalFactory {
pub behavior: FakeBehavior,
pub writes: Arc<Mutex<Vec<Vec<u8>>>>,
pub resizes: Arc<Mutex<Vec<TerminalSize>>>,
/// 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<Mutex<std::collections::VecDeque<String>>>,
/// The commands run beside the shell, in order.
pub exec_commands: Arc<Mutex<Vec<String>>>,
}
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<String> {
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<Mutex<Vec<Vec<u8>>>>,
pub resizes: Arc<Mutex<Vec<TerminalSize>>>,
pub exec_outputs: Arc<Mutex<std::collections::VecDeque<String>>>,
pub exec_commands: Arc<Mutex<Vec<String>>>,
}
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,
}
+95 -41
View File
@@ -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<gpui_kit::Pixels>, 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:?}"
);
}