系统服务:右侧栏的新工具,管理 Linux 主机的 systemd 服务

- 按自定义 / 系统分组,按状态分页、搜索;启动、停止、重启,启用或禁用开机启动
- 点服务打开详情,含状态和最近的日志

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-02 16:51:39 +08:00
1 parent 3e8f635fa7
commit 610cb12fbf
16 files changed
+2684 -14

No files matched your search

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