Files
shell-rs/CLAUDE.md
T
XuzandClaude Opus 5.5 2feae2fe3b 外部连接:sftp:// 链接打开 SFTP 标签,兼容 WinSCP 的调用方式
堡垒机像调 WinSCP 一样拉起 ShellRS(`ShellRS sftp://用户[:密码]@地址[:端口]`,
前面可以有 `/sessionname=名称`),开一个不保存的 SFTP 标签,左侧栏收起;
其余规则和 ssh:// 的外部连接一样。WinSCP 的其他开关忽略,链接里的路径暂不认。

补了 SFTP 不带密码、只凭账号(堡垒机令牌)登录的协议测试。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:44:17 +08:00

192 KiB
Raw Blame History

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

项目是什么

ShellRS(crate 与二进制都叫 shellrs)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 gpui-kit 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。

保存的一项叫「主机」,代码里叫 Host。 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(Host、HostId、HostStore、host/ 模块、hosts 表)。它以前叫「会话」/ Session,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(Host.address、hosts.address),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 client::Session / server::Session、channel_open_session、SFTP 会话照旧。

主机和分组是真实持久化的,存在一个 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 和测试连接都照它走。堡垒机(JumpServer 等)可以像调 Xshell 一样用 ssh:// 链接、像调 WinSCP 一样用 sftp:// 链接拉起 ShellRS,开一个不保存、只开一个终端或一个 SFTP 标签的「外部连接」(见下面「外部连接不保存,只开一个标签」)。命名(用户定的):命令行链接打开的叫「外部连接」;以后要做的、在 ShellRS 里直接输入地址、不保存但功能齐全的叫「临时连接」。两者共同的「不保存」在代码和外部 CLI 里叫 temporary(临时主机、HostInfo::temporary),「只开终端」只给外部连接(HostStore::is_external),不绑在「不保存」上。功能的详细说明见 docs/manual.md(README.md / README.en.md 是开源首页,截图在 docs/screenshots/)。

已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),凭据带用户名:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 ~/.ssh 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 keys/ 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(CredentialKind::without_credential),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,只走这里列出的链:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(CONNECT)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(SecretRef::Proxy),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 ON DELETE SET NULL),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(dl.shellrs.com,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 https://dl.shellrs.com/update/v1/{channel}.json,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查并下载,下好、校验完才在标题栏右上角亮图标(success 色的浅色按钮,出错时 warning 色;原来是普通图标加小绿点,用户嫌不显眼),点开是更新对话框(不列更新内容,用户要的:页脚两个按钮「查看更新内容」打开官网的 https://shellrs.com/changelog(build_info::CHANGELOG_PAGE,用户要的:整页,不带版本锚点,也不用清单的 notes_url)和「重启并安装」,没有「稍后」,用关闭按钮或 Esc 关;正文是一句状态、当前版本和发布日期、重启会中断什么,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 v版本号 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。右侧栏:窗口最右边(标题栏和状态栏之间)一列竖排的工具切换,点一个就在右侧 Dock 里显示这个工具、再点一次收起,⌘⌥B / Ctrl+Alt+B 显示或隐藏(显示上次的工具);只跟着 SSH 远程终端出现:当前标签是 SFTP、本地终端、设置或开始页时,右侧栏连同按钮列都不显示(用户要的:看的是每个终端里的信息;本地终端试过显示「本机」,用户让先隐藏、专注远程),⌘⌥B 也不起作用;回到任一远程终端时按原样恢复(开着就还开着、同一个工具),内容对应这个终端的主机;它是工作区级的一份,不是每个终端标签各带一份(中间区可以分栏,每个标签一份会重复、挤占终端),开合和所选工具在所有终端间共用;分栏时按「当前标签」(最近显示的那个)判断;默认收起、宽 320 px,拖动只能往宽里拉、不能比 320 px 窄(用户定的,系统监控的卡片按这个宽度排),宽度和 Dock 布局一样不持久化;工具有命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控(按钮列从上到下就是这个顺序,同用户给的参考图),七个工具都做好了。系统监控:系统(主机名、架构、发行版、运行时长;运行时长写成「32 天 23:40:26」,不足一天「23:40:26」,随每次读数刷新,不在两次读数之间自己走(用户定的,别过度设计)、CPU(型号、核数、平均使用率的环和条;多于 1 核时平均使用率下面是一排小竖条,每核一根,鼠标停上去提示「核 12:35.0%」,不管多少核都这一种画法(用户定的;试过两列每核一行编号、条和百分比,64 核要 32 行);默认只显示放得下的第一行(大多数主机一行就够,默认宽度一行 23 根),一行放不下时标题行右端才有「N 核 ⌄」按钮,提示「显示更多核的占用」/「收起更多核的占用」,展开后换行显示全部,默认宽度下 64 核排 3 行)、内存(物理内存,有交换空间时再加一行)、网络(每块网卡的上传 / 下载速率,不列 lo 和从没收发过的网卡;默认只显示主网卡(默认路由走的那块,读 /proc/net/route,没有就取收发最多的),多于 1 块时标题行右端的「N 个网卡 ⌄」展开全部;两个展开状态在所有终端间共用,不保存)、磁盘(每个挂载点的占用,按 df 的算法不含 root 保留空间,不列 tmpfs、devtmpfs、overlay、snap 的 loop 设备和 /dev /run /sys /proc 下的挂载)五张卡片;只支持 Linux(读 /proc 和 df -kPl,只看本地文件系统,失效的网络挂载不会卡住):已探测到不是 Linux 的主机(macOS、Windows、FreeBSD、OpenBSD、NetBSD)不显示系统监控、网络连接、进程管理和系统服务的图标(用户要的,比点进去看「暂不支持」好),这几个工具正开着时切到这样的主机,右侧栏收起、别的工具图标还在,切回 Linux 主机原样恢复,在这样的主机上按 ⌘⌥B 显示第一个可用的工具;还没探测出系统的主机照样显示,读不了时写明「暂不支持」兜底;Docker 和历史命令只在已知是 Windows 的主机上不显示(sh 能跑 docker、读得到 bash 历史文件的地方都行,Mac 上的 Docker Desktop 也是);CPU、内存、网络和运行时长每 2 秒读一次(用户定的,试过 3 秒;第二次在 1 秒后,CPU 和网速要两次读数才有),主机名、架构、发行版和 CPU 型号每次连接只读一次,磁盘每 30 秒一次,其余每次只用 shell 内建命令读几个 /proc 文件,除了跑它的 sh 不在主机上起别的进程;只在它显示时读,隐藏、换工具、切到别的标签就停;用量 ≥ 70% 黄、≥ 90% 红,其余绿。网络连接是右侧栏里单独的一个工具,不是系统监控里的一张卡片(试过卡片,用户要独立工具,并给了参考图):顶上一行「共 96 条 · 15 个监听端口 · 21 条已连接」加刷新按钮,下面是搜索框(地址、端口、状态、PID、进程、用户,不分大小写)和「全部协议 / TCP / UDP」「全部状态 / 监听 / 已连接 / TIME_WAIT / CLOSE_WAIT / 其他状态」两个下拉;每条一张卡片:角色图标、协议和状态两个标签、右端写「监听端口」「入站连接」(本地端口是本机同协议在监听的)或「出站连接」,然后是本地、远端地址(照 ss 的写法),分隔线下是「进程 名称 (PID n)」(几个进程共用时「等 N 个」)和右边的用户;状态里监听、已连接用中文,其余用 TCP 的名字(TIME_WAIT 等),UDP 没连对端的算「监听」;绿是监听、蓝是已连接、黄是 CLOSE_WAIT,其余灰;监听的在前,然后已连接、CLOSE_WAIT、握手中、关闭中,TIME_WAIT 最后,同类按协议、本地端口排;同一地址被几个进程一起监听(nginx 的 worker、SO_REUSEPORT)合成一张卡片。列表不自己刷新:打开网络连接、换到别的终端时各读一次,之后只有点刷新才读(列表在读的时候自己挪动没法看;系统监控是仪表,才每 2 秒读);切回来先显示上次的列表、同时重读;终端没连接时每 2 秒看一眼,连上就读。读的是 ss -tuanpe,不是 root 时只看得到自己的进程,没有 ss 的主机读 /proc/net、没有进程,这两种情况顶上都写明;最多列 3000 条,超过时顶上写「连接太多,只列出了前 3000 条。」,计数仍是主机上的全部。进程管理(同样是单独的工具,用户给了参考图):顶上一行「共 137 个进程 · 每 15 秒刷新」加刷新按钮,下面是搜索框(进程名、PID、用户)和「内存」「CPU」两个排序按钮,右端是当前列出的个数;每个进程一张卡片:状态圆点(绿运行中、灰休眠 / 空闲、黄等待 I/O / 已暂停、红僵尸,tooltip 是状态)、进程名(/proc/<pid>/stat 的名字,内核截到 15 字节)、右端「PID n」,第二行「用户 · 启动时间」(本机时区,「2026-09-15 17:53:54」),第三行「内存 474.74 MB · 28.1%」(蓝)和右边「CPU 0.5%」(橙),最下面一条内存占比的进度条;不列内核线程(PF_KTHREAD);默认按内存从多到少,点另一个按钮按它从多到少,再点同一个按钮反过来,箭头表示方向;CPU 按 top 的算法(一个核 100%,两次读数之间的 CPU 节拍差),所以第一次读数是「—」,2 秒后读第二次,之后每 15 秒一次(参考图上写的),只在显示时读,换终端、刷新都从头来;点进程打开详情对话框(用户给了参考图):标题是进程名加状态标签,下面一行「PID 2202 · root」;「进程信息」两列 16 项(PID、父进程「systemd (1)」、用户、状态「休眠 (Ssl)」、启动时间、运行时长、终端(没有写「无」)、优先级(top 的 PR,nice 0 是 20)、Nice、CPU 使用率、累计 CPU 时间、常驻内存「468.75 MB (24.0%)」、虚拟内存、线程(参考图没有,补上填满两列)、直接子进程、全部后代进程),运行时长和累计 CPU 时间用和系统监控一样的「N 天 HH:MM:SS」;「完整命令」是打开时才读的 /proc/<pid>/cmdline(列表不带它),右边「复制命令」;底部左边「复制 PID」,右边「结束进程」「强制结束」(参考图写「强制杀死」,统一成右键菜单和确认框的叫法),点了先关掉详情再弹确认框(不叠两层);详情是打开那一刻的列表,不跟着刷新。右键进程有「查看详情」「结束进程…」(SIGTERM)和「强制结束进程…」(SIGKILL),都先弹确认框(写明 PID、用户和后果,危险按钮「结束进程」/「强制结束」),结果是通知(「已向 java(PID 2202)发送结束信号」「已强制结束…」或「无法结束…:没有权限…」),1 秒后重读列表;最多列 2500 个进程,超过时顶上写明。系统服务(用户给了参考图):顶上「systemd 249 · 降级运行」(is-system-running 是 degraded 时这行是黄字)加刷新按钮,搜索框(服务名、描述),gpui-kit 下划线 TabBar 的四个标签「全部 / 运行中 / 已停止 / 失败」,数量是标签的子元素、淡色紧跟文字(数量随搜索变;用户定的:试过和 Docker 一样均分整行的分段控件,「已停止 202」在均分的一段里被缩略成「…」,下划线标签按文字宽度排、放不下时可以左右拖动);列表分「自定义服务」(unit 文件在 /etc/systemd/system/)和「系统服务」两组,各带数量,组内按名字(systemd 的顺序,大写在前);每个服务一张卡片:状态圆点(绿运行中、红失败、黄启动或停止中、灰已停止)、名字、描述、运行状态标签和开机启动标签(已启用绿,其余灰),右边是按钮:运行中「停止」(浅红)「重启」,没运行「启动」(浅绿),再一个「…」打开详情(参考图的「…」,我们没做每张卡片的下拉菜单,那些命令在右键菜单和详情里);点卡片也打开详情。「运行中」包括 active / reloading / activating / deactivating,一次性服务的 active (exited) 也算运行中(参考图上 apparmor 就是)。详情对话框:标题是名字加状态标签、下面描述;TabBar 的「状态」「日志」两页,状态页两列(加载状态、运行状态「运行中 / 已退出」、开机启动、主进程 PID、内存、任务数、重启次数、退出状态、启动时间、停止时间(运行中时写「—」,参考图写 n/a)),最后一整行 Unit 文件;日志页是 journalctl -u 最近 200 行,第一次切过去才读,滚到最底;底部靠左是按钮:运行中「停止」(danger)「重启」,否则「启动」(primary),再加「禁用开机启动」或「启用开机启动」(只有 enabled / disabled 的 unit 有,static、generated 之类没有)。停止和重启先弹确认框(ssh.service / sshd.service 的停止特别写明新的连接会连不上),启动和开机启动直接执行(参考图上没有确认,这两样不拿走什么);结果是通知,之后重读列表。不是 root 时用 sudo -n:云主机常以 ecs-user、ubuntu 之类登录、配了免密码 sudo;sudo 要密码就报「需要 root 权限:以 root 登录,或给这个用户配置免密码的 sudo」。列表不自己刷新(服务很少变,一次读数起好几个 systemctl):打开、换终端、刷新、执行命令之后各读一次。Docker(用户给了参考图):顶上「Docker 26.0.0 · Compose v2.25.0」加刷新按钮,下面 count_tabs 的「容器 / 卷 / 镜像 / 网络」带数量;容器页按 compose 项目(com.docker.compose.project 标签)成卡片:折叠箭头、圆点(全在跑绿、部分黄、都停灰)、项目名、「运行中/总数」标签、项目目录(…project.working_dir),右边停止或启动、重启(对项目里全部容器,用容器 ID 直接 docker stop/start/restart,不靠 compose 文件);只有一个容器且在跑的项目默认展开,多个容器的(用户要的,展开会把下面的挤下去,卡片上已写着「运行中/总数」)和都停了的默认折叠,点项目那一行切换(记在面板里,不保存);展开后每个容器一行:圆点、名字、端口(0.0.0.0: 省掉,-> 写成 →)或没有端口时的镜像,右边停止或启动、重启、「…」(详情);没有项目的容器在「独立容器」小标题下,同样的行。卷 / 镜像 / 网络页每项一张卡片:名字、一行说明(卷:驱动 · 挂载点;镜像:ID · 大小 · 创建时间,没名字的写「未命名镜像」排最后;网络:驱动 · 范围)、标签(使用中 / 未使用、N 个容器、内置),右边删除按钮,有容器在用或是 bridge / host / none 时禁用(tooltip 说为什么);点卡片打开它的详情(用户要的):标题是名字加卡片上那个标签、下面「种类 · 那行说明」,正文是 docker volume|image|network inspect 排成的区块(卷:基础信息、使用它的容器(来自列表,inspect 不说)、选项、标签;镜像:基础信息(完整 ID、标签、摘要、创建时间、大小、平台、层数、作者)、使用它的容器、运行配置、环境变量、标签;网络:基础信息、子网(「网关 …」)、容器(inspect 只列运行中的,带地址)、选项、标签),没有的写「—」,底部只有「删除」(不能删时禁用、tooltip 说为什么),点了先关详情再确认。点容器打开详情对话框:标题是容器名加状态标签、下面镜像(参考图标题写「容器详情」,统一成进程、服务详情的样子),「详情」「日志」两页;详情按参考图分「基础信息(名称、ID、镜像、创建时间、入口、命令)」「端口映射」「挂载」「环境变量(等宽、可滚动)」「标签」,没有的写「—」;日志是 docker logs --tail 200 --timestamps,第一次切过去才读;底部停止或启动、重启,已停止的再加「删除」。停止、重启、删除先确认,启动直接执行。不是 root 又不在 docker 组时用 sudo -n docker。列表不自己刷新,同系统服务。历史命令(用户定的:从主机上 bash 的历史文件 ~/.bash_history 读,不是 ShellRS 自己记录终端里敲的命令;点一条是「输入到终端、不执行」,另有执行按钮):顶上「共 401 条 · ~/.bash_history」加刷新按钮,下面一行淡色的「这个终端里执行的命令,bash 退出后才会出现在这里。」(bash 退出时才写文件,不说明的话用户会以为刚敲的命令丢了),搜索框(几个词都要有,不分大小写);每条命令一张卡片:等宽的命令(一行,多行命令的换行写成「↵」,长的截断、鼠标停上去看全文)、下面「3 分钟前 · 执行 2 次」(文件里没有时间就只有「执行 2 次」;一个月以前写日期),右边执行按钮;最近的在前,同一条只列一次(排在最近一次的位置,计次数)。点卡片把命令放进终端的输入行、替换已经输入的内容、不执行,终端拿到键盘可以接着改;执行按钮直接执行;右键「输入到终端」「执行」「复制」;终端没连接、正在跑全屏程序时不输入,弹通知说明。列表不自己刷新,同网络连接;文件太长时只读最后 512 KB,顶上写明。命令片段(用户定的:所有主机共用,不绑定分组或主机,但库里预留了「适用范围」的列;一层分类加搜索):顶上「共 12 个片段 · 3 个分类」和「新建分类」「新建片段」两个按钮,搜索框(名称和命令,几个词都要有,不分大小写);按分类分组,分类按名称、组内按名称(不分大小写),没分类的在最后的「未分类」下,一个分类都没有时不画分类标题;新建的空分类也显示(「部署 0」);点分类标题折叠 / 展开(记在面板里,不保存),搜索时只列有匹配的分类、全部展开;每个片段一张卡片:名称、下面一行等宽的命令(多行命令的换行写成「↵」,长的鼠标停上去看全文),右边的执行按钮平时不显示、鼠标移到卡片上才出现(用户要的:一看就知道点了会怎样):点击时自动执行的是黄色闪电(提示「点击时自动执行」),只输入的是 ▶(提示「执行」),两种点了都执行;按钮的位置一直留着(invisible 加卡片的 group_hover),出现时别的不挪;点卡片输入到终端、执行按钮直接执行,和历史命令一样,勾了「点击时自动执行」的片段点卡片也直接执行(用户要的设置);右键片段「输入到终端」「执行」「复制命令」「编辑…」「删除…」,分类标题「新建片段…」(预选这个分类)「重命名分类…」「删除分类…」,空白处「新建片段…」「新建分类…」;一个片段都没有时是「还没有命令片段」和「新建片段」按钮。片段对话框是名称、分类(下拉,「未分类」在最前)、命令(多行、等宽)和勾选框「点击时自动执行」(下面的说明「启用后将自动执行命令,否则只将内容填入终端」,新建时默认勾上,用户定的),打开时名称就有键盘;分类对话框只有名称,不许重名;删除分类连同里面的片段一起删,确认框写明个数。不读主机,所以 Windows 主机上也有;Windows 的 shell 不认 Ctrl-E / Ctrl-U,那里点片段不替换已经输入的内容,接在后面输入。

做 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 为准。

常用命令

cargo run                                   # 启动应用
cargo build                                 # 首次构建会编译整个 GPUI 栈,很慢
cargo test                                  # 单元测试(模块内)+ tests/ui/(无头窗口 UI 集成测试)
cargo test --lib                            # 只跑单元测试
cargo test --test ui                        # 只跑 UI 集成测试
cargo test --test ui new_host -- --nocapture   # 按名称片段跑单个 UI 测试
cargo test --test cli_bin                   # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
cargo test --lib update::                   # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
cargo clippy --all-targets -- -D warnings   # 必须无警告
cargo fmt --check

sqlite3 ~/Library/Application\ Support/shellrs/shellrs.db '.schema'   # 查落盘结果
security find-generic-password -s shellrs -a "password:<user>@<host>:<port>"   # 查钥匙串条目
cargo test -- --ignored                     # 会读写真实钥匙串的测试,默认跳过

工具链:rust-toolchain.toml 固定 Rust 1.98.1,因为 gpui-pre 用到了 1.94 尚未稳定的 std API。Cargo.lock 把 gpui-pre* 系列固定在 0.3.2(gpui-kit 0.6.1 发布时配套的版本),不要随手 cargo update 它们,除非确认仍能编译。cargo test 需要 gpui-kit 的 test-support 特性,首次会再编译一遍(同样慢)。

本终端的 screencapture 被 macOS 权限拦截,视觉检查只能靠 UI 集成测试或让用户看窗口。

架构

单个二进制 crate,带 src/lib.rs,这样 tests/ui/ 能驱动生产环境的 Workspace。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 workspace(窗口壳),也不得触碰彼此的内部实现。

  • app/ — actions.rs 定义全部用户命令(gpui_kit::actions! 单元动作 + 携带 HostId / GroupId 等 id 的动作,统一由 id_actions! 声明;右侧栏的工具 ToolKind 和 CenterTab 一样定义在这里,名称、图标在 workspace/tool_sidebar.rs);paths.rs 解析数据目录(SHELLRS_DATA_DIR → dirs::data_dir()/shellrs)与数据库路径;mod.rs 绑定快捷键、初始化 gpui-kit 并 set_locale("zh-CN");assets.rs 用 icon_assets! 把额外的 Lucide 图标并入默认图标包——额外图标用 CatalogIcon::*,默认包用 IconName::*;OS_ICONS 另外嵌入 assets/icons/os/*.svg(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 Icon::default().path(os.icon_path()) 渲染;BRAND_ICONS 嵌入 assets/icons/brand/*.svg(Lucide 不收品牌标志:右侧栏 Docker 工具的鲸鱼 DOCKER_ICON,同样取自 Simple Icons,CC0,用户要的),所以 ToolKind::icon 返回 Icon 而不是 CatalogIcon;app_icon.rs 的 show_logo_when_unbundled:不在 .app 包里运行(cargo run)时,main 用它把 assets/logo/shellrs.png 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 build.rs(winresource,图标资源 1,gpui 给窗口用的也是它)嵌入 assets/logo/shellrs.ico。
  • host/ — model.rs(Host / HostGroup / HostDraft / GroupDraft / HostOs / PublicId;Host.group 与 HostGroup.parent 都是 Option<GroupId>,Host.os 是 Option<HostOs>,Host.credential 是 Option<CredentialId>,Host.route 是连接方式 Route::{Direct, Jump(Vec<Option<HostId>>), Proxy(ProxySettings)},跳板里的 None 是已删除的那一台,Host.notes 是对话框最后的「备注」,不进登录快照、改它不重连)、credential.rs(凭据的模型:Credential / CredentialDraft / CredentialKind,validated、summary、matches_credential_query 是纯函数;放在 host 里的理由同 forward.rs)、private_key.rs(粘贴和生成的私钥:PastedKey::parse 认出私钥并在不要口令时读出公钥,GeneratedKey(generate / public_key_line / encode,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),read_public_key 从私钥文件或旁边的 .pub 读公钥,write_key_file 经临时文件写 0600 的私钥文件;Debug 一律打码)、login.rs(HostLogin / LoginMethod / LoginRoute:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、snippet.rs(命令片段的模型:Snippet / SnippetDraft / SnippetCategory / SnippetScope(All / Group / Host,界面只会是 All),validated、matches、by_name 是纯函数;放在 host 里的理由同 forward.rs,片段将来绑定的分组或主机删了要一起删)、store/snippets.rs(impl HostStore 的片段和分类部分,store.rs 的子模块所以能用它的私有字段)、secret_fields.rs(SecretFields:主机对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、*_loaded 标志和 secret_change;两个对话框各自在自己的 Form 里摆放、用各自的 id)、forward.rs(端口转发规则的模型:ForwardRule / ForwardDraft / ForwardKind / ForwardEndpoint,validated、summary、needs_restart、matches_forward_query 都是纯函数;放在 host 里是因为规则和主机同库、随主机级联删除,而 host 不能依赖 forward,同 BookmarkSide)、database.rs(HostDatabase:rusqlite 连接、一份完整的 SCHEMA 加 PRAGMA user_version 和逐版本的迁移步骤、行与模型的映射)、store.rs(HostStore 实体,内存是唯一事实来源,写穿到数据库)、outline.rs(纯函数:store → TreeItem,递归铺开嵌套分组;节点 id 形如 g:<id> / s:<id>;group_options 给表单提供按全路径标注的分组列表)、host_panel.rs(左侧 Dock 面板:搜索 + 树 + 右键菜单)、host_dialog.rs、group_dialog.rs(GroupForm、open_group_dialog、confirm_delete_group)、ssh_link.rs(SshLink::parse:读 ssh:// 或 sftp://用户[:密码]@地址[:端口],kind 是 LinkKind::{Ssh, Sftp},url crate 加 percent-encoding 解码,密码是 Zeroizing、Debug 打码,报错不带链接原文;draft() 给临时主机用,见下面「临时连接不保存」)。
  • terminal/ — 本地终端是真的:transport.rs(字节流传输的 trait)、local_pty.rs(portable-pty 实现,跑在专用线程上)、engine.rs(驱动 alacritty_terminal,解析线程 + 16ms UI 轮询批量刷新)、terminal_view.rs(网格渲染、选区、输入编码、内容区右键菜单、查找条)、search.rs(查找)、local_terminal_panel.rs(⌘T 开的本地标签页)、model.rs、font.rs(终端字体:全局量 TerminalFont 与默认值 / 取值范围、is_font_installed、后台用的 monospace_font_families、设置页的 TerminalFontPreview)、exec.rs(右侧栏的工具共用的 ExecTarget:在哪个远程终端的连接上执行命令,和轮询回答的 exec_answer)。远程主机的传输在 ssh/,中间区的标签页是 terminal_panel.rs。prompt 协议在 connection.rs(ConnectionPrompt / ConnectionPromptKind / ConnectionPromptReply / ConnectionSecret),连接的往返延迟 Latency 也在那里,终端、SSH、SFTP 和工作区都直接用这些类型。
  • ssh/ — exec.rs(外部 CLI 的 run_command:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、probe.rs(探测远端系统的命令与纯解析函数,外加驱动两步探测的 HostOsProbe 状态机)、transport.rs(远程终端的传输:pty、shell、探测和延迟)、connection.rs(SshConnector:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;connect_forwarding 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 ForwardedTcpip 的接收端;is_network_error 区分「网络断了」和「需要人处理」)。proxy.rs(HTTP CONNECT 和 SOCKS5 的客户端握手,泛型于流,单元测试用 tokio::io::duplex)、route_tests.rs(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。SshConnector::connect_inner 先按 HostLogin::route 建底层流(Tunnel:直连的 TCP、握手后的代理 TCP,或上一台跳板 channel_open_direct_tcpip 的 ChannelStream),再 client::connect_stream。跳板连接靠下一跳攥着的通道流活着:每台跳板登录、开好通道后就丢掉自己的 Handle,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(a_host_behind_two_jump_hosts_is_reached_through_them_in_order 用服务器的 ended 计数守着,a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly 守着 runtime 先关的情形)。跳板的问答经 Asker / ConnectionPromptKind::at_jump_host 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 RouteFailure(文字写明在哪一台、为什么,错误链保留,所以 is_network_error 和 MissingCredential 照常认),describe_login_error 先认它。代理或跳板报告目标连不上时映射成直连同样的 io::Error 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 russh::Error::IO,和以前 client::connect 一样,解析不了的名字也算网络错误。算法按 OpenSSH 的默认来(ssh_config:OpenSSH 默认能连上的,ShellRS 也要能连上):russh 自己的密钥交换列表不含 NIST 曲线,素数组交换(GEX)又要至少 3072 位;用户的堡垒机是老 Java 上的 Apache SSHD 2.10,没有 curve25519、GEX 最大只有 2048 位,于是 russh 选了 GEX、服务器找不到合适的素数直接断开(「Disconnected」/「early eof」),OpenSSH 却选 ecdh-sha2-nistp256 连上了。所以 KEX_ORDER 照 OpenSSH 的顺序加上 ecdh-sha2-nistp*,MIN_DH_GROUP_BITS 是 OpenSSH 的 2048;没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的老算法。route_tests 的 a_server_with_only_2048_bit_groups_or_nist_curves_is_reached 用只给 2048 位组、只有 NIST 曲线的进程内服务端守着。连接失败的说明一律经 describe_login_error(终端、SFTP、测试连接、外部 CLI、转发):它沿错误链找到真正的原因(网络、缺凭据、NoCommonAlgo 时写明服务器支持哪些算法),找不到时给出整条错误链({error:#}),不再只剩「无法建立 SSH 连接,请检查地址、端口和主机密钥」这一层。known_hosts 校验写 <data_dir>/known_hosts(不碰 ~/.ssh/known_hosts),每次登录先发 none(服务器免认证就直接进),再按 LoginMethod 走:Password 只走密码和键盘交互,NoPassword 是 agent → ~/.ssh 默认私钥、之后没有部分成功就报错(a_login_without_a_password_refuses_a_server_that_wants_one),Key / Agent 是凭据的。登录参数 SshConnectionConfig 由 HostLogin 构造;LoginMethod::Agent(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 SshConnector 上的 AgentLocation(默认 System:unix 读 SSH_AUTH_SOCK,Windows 是 \\.\pipe\openssh-ssh-agent,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 with_agent(AgentLocation::At(..)) 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 is_network_error 把它当成断网去重试。密码和私钥口令先查 secrets,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时不删钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒(ssh::LATENCY_INTERVAL)用 ssh::round_trip(latency.rs:Handle::send_ping(),即 keepalive@openssh.com,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 TerminalTransportEvent::Latency 送出;引擎把最新一次存成状态(和 lifecycle 一样,重启、退出、失败时清空),TerminalPanel::title_suffix 在标签栏右侧显示:< 100 ms 用 success,100–200 ms 用 warning,> 200 ms 或超时用 danger,分档在 Latency::level(Latency / LatencyLevel 在 connection.rs,画法是 shared::LatencyLabel)。SFTP 标签同样显示:工作线程另起一个 measure_latency 任务,新连接一建好就量、之后每 5 秒量一次,传输进行中也照量(ping 排在数据后面,读数随之升高),结果是 SftpEvent::Latency;ExplorerPanel 在 Connecting / Disconnected 时清掉,只在已连接时由 title_suffix 显示。这个任务只在一次 ping 期间攥着 Arc<SftpClient>,并且每 250 ms 看一眼共享槽位,客户端被换掉或丢掉(断开、断线)就立刻放手,所以「断开」照样马上关掉连接(同下面「SFTP 空闲时也要发现断网」的约束)。
  • 主机对话框的「测试连接」是真登录。 主机模块不依赖 ssh:connection.rs 定义 LoginTest(登录快照加表单当前值,秘密用 Zeroizing,Debug 打码;LoginTest::typed 是主机自己的密码或无密码,LoginTest::saved 是使用凭据)和 ConnectionTester 接口,工作区用和终端 / SFTP 共享的 SshConnector 造 ssh::SshConnectionTester 注入进去(Workspace::new_with_services 的 connection_tester 参数,UI 测试注入 FakeConnectionTester)。测试用的秘密来源 FormSecrets 只读:本端点的登录密码、表单所选私钥的口令只认表单里的值(钥匙串里的旧密码不参与),其余条目回退钥匙串,从不写入;使用凭据(saved)时一律不拦截,读钥匙串里凭据保存的。代理的密码不论哪种登录都只认表单(LoginTest::with_proxy_password),跳板主机的秘密一律回退钥匙串。测试前本机预解析的是第一段的地址:直连解析主机,代理解析代理,跳板解析第 1 台。SshPrompts::non_interactive() 让缺密码 / 口令 / 键盘交互直接以带类型的 MissingCredential 失败而不弹框,失败原因由 tester.rs 的 describe_test_failure 按类型映射成中文。陌生主机密钥仍然要问:工作线程通过 channel 把问题交给表单,表单每 50 ms 轮询,在主机对话框上方弹「首次连接此主机」,信任就写入 known_hosts;密钥已变化一律判失败。结果只以通知呈现:「连接成功」,或标题「连接失败」加原因。
  • secrets/ — 系统钥匙串(keyring 4:macOS Keychain / Windows 凭据管理器 / Secret Service)。SecretRef 决定归属:密码按 user@host:port(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的 keychain_id(credential:<id>;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。SecretStore 的三个方法全是阻塞的,只能在后台执行器或传输层工作线程上调用。InMemorySecretStore 给测试,NoSecretStore 给没有钥匙串的机器(界面据此禁用密码字段)。TemporarySecretStore(temporary.rs)是 HostStore 包在注入的存储外面的一层:SecretRef::Temporary { host }(不保存的连接的密码)只记在它的内存里,其余原样转给钥匙串,remember / forget 只碰内存、可以在 UI 线程上调。服务名是 SERVICE = shellrs。
  • explorer/ — SFTP 标签页的界面。explorer_panel.rs(连接、事件轮询、execute 执行全部 ExplorerCommand、把排队的批次交给传输引擎、标签菜单和标签栏按钮、给窗口状态栏的 ExplorerStatus)、transfer_queue.rs(纯逻辑的 TransferQueue:批次的排队、状态与「来源」「进度」列的文字)、queue_panel.rs(双栏下方的传输队列:工具栏、每批一行、进行中批次下面的当前文件子行、展开后的逐项结果)、file_pane.rs(一侧面板:两行工具栏、目录列表 Select、列表容器上的右键菜单和拖放;列表下方的状态栏显示「正在连接」「正在读取」或项目数,出错 / 断开原因交给窗口状态栏)、path_label.rs(WinSCP 的路径标签:/home/tester/ 逐级可点,悬停时从根到该级高亮,放不下时中段折成 …;点当前目录或双击空白处打开「打开目录/书签」)、open_directory_dialog.rs(WinSCP 的「打开目录」:目录输入框加本侧书签列表,添加 / 删除 / 上移 / 下移)、file_listing.rs(DataTable 委托:WinSCP 的列、选中行 / 光标的绘制、行点击与拖动)、selection.rs(纯函数的多选模型)、history.rs(后退 / 前进)、pane_menu.rs(右键、书签、新建菜单)、pane_operations.rs(把确认过的文件操作派给 SFTP 工作线程或本地提供者,完成后刷新)、file_dialogs.rs(删除确认、重命名 / 新建的名称表单;确认后和属性对话框一样派发 ExplorerCommand::Operate { remote, operation: PaneOperation })、properties_dialog.rs(权限 3×3 与八进制,PermissionDraft 是纯函数)、transfer_dialog.rs(上传 / 下载共用的确认框 TransferForm、冲突 / 续传 / 出错问答、关闭确认)、model.rs(FileEntry 保存原始值,FileSizeFormat::format / format_size / format_changed / format_rights 负责显示;大小列按 FileSizeFormat:默认整 KB(同 WinSCP),可切成字节或简短格式,简短格式和进度条、属性对话框共用 format_size)。ExplorerAction 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。
  • sftp/ — 可注入传输与本地目录接口(LocalDirectoryProvider 也负责本地的废纸篓、重命名、新建和改权限)、RemotePath、russh-sftp 3.0.0 协议适配(RemoteFs:lstat / stat / read_dir / 读写 / rmdir / setstat;list 解析 longname 得到所有者,并发 stat 解析链接目标)、operations.rs(删除、重命名、新建、递归改权限,显式栈遍历,从不跟随链接)、upload.rs / download.rs(有界并发分块传输、.filepart、冲突与续传问答)、meter.rs(TransferMeter:两个方向共用的进度记账,阶段、当前文件、字节数、速度和逐项结果)、journal.rs(原子 JSON 续传记录:上传在 upload-resume/,下载在 download-resume/)、worker.rs(TransferBatch 二选一、三次自动重连、空闲时监听连接关闭)。ssh/connection.rs 的 SshConnector 与终端共享认证、钥匙串服务及 known_hosts 写锁。
  • forward/ — 端口转发。规则的模型在 host/forward.rs,这里是运行和界面。
    • transport.rs:可注入的接口 ForwardTransport / ForwardTransportProvider,命令 ForwardCommand(PromptReply、Stop)和事件 ForwardEvent(Connecting、Prompt、Listening、Connections(n)、ConnectionFailed、Reconnecting,以及两个终结事件 Failed / Stopped)。
    • worker.rs:真实实现 SshForwardTransportProvider。每次启动一条规则就是一个线程(shellrs-forward)加一个 current-thread runtime,结构照 sftp/worker.rs;本地转发是 channel_open_direct_tcpip 加 copy_bidirectional,远程转发是 tcpip_forward 加处理器交出来的通道,动态转发先过 SOCKS 握手。describe_* 把错误映射成中文。
    • socks.rs:SOCKS5(无认证、CONNECT)与 SOCKS4 / 4a 的纯函数解析(Parsed::{Incomplete, Done, Invalid})加一个喂字节的 negotiate。没有引入 SOCKS 依赖。
    • manager.rs:ForwardManager 实体,持有每条在跑的规则的工作线程和状态 ForwardStatus,每 50 ms 轮询事件(不从后台线程唤醒 GPUI);自己订阅 store:规则被删(含随主机级联)就停,ForwardSettingsChanged / ConnectionSettingsChanged 就重启在跑的。
    • forward_panel.rs(侧栏里的转发列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车)、forward_dialog.rs(ForwardForm 与 open_forward_dialog)、diagram.rs(对话框里的示意图 ForwardDiagram,以及 stops / explain / purpose 等纯函数)。
    • protocol_tests.rs:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。
  • credential/ — 凭据的界面,模型在 host/credential.rs。credential_panel.rs(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、credential_dialog.rs(CredentialForm 与 open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..):类型是 SegmentedControl::new("credential-kind"),密钥的来源是 credential-key-source(本机文件 / 粘贴 / 生成新密钥),秘密字段用 host::SecretFields。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 HostStore::save_private_key 写私钥文件,再 insert_credential / update_credential,最后 save_secret。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 commit 返回 false、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 close_dialog;切换来源会清空口令栏,因为口令属于一把钥匙)。
  • monitor/ — 系统监控,右侧栏的第一个真工具,只读远程 Linux 主机:linux.rs(在终端自己的连接上执行的命令 COMMAND 和解析 parse)、model.rs(读数、Tracker 算比率、格式化)、monitor_panel.rs(MonitorPanel:轮询读数、五张卡片),见下面「系统监控(monitor/)是纯解析加一个面板」。依赖 terminal(TerminalView::exec、ExecTarget),不依赖 workspace。
  • snippets/ — 命令片段,右侧栏的第七个真工具,片段本身在 HostStore 里(host::Snippet),这里是列表和对话框:model.rs(rows:分类标题和片段排成列表的行,纯函数)、snippet_panel.rs(SnippetPanel:搜索框、gpui list 画的分类标题和卡片、挂在列表上的右键菜单,菜单在打开时读 store)、snippet_dialog.rs(open_snippet_dialog(SnippetDialog::{New(分类), Edit(id)}, ..) 和 open_category_dialog(CategoryDialog::{New, Rename(id)}, ..))。依赖 host 和 shared,不依赖 terminal(输入到终端是工作区处理 EnterCommand)。
  • history/ — 历史命令,右侧栏的第六个真工具:bash.rs(读 ~/.bash_history 的命令 command() 和解析 parse)、model.rs(History::new 去重、按最近排、计次数,matching 搜索,format_when 写「3 分钟前」,都是纯函数)、history_panel.rs(HistoryPanel:读取、搜索框、uniform_list 画的卡片和挂在列表上的右键菜单)。依赖 terminal(读取用 TerminalView::exec,把命令放进输入行是工作区经 TerminalView::enter_command 做的),不依赖 workspace。
  • docker/ — Docker,右侧栏的第五个真工具:linux.rs(读数的命令 command() 和解析 parse(每行一个 JSON,serde_json),valid_id,容器命令 control_command、删除的 remove_command、详情的 inspect_command / parse_details、日志的 logs_command,结果的 done)、model.rs(Container / Volume / Image / Network,DockerTable::new 按项目分组、标出哪些卷 / 镜像 / 网络有容器在用,rows 按标签页和折叠排出行,都是纯函数)、docker_panel.rs(DockerPanel:读取、count_tabs、gpui list 画的项目卡片、容器行和卷 / 镜像 / 网络卡片)、container_details.rs(open_container_dialog:详情对话框,正文 ContainerDetailsView 打开时 docker inspect、切到日志页时读日志)、object_details.rs(open_object_dialog:卷、镜像、网络的详情对话框,正文 ObjectDetailsView)、details.rs(两种对话框共用的 Read、在终端连接上执行的 run,以及把 DetailSection 画成标题加内容的 render_sections)。卷、镜像、网络的卡片和详情都从 ObjectSummary(Volume::summary 等:名字、一行说明、标签及其 Tone、能不能删、被哪些容器用着)画,DockerTable::new 记下的是用着它们的容器名(used_by),不只是有没有。依赖 terminal 和 shared;命令的确认和通知在工作区。
  • 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 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、links.rs(impl Workspace 的外部连接部分:open_link、main 用的 open_link_once_open(window.defer,同 notify_once_open)和最后一个标签关掉时的 forget_unused_temporary_host)、status_bar.rs、tool_sidebar.rs(右侧 Dock 唯一的面板 ToolSidebar 和窗口最右边的工具切换 render_tool_switch)、tools.rs(impl Workspace 的右侧栏部分:set_active_tab、ToggleTool / ToggleToolSidebar 和 set_right_dock_open,见下面「右侧栏是右侧 Dock 的一个面板」)、recent_hosts.rs(中间区没有标签页时显示的「最近连接」开始页,不是 Dock 面板;行的右键菜单就是主机树里主机的那个菜单,两处都调用 host::host_menu,不许分叉)、dock_skin.rs(WorkspaceDockSkin:包一层 DockSkin,中间区为空时用 deferred 把开始页画在空的中间区之上;工作区在 DockEvent::LayoutChanged 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。
  • settings/ — 设置。model.rs(AppSettings:InterfaceLanguage 界面语言、Appearance 应用外观、TerminalFontSettings 终端字体;两者实现 Choice,key 是写进文件的值,label 是下拉里的文字;InterfaceLanguage::locale / Appearance::theme_mode 是解析「跟随系统」的纯函数)、store.rs(SettingsStore,写 <data_dir>/settings.json,见下面「设置写穿到 JSON」)、apply.rs(apply:把设置落到窗口上——Theme::change 加 deepen_list_hover、set_locale、终端字体的全局量,一致时什么也不做)、settings_panel.rs(「设置」标签页 SettingsPanel)。标签页用 gpui-kit 的 Settings 组件分两栏,左边是分类(CATEGORIES:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。Settings 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 choice_field 生成:读写都经过 SettingsStore,带默认值,所以页头的「重置全部」可用。Settings 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 settings_tab: Option<..>,CenterTab::Settings):OpenSettings(主机面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。
  • cli/ — 外部 CLI:给 AI Agent 用的 shellrs 命令,以及它在应用里的服务端。
    • mod.rs:clap 命令(list / exec / upload / download)与 main。main.rs 一开头看参数:去掉 macOS 的 -psn_* 之后,先由 link.rs 的 link_arguments 判断是不是堡垒机拉起的链接(只看第一个参数:Xshell 的 -url / -newtab / -newwin、WinSCP 的开关(/sessionname= 当标签名,/newinstance、/ini= 等列在 WINSCP_SWITCHES 里的忽略;只认列出的名字,所以 /tmp/x 这种路径不算),或形如 scheme://…,所以 shellrs exec web "curl http://x" 仍是命令;认出是链接却没给地址时 url 为空,交给界面报错),是链接就不进 CLI;否则还有参数就走 CLI、直接 process::exit,不初始化 GPUI;没参数才起图形界面。识别链接在 Windows 发布版里也做(堡垒机主要在那里)。Windows 的发布版是 GUI 子系统(windows_subsystem = "windows",不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 src/bin/shellrs-cli.rs(shellrs-cli.exe,各平台都构建,default-run 仍是 shellrs)承担。main 在 Windows 控制台上把 stdout / stderr 包进 ConsoleText(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 normalize_command 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。
    • protocol.rs:套接字上的帧:1 字节类型(J JSON / 1 stdout / 2 stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;ErrorCode 是给 Agent 判断用的稳定英文码。Request::Activate 不属于 shellrs 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。
    • client.rs:命令这一端,连 app::cli_socket_path()(即 app::cli_endpoint(data_dir):Unix 上是 <data_dir>/cli.sock,Windows 上是 \\.\pipe\shellrs-cli-<小写的数据目录绝对路径的 sha256 前 16 位>,所以 SHELLRS_DATA_DIR 同样适用),把回复写到 stdout / stderr 并给出退出码(exec 为远程退出码,出错 255,传输有失败项 1)。connect 按平台实现,读帧循环 talk 共用。进度行用 \r 加补空格覆盖,不用 \x1b[2K(老 conhost 不认)。
    • pipe_windows.rs:Windows 命名管道的 Win32 调用(windows-sys):带 DACL 的实例、ConnectNamedPipe、客户端的打开与属主校验。
    • server.rs:CliServer 与可注入的 CliBackend trait;backend.rs 是真实实现 SshCliBackend(exec 走 ssh::run_command,传输走 sftp_provider)。
    • install.rs:把 shellrs 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 IntegrationPaths;SKILL.md 是 skill 正文(include_str!)。install_windows.rs:Windows 的做法(复制 + 用户 PATH),cfg(any(windows, test)),所以在 Mac 上也跑它的测试。integration.rs:设置页用的状态实体 CliIntegration。
  • update/ — 在线升级,不依赖 workspace 和 settings(只用 app 的路径和动作)。
    • build_info.rs:编译期由发布流程注入的 SHELLRS_UPDATE_CHANNEL(stable / beta;没有就是开发构建,从不检查;有的话只决定「更新渠道」的默认值,实际查哪个渠道由设置决定,UpdateServices::release_build)、SHELLRS_COMMIT、SHELLRS_BUILD_DATE、SHELLRS_APPLE_TEAM_ID,清单地址 MANIFEST_URL,以及发布公钥 TRUSTED_KEYS(两个槽位:日常用的和离线备用的;为空时 release workflow 拒绝发布)。
    • manifest.rs(纯函数):清单是一个信封 {manifest: <清单原文>, signature: <minisign 签名>},一个文件、一次请求,不会出现清单和签名缓存不同步;Manifest::offer(当前版本, 平台 key, 本机抽签) 给出 Offer::{UpToDate, Update(Release), Manual}。格式只加字段:不认识的字段忽略,schema 比 SCHEMA 大的清单只读出版本、让用户去下载页。平台 key 是 macos-aarch64 / macos-x86_64 / windows-x86_64 / linux-x86_64(platform.rs),macOS 两个 key 指向同一个 universal 包。
    • verify.rs:open_envelope 先验签再解析,签名的 trusted comment 必须是 shellrs-manifest <通道> <版本> 且和清单一致(防拿旧清单冒充新版本、拿 beta 清单冒充 stable);安装包靠清单里的大小和 SHA-256(verify_file)。debug 构建额外信任 SHELLRS_UPDATE_PUBLIC_KEY,release 构建只信编进去的。
    • feed.rs:UpdateFeed trait(阻塞,只搬字节,所以假 feed 绕不过验签)与真实的 HttpFeed(reqwest blocking,默认特性里的 rustls + 系统代理:从 Finder / 开始菜单启动的进程没有 HTTPS_PROXY);下载写 .part、用 Range 续传、urls 依次回退,timeout 在阻塞客户端里是每次读的超时,所以等于停滞判断;超时被包成「解码响应体出错」,read_error 从内层的 reqwest::Error 认出来。debug 构建可用 SHELLRS_UPDATE_URL 指向本地服务器。客户端在工作线程上按任务新建(阻塞客户端自带 runtime,不能在 async 上下文里建或丢)。
    • install.rs:纯函数 detect 决定 InstallKind(MacBundle / WindowsSetup / AppImage / Unsupported(..):开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);Installer trait 的 stage 在工作线程上做慢的部分,apply 在 UI 线程上只改名或准备一个进程,返回 Relaunch::{Restart(path), Hand(Launch), Nothing}。平台实现 install_macos.rs(ditto 解到 app 旁边同一卷上的 .ShellRS.app.update-<版本>,codesign 核对 Team ID,两次改名交换,第二步失败就换回去)、install_windows.rs(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、install_linux.rs(复制到 $APPIMAGE 旁边,一次改名覆盖;重启用 $APPIMAGE,current_exe 是会消失的挂载点)都在 cfg(test) 下也编译,Mac 上能跑它们的测试;外部命令在 MacTools 之类的接缝后面。
    • updater.rs:Updater 实体,阶段 Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}。每个任务一个 shellrs-update 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。start()(只在生产路径调用):报告上一次更新(读 applied.json)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、on_app_quit 时把 Ready 的版本装好。下载进度只 notify(设置页和对话框观察 Updater),阶段变化才发 UpdaterEvent::Changed,工作区只订阅它来重画标题栏。
    • status.rs(纯函数):设置页的状态行、标题栏按钮 UpdateBadge、哪些按钮可用、设置页和对话框共用的下一步 UpdateStep(UpdateSnapshot::step),以及 restart_note(RestartImpact)。update_dialog.rs:更新对话框。
  • shared/ — 多个功能共用的展示片段(ClosableTabTitle、右侧栏工具共用的 format_bytes / format_percent / format_duration(「32 天 23:40:26」)和带状态的淡色标签 soft_tag、浅色按钮 tinted、HostMark、远程终端和 SFTP 标签栏右侧的延迟 LatencyLabel、close_tab_items,表单里「几选一」的分段控件 SegmentedControl / Segment,表单对话框的「取消 + 提交」页脚 commit_footer、校验错误的通知 form_error_notification 与收起它的 dismiss_form_error(见下面「表单的校验错误一律弹错误通知」)、端口字段的 parse_port、删除确认 confirm_delete 和其他危险操作的确认 confirm_danger,侧栏三个列表的行 tooltip RowTooltips / RowTooltip(见下面「侧栏三个列表的行 tooltip」),以及远程终端和 SFTP 标签共用的「重命名标签」对话框 open_rename_tab_dialog,面板实现 RenamableTab 即可接入)。

关键流程与不变量:

  • 传输队列在标签这一层,引擎一次只跑一批。 ExplorerPanel 持有 TransferQueue:确认框派发的 ExplorerCommand::Enqueue(TransferJob) 只是入队(请求在入队时就校验,失败直接报错不入队),start_next_if_idle 在引擎空闲(engine_busy 为假,它由发出 Upload / Download / Resume / Discard 置真、由引擎的 Idle 清掉)且已连接时把第一个排队中的批次交出去。引擎处理完每个命令都会发 Idle(完成、停止、丢弃、准备失败都一样),标签收到后才推进,所以前一批的事件不会串到下一批上。进度事件只归队首(运行中或已停止的那一批),没有队首时丢弃(例如丢弃续传进度后引擎回的空 Completed)。已停止的批次挡在队首:引擎还留着它等「继续」,开始新批次会把它换掉,所以要等用户继续、丢弃续传进度或「移出队列」(续传记录留在磁盘上)之后队列才往下走。确认框和询问框分开记录(question_shown):批次停止或结束时只关询问框,不碰用户正在填的下一批的确认框。TransferProgress 为队列补了当前文件(current_source / current_bytes / current_total)、只在传输阶段累计的用时 elapsed、最近 3 秒的速度(sftp/speed.rs 的 TransferClock,等待回答和重连时归零)、跳过和失败文件的字节 settled_bytes(fraction() 因此能到 100%,remaining() 按剩余字节除以速度)和重连说明 note(current 始终是路径)。队列面板和双栏在同一个 v_resizable 里,队列为空时那一块 visible(false),双栏在元素树里的位置不变,分栏比例不丢。

  • SFTP 独立于终端。 主机状态综合所有终端与 SFTP 状态计算;关闭终端不停止传输,断开 / 删除主机会停止传输并保留续传数据。每个 SFTP 标签的传输引擎一次跑一个批次,其余在标签的传输队列里等,上传和下载共用(TransferBatch,类型统一叫 Transfer*,方向在 TransferProgress::direction),来源、端点和目标在确认时固定。SFTP 标签按 ExplorerId 登记(工作区的 explorers,explorers_of(host) 列出一个主机的全部 SFTP 标签),ExplorerAction、CloseExplorer、RenameExplorer、CenterTab::Explorer 和 SFTP 认证问答的 PromptOwner::Sftp 都按标签 id 寻址;按主机做的事(连接状态汇总、断开、删除主机 / 分组、统计要停止的传输)要遍历 explorers_of。认证问答按终端 / SFTP 来源与 SFTP 连接代次隔离。

  • 下载严格按顺序写盘。 读请求并发且可能乱序、短读返回,download_file 用重排缓冲区按偏移顺序写入 .filepart,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。

  • 文件操作从不跟随符号链接。 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 trash crate 的 NSFileManager 方式(不弹自动化权限、不放声音)。文件操作与传输批次可以并行;同一侧面板有操作在跑时(FilePane::is_busy)第二行工具栏和对应命令都不接受新操作,结果回来后面板重新读目录。

  • 选择归面板,不归表格。 DataTable 只支持单行选择,所以表格设成 row_selectable(false),FilePane 持有按名称保存的 Selection(.. 永远不进选择集),通过 ListingContext 快照交给委托;同 WinSCP 关掉「整行选择」,名称单元格才是项目:render_td 给出外层 name-cell:<名称>(size_full,画悬停灰底、选中底色和光标框,接单击、双击打开和右键,FileListing::name_events)和里面只有内容宽的 name:<名称>(图标加文件名,只接文件拖动)。所以在名称单元格里任何地方单击都选中这一行,但只有拖图标和文件名才是拖文件,从名称右边的空白拖是框选。行上不画任何状态:DataTable 在 render_tr 的样式之后给整行加 hover 底色、我们改不掉,所以行的第一个子元素是一块铺满的 tokens.table 底色(表格自己的不透明背景)把它盖住,单元格画在它上面;右键时表格给整行画的边框也在容器的右键处理里立刻清掉(press_empty 每次都 clear_menu_row)。为了连单元格的内边距一起铺满,名称列用 name_column 把表格的单元格内边距设成 0,名称单元格和它的列标题各自 px_1p5()(等于 .small() 表格的 6 px,文字因此和标题对齐);Size::table_cell_padding 不是公开 API,拿不到表格自己的值。单击、双击打开、右键都挂在名称上(FileListing::name_events),行上只剩拖放目标和记几何的 canvas。选中的名称一律只有底色,光标框只画在未选中的光标项上(..、刚用 ⌘ 取消选择的项),否则多选的最后一项会多出一圈边框。名称单元格的 on_click 读 ClickEvent 的修饰键和点击次数,直接更新面板(这是事件时刻,不是渲染时刻)。名称单元格和列标题以外都是空白处:按下落在哪里记在 ListGeometry::pressed(Pressed::Empty / Name / Title,容器在捕获阶段复位成 Empty,名称和标题在冒泡时改写),容器的 on_click 和右键按下在 Empty 且没按 ⌘ / Shift 时清空选择(FilePane::press_empty,连光标一起清)。表格底部的横向滚动条会在按下时停止冒泡,点在它上面不算空白处。键盘上的方向键 / Home / End / 翻页绑在 "LocalFileList > DataTable" 这一层,盖过表格自带的单行选择键;←、→ 绑 NoAction,因为表格在行模式下也会把它们切成列选择。

  • 文件列表快捷键按焦点路由。 一个动作 ExplorerShortcut(ExplorerCommand),侧别写进绑定本身;工作区找 focus handle 含焦点的那个 SFTP 面板(contains_focus),不用最近激活的标签,否则两个 SFTP 分栏并排时会发错。同一命令绑多个键时,最后注册的那个会显示在 tooltip 里(tooltip_with_action),所以 WinSCP 的键放最后。菜单项派发 ExplorerAction,按动作相等查不到绑定,菜单里不显示快捷键。

  • 框选同 WinSCP 关掉「整行选择」。 拖文件去另一侧的 on_drag 只挂在名称单元格(图标加文件名,FileListing::drag_from)上,不挂在行上;名称上不带 ⌘ / Shift 的左键按下会在按下时就把未选中的项单独选中(FilePane::select_pressed,右键按下也用它),所以拖走的就是高亮的:未选中的项单独走,已选中的项带着整个选择走(拖动的载荷在 render 时按选择算好,按下前后算出的都是这一项,不会不一致)。拖动中只有指针在接收它的另一侧列表上(且可以传输)时才显示「上传 / 下载 N 个项目」并用 DragCopy 光标,其余地方(包括自己的列表)不显示预览、光标是 OperationNotAllowed(WinSCP 同):载荷带一个 DropSpot,接收侧容器的 on_drag_move 在指针落在自己范围内时记下指针位置,预览在 render 时比较它和 window.mouse_position()。存位置而不是布尔值,是因为 on_drag_move 会通知所有监听这个类型的元素(并排的另一个 SFTP 标签也会收到),「不在我这儿就清掉」会被后调用的覆盖。光标由预览里一个 canvas 在 paint 时 set_window_cursor_style:拖动层最后绘制,所以它的光标请求最后一个入栈、生效;测试平台不暴露光标,这点只能肉眼确认,测试只查预览 file-drag-preview(aria_label 是显示的文字)。列表容器 ("file-list", side) 自己挂一个 MarqueeDrag,它带着开启它的面板的 EntityId:GPUI 的 on_drag_move 会把同一类型的拖动通知给所有挂了监听的元素,不管是谁开启的,所以监听里要核对 event.drag(cx).pane == cx.entity_id(),否则另一侧面板(以及别的 SFTP 标签)会跟着画框、改选择。所以从名称以外按下拖动就是框选(GPUI 的拖动由最上层先达到阈值的元素开启并停止冒泡,名称的拖动因此优先;滚动条在按下时就停止冒泡,不会触发框选)。容器在按下时记下位置,从列标题(Pressed::Title)开始的拖动不画框,其他地方都画(名称的图标和文件名有自己的文件拖动,GPUI 由它先开启,所以轮不到框选)。行高固定,每行用绝对定位的 canvas(必须 top_0().left_0())在 prepaint 记下「第一行顶端的位置和行高」(ListGeometry::rows),选框的起点按「距第一行顶端」保存,所以列表滚动时起点跟着内容走;框住哪些行由纯函数 selection::swept_rows / row_at 算,Selection::sweep 合并(开始时按住 ⌘ 或 Shift 则加到原有选择上)。拖到边缘用 gpui_kit::base::AutoScroll 滚动表格的 vertical_scroll_handle。拖动中 GPUI 每画一帧都会清掉各元素挂起的按下(div.rs 里 has_active_drag 时清 pending_mouse_down),所以松开时起点那个名称单元格不会再收到单击;拖动开始和松开落在同一帧里(快速一甩)时这条不成立,名称单元格的单击因此还用 ends_a_drag(按下和松开相距超过 GPUI 的 2 px 拖动阈值)排除掉;松开(on_mouse_up / on_mouse_up_out)时清掉选框并停止滚动。选框是容器里的绝对定位 selection-rectangle,夹在列表范围内,配色和选中的名称一套(tokens.table_active 底色加 table_active_border 边框,用户要的;原来是 primary 的灰底深色框,和浅蓝的选中不搭)。

  • 列表右键菜单挂在列表容器 ("file-list", side) 上,和主机树一样:名称的右键处理器先把没选中的项选中、把 MenuHit::Item(名称) 写进 menu_hit(右键空白处则清空选择,给空白处菜单),列标题(委托的 render_th)写 MenuHit::Column(列键),容器在捕获阶段清空它;容器的菜单构造器按命中给出文件菜单、「大小」标题的「文件大小显示为」(其他标题给空菜单,不弹出)或空白处菜单。不要在标题或行上另挂 context_menu:菜单的右键监听不阻止事件冒泡,外层容器的菜单会一起弹出。大小格式 explorer::FileSizeFormat 存在 AppSettings::file_size_format(settings.json),由 settings::apply 设成全局量(explorer 不依赖 settings,同终端字体),菜单项派发 SetFileSizeFormat,工作区写设置;委托的 context_menu 保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。

  • 滚动条挂在不滚动的外层上。 vertical_scrollbar(&handle) 往元素里加一个绝对定位、铺满的滚动条图层;挂在 overflow_y_scroll() 的元素自己身上时,GPUI 给滚动容器的所有子元素(包括绝对定位的)加滚动偏移,滚动条跟着内容一起移走,滑块和实际位置对不上(用户在系统监控上报过,端口转发、凭据列表和开始页原来也是这样写的)。所以滚动区(overflow_y_scroll().track_scroll(&handle))外面包一层 .relative()、不滚动的容器,vertical_scrollbar 挂在这一层上(gpui-kit 自己的 Tree、设置页都这样;主机树、端口转发和凭据列表的 *-list、开始页的 recent-hosts 包 recent-host-page);不需要自己的 ScrollHandle 时直接用 overflow_y_scrollbar(),它自己把两层摆好(系统监控)。滚动条不暴露给测试,改动后只能看真窗口。

  • Select 必须放进有尺寸的容器。 它的外层是 size_full(),直接放进工具栏行会盖住整行,别的按钮全点不到。

  • SFTP 面板的工具栏永远一行高,放不下的按钮整个藏起来。 折行会让这一侧的列表比另一侧低一截,所以工具栏行是 flex_wrap 加固定一行高(h_6,和 small 按钮、下拉框一样高)加 overflow_hidden:放不下的按钮折到被裁掉的第二行,整个隐藏(不会只露半个),从最右边的开始;命令在菜单和快捷键里都还有。边框和内边距在外层 frame 上,行高才不含它们。sftp_controls_fit_small_window_in_light_dark_and_zoom 查小窗口大字号下每个按钮要么完整可见要么不可见、两侧列表顶端对齐。新标签的两侧对半分(两个 resizable_panel 都 flex_basis(relative(0.5)),只管第一帧,之后分栏状态记下实际宽度),默认窗口(1280 px)开着侧栏时每侧约 500 px,第一行(目录下拉框 rems(10.))约 404 px,放得下(sftp_panes_open_half_and_half 查按钮都可见、两侧列表顶端对齐)。改工具栏按钮或下拉框宽度时要重新算这笔账。

  • 断开时对远程侧的操作要有回应。 ExplorerPanel::execute 一进来先看 needs_connection(command)(远程侧的导航、打开、删改、属性,以及所有传输;选择、书签、复制路径、队列按钮不算):标签是「未连接」时不执行,弹出「与 主机名称 的 SFTP 连接已断开」(说明是远程面板的 problem),确定按钮「重新连接」派发 ExplorerCommand::Reconnect;can_reconnect 为假(队列里有停止的批次,或传输正在自动重连)时只有「知道了」并说明该怎么办。为了让这些命令发得出来,远程面板在「未连接」时不禁用工具栏、菜单和路径标签(FilePane::takes_commands:只有「连接中」才禁用),传输按钮和拖放在「未连接」时也可用(sync_available 的 transfer)。

  • SFTP 标签自己持有分栏状态。 双栏之间和双栏与队列之间的分隔线位置是 ExplorerPanel 的 panes_split / queue_split(ResizableState 实体,with_state 交给 h_resizable / v_resizable)。不给 with_state 时分栏用 keyed element state,标签一不渲染(切到别的标签)就丢,切回来回到初始宽度(sftp_panes_keep_their_split_across_tab_switches)。和 Dock 布局一样只活在内存里,不持久化。

  • 路径标签按上一帧的宽度折叠。 render 时拿不到布局宽度,所以 path-parts 里放一个 canvas,在 prepaint 记下宽度,变了就用 on_next_frame 通知面板重画(绘制阶段里 notify 会丢)。各段宽度在 render 里用 text_system().shape_line 按 text_sm 量,和实际布局一致(各段不加内边距,否则 / home/ tester/ 看起来像多了空格)。折叠规则是纯函数 folded_parts:留住根,中段换成 …,末尾尽量多留几级;… 点了去它藏起的最后一级(WinSCP 同)。「当前面板」是最近获得焦点的一侧(on_focus_in,必须 defer_in,见下条),标签重新显示时焦点回到它;界面上不标出来(路径标签两侧一样是 list_active 底色加普通下边框):以前给当前面板加一条 list_active_border 下划线,用户看它像「这一侧处于激活状态」,而它和命令实际作用的一侧对不上。

  • 焦点监听器里改渲染状态要 defer_in。 on_focus_in / on_focus / on_blur 在 draw 的焦点阶段同步执行,这时 cx.notify() 只记下脏视图、不置窗口脏也不排新帧,改动要等下一次别的原因重绘才看得见。点列表空白处(GPUI 对 track_focus 元素在按下时自动聚焦,没有别的处理器触发重绘)因此要点两下才切换当前面板;改为 cx.defer_in 在绘制结束后执行就好。测试 sftp_clicking_empty_list_space_makes_that_pane_current_at_once 不自己 render_frame,只看点击本身触发的帧。

  • SFTP 空闲时也要发现断网,和终端同时。 终端靠主循环一直 channel.wait(),russh 的 keepalive(SshConnector 里 30 s × 3,所有 SSH 连接共用)一关掉连接它就返回。SFTP 连接同样会被关掉,但 russh-sftp 只在下一次请求时才报 session closed,而且不公开关闭状态。所以 SftpClient 把通道流包一层 WatchedStream:读到结尾或出错就置位一个 watch,SftpClient::closed() 等它;工作线程空闲等命令时同时等它,一触发就 forget 这个连接并发 Disconnected。等待时只持有 Weak 和那个 watch 接收端,不能持有 Arc<SftpClient>:主动「断开」靠丢掉最后一个引用来关连接,监听攥着强引用会让 SSH 连接一直开着(an_idle_connection_that_drops_is_reported_without_a_request 用服务器端的 ended 计数守着)。is_network_error 也认 russh-sftp 的 session closed。

  • 读目录不许挪动列表,出了什么问题写在窗口左下角。 路径标签和列表之间什么都不放。「正在连接 SFTP…」和「正在读取目录…」写在列表下方常驻的状态栏 pane-status(FilePane::render_status)里,平时是项目数;「正在读取目录…」要读取超过 300 ms(SLOW_LOAD)才出现——大多数目录瞬间就回来,立刻显示只会闪一下。列表没有行时表格中央的占位文字(委托的 render_empty,id list-placeholder,文字来自 FilePane::placeholder,经 ListingContext::placeholder 传进去)也按阶段说:连接中「正在连接 SFTP…」,还没读到过目录(listed 为假)时「正在读取目录…」,读到过之后才是「空目录」(重读一个空目录时同样等 SLOW_LOAD 才改口);没读成功过时是「未连接」或「无法读取目录」。非根目录总有 .. 一行,所以「空目录」实际只出现在空的根目录。改加载状态的地方都要 sync_listing,否则委托里的占位文字不更新。读不了目录、远程侧断开的原因是面板的 problem,显示在窗口状态栏最左边、平时写「已连接 主机名称」的 status-connection:SFTP 标签在前时,工作区用 ExplorerPanel::status(这个标签自己的连接状态,不是主机汇总的,加上 problem,远程侧优先)生成 WorkspaceStatus::Explorer,未连接时写「未连接 主机名称:原因」,已连接但读不了目录时只写原因,两种都是 danger 红字;主机(终端标签)断开时同样是红字。problem 变化时面板发 FilePaneEvent::ProblemChanged,标签转成 ExplorerPanelEvent::StatusChanged,工作区只在那个标签在前时 notify(不观察整个标签,传输进度每 50 ms 一次)。

  • 「打开目录/书签」的选中项是推导出来的。 列表里被选中的就是与目录框(按面板规则展开 ~、相对路径并去掉末尾分隔符后)相同的书签,没有另存选中状态,和 WinSCP 的 DirectoryEditChange 一致:单击书签只是把它写进目录框。书签的增删和排序都派发 ExplorerCommand(AddBookmark { path: Some(..) }、RemoveBookmark、MoveBookmark),改动立即写库,取消对话框也不回滚(WinSCP 同)。对话框打开后在 open_dialog 同步返回之后立刻 focus 目录框并全选,这样能生效(Root::open_dialog 是同步聚焦对话框宿主的,之后同一更新里的聚焦会覆盖它);延迟到下一帧的聚焦才会失效。

  • 书签按主机、按侧持久化。 bookmarks 表对 hosts 有 ON DELETE CASCADE;remove_unnotified / remove_group_unnotified 必须同时丢掉内存里被删主机的书签。BookmarkSide 放在 host 模块,因为 host 不能依赖 explorer。

  • SFTP UI 测试完全注入。 使用 Workspace::new_with_services 注入目录与传输,不访问用户服务器和钥匙串;FakeLocalDirectory 只记录本地操作,不真的改文件。工作线程事件由 UI 定时轮询,禁止后台线程直接唤醒 GPUI 前台任务;实体回调中的 SFTP 动作通过 ExplorerDispatch 延后派发,避免重入实体。

  • 端口转发独立于终端和 SFTP:规则在 store,运行状态在管理器。

    • HostStore 持有规则(forwards,写穿到 forwards 表,对 hosts 有 ON DELETE CASCADE,remove_unnotified 同时丢掉内存里的规则,复制主机不复制规则);ForwardManager 只持有「在跑」这件事。规则没有运行状态字段,启动应用时全部是停止的,除了 auto_start 的(start_automatic,工作区构造末尾调用)。
    • 转发不进 refresh_host_connection_state,on_disconnect_host 不碰它,cancel_prompts_for_host 也跳过 PromptOwner::Forward:主机的「断开连接」只管标签。删除主机 / 分组时工作区不用显式停转发,管理器看到规则从 store 消失就停。
    • 认证问答走和终端、SFTP 同一套对话框,归属是 PromptOwner::Forward(ForwardId, 代次),代次是管理器每次启动递增的编号;StatusChanged 时工作区丢掉过期代次的问答。转发可能是自动开启的,屏幕上没有任何东西说明谁在问,所以对话框开头多一行「端口转发“名称”正在通过 主机名称 连接。」(forward_prompt_origin)。取消问答等于停止(manager.stop),不算失败、不弹通知。
    • 转发自己失败(ForwardManagerEvent::Failed)弹错误通知,因为转发列表可能没在显示。单个连接转发不了(ForwardEvent::ConnectionFailed,例如服务器连不上目标)只记成这一行的 problem,转发继续跑;连接数变化只 notify、不发 StatusChanged,繁忙的 SOCKS 代理上它一直在变。
  • 转发发现断线靠处理器被丢弃,不靠 Handler::disconnected。 russh 0.63.3 在套接字 shutdown 失败时会跳过 disconnected(client/mod.rs 的 Session::run)。SshClientHandler 由会话任务持有,任何退出路径都会丢掉它,所以 connect_forwarding 把一个 mpsc 发送端放进处理器,工作线程 forwarded.recv() 读到 None 就是连接没了(SSH keepalive 30 s × 3,和终端同时发现)。同一个通道也承载远程转发:server_channel_open_forwarded_tcpip 把通道连同 ChannelOpenHandle 交给工作线程,连上本机目标之后才 accept,连不上就 reject(ConnectFailed),对端看到的是拒绝而不是接受后挂断。别的连接(终端、SFTP、exec)没有这个发送端,服务器主动开来的 forwarded-tcpip 通道一律拒绝。

  • 本地 / 动态转发先监听再登录,监听器跨重连保留。 端口被占用在任何问答之前就失败(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、ServicePanel、DockerPanel、HistoryPanel、SnippetPanel),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()(见下面「滚动条挂在不滚动的外层上」)。

  • Docker(docker/)一次读出四类对象。 命令(linux::command())先补 PATH(/usr/local/bin、/opt/homebrew/bin:Docker Desktop、Homebrew),再定 $d:root 或 docker version 能连上 daemon(在 docker 组里)就是 docker,否则 sudo -n docker(所有 Docker 命令都用这一段前缀 DOCKER);@@version 是 $d version --format "{{.Server.Version}}" 2>&1,不以数字开头就是连不上,complaint 把「Cannot connect」翻成「Docker 没有运行」、权限问题和 sudo 要密码翻成「没有权限:以 root 登录、把这个用户加入 docker 组…」;其后 ps -a --no-trunc、volume ls、images、network ls --no-trunc 都用 --format "{{json .}}",每行一个对象(Docker 1.13 以后都有),按行 serde_json 解析,认不出的行跳过。ps 的标签是「k=v,k=v」,值里也可能有逗号(compose 的配置文件列表),没有 = 的一段接到前一个值上;老 Docker 没有 State 字段,从 Status(「Up 3 days」)推。卷 / 镜像 / 网络是否在用由 DockerTable::new 按容器的 Mounts、Image(nginx 就是 nginx:latest;镜像没名字了容器会显示它的 ID)、Networks 算。拼进命令的 ID 和名字先过 valid_id(字母数字开头,只许字母数字和 _.:-/,/ 是给带仓库地址的镜像名的),加双引号。docker images 每个「仓库:标签」一行,同一个镜像打了几个标签就有几行、镜像 ID 相同,所以镜像按 Image::key 认:有标签的是 仓库:标签,没有标签的是镜像 ID(只有仓库名的 nginx 会被 Docker 当成 nginx:latest,显示成 nginx:<none>,也只按 ID 算在用);卡片 id、详情(image inspect)和删除(rmi,有别的标签时只去掉这一个)都用它,DockerTable::new 按键去重。卡片 id 重复时调试版构建会因无障碍节点 id 重复直接 panic(用户在镜像页碰到过)。the_script_asks_docker_for_json 用一个假 docker 脚本(按前两个参数回答)真跑读数命令,查模板 {{json .}} 原样到了 docker 手里。标签页是 shared::count_tabs(见下面「表单里的几选一用 shared::SegmentedControl」)。卷 / 镜像 / 网络卡片的名字用 GPUI 自带的中间省略(overflow_hidden().whitespace_nowrap().text_ellipsis_middle(),用户要的:「registry.ap-…hello-world:latest」),右边的标签 flex_shrink_0 不被挤;镜像的创建时间只写日期,带上时分在 320 px 的卡片里会被截掉。列表同系统服务用 gpui 的 list(项目卡片展开后高度变,行里带着展开状态,变了就 splice);项目那一行的单击派发 ToggleDockerProject,按钮 stop_propagation。详情一律是 DetailSection { id, title, body } 的列表(SectionBody::Rows 标签和值、List 一行一个名字、Text 等宽文本),rows() 把空值写成「—」;容器的 ContainerDetails::sections() 也转成它,所以五种详情长得一样,测试 id 都是「{前缀}-{区块}:{标签}」(container-basics:名称、volume-basics:挂载点、image-users:<容器>、network-subnets:<子网>),等宽文本是「{前缀}-{区块}」(container-environment、image-environment)。打开详情是一个动作 ShowDockerObject { object, id }(容器也是),工作区按种类开容器的或另外三种的对话框。ControlContainers { subject, ids, command } 和 RemoveDockerObject { object, id, name } 由工作区处理,执行、通知和之后重读列表走 tools.rs 的 run_on_host(HostCommand { script, outcome, done, failed, then }),系统服务的命令也用它。

  • 历史命令(history/)读 bash 的历史文件,点了往终端的输入行里打字。 命令(bash::command())是一行 sh -c '…':~/.bash_history 读得到就印 @@size(wc -c)和 @@history 加文件最后 LIMIT(512 KB)字节(tail -c),否则 @@missing;两样都没有(没有 sh)是不支持。@@history 之后原样是文件,命令本身可能以 @@ 开头,所以只认第一个;文件比 LIMIT 长时丢掉第一行(从行中间切开的)。设了 HISTTIMEFORMAT 的 bash 在每条命令前写一行「#秒数」,多行命令写成几行:照 bash 自己读回的规则,跟在带时间的命令后面、自己没有时间的行接到它上面,没有时间的文件一行一条(# 后面不全是数字的是注释命令,不是时间)。the_script_reads_the_end_of_the_history_file 把 $HOME 换成临时目录、用本机的 sh 真跑。卡片的单击和执行按钮派发 EnterCommand { command, run }(按钮 stop_propagation),工作区的 on_enter_command 对当前远程终端调 TerminalView::enter_command,成功就把焦点给终端;引擎的 encode_command 先发 Ctrl-E、Ctrl-U(bash、zsh、fish 的 emacs 键:到行尾、删掉前面所有的,替换已经输入的内容;replace 为假时不发,工作区对已知是 Windows 的主机这样做),再把命令当粘贴发(bracketed paste 时多行命令留在输入行上),run 时再加回车;ALT_SCREEN(vim、less)拒绝,没有 bracketed paste 时多行命令只能执行、不能只输入(第一个换行就会执行它)。复制是 CopyCommand(String)。

  • 系统服务(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 上。

  • 侧栏是一个面板、三个列表。 左侧 Dock 里只有 workspace::Sidebar,它持有 HostPanel、ForwardPanel 和 CredentialPanel 三个实体和一个模式,Dock 标题栏的标题和工具栏按钮委托给当前显示的那个,「设置」页脚在 Sidebar 里、两种模式都有。不用 set_dock 换面板(被换下的面板会收到 on_removed、焦点丢失),也不把两个面板放进一个 TabGroup(两个可见面板必然画出标签栏)。切换时 Sidebar::show 把焦点交给新显示的列表:被藏起来的列表不在元素树里,焦点留在它上面会让所有窗口级快捷键失去派发路径(同「每个面板都必须在 set_active(true) 里把焦点收下」)。三个列表都一直活着,各自的搜索词、选择和滚动位置不丢。FocusSearch 交给 Sidebar::focus_search。

  • 转发列表的命令经工作区的 focus handle 派发。 行上的启停按钮、工具栏的「+」、空状态的按钮都用 target.dispatch_action(同标题栏和开始页)。用 window.dispatch_action 的话,回答完主机密钥的对话框之后焦点是空的,点「停止」什么也不发生(a_forward_question_is_answered_in_a_dialog_and_declining_stops_the_forward 守着)。右键行时面板先自己取得焦点,菜单项才有派发路径。行按钮的 on_click 要 cx.stop_propagation(),否则双击它会连行的双击一起触发。

  • ListItem 里会增长的文字要零宽加裁剪。 ListItem 把内容包在两层 w_full 里、suffix 是它们的兄弟,整行靠内容的最小宽度收缩来给 suffix 让位。flex_1().min_w_0() 的文字列在这里不管用:可增长的 flex 项在父级按内容定宽时按它的内容宽度计入,一行长文字就把 suffix(状态和启停按钮)挤出列表。转发行的文字列因此是 w_0().flex_grow(1.).overflow_x_hidden()(the_forward_dialog_and_a_long_row_fit_the_smallest_window 查按钮仍在列表内)。转发对话框的三张类型卡片是同一回事:RadioGroup::horizontal 的那一行是 flex_wrap,卡片按文字宽度计入就放不下三张,第三张会折到下一行。所以卡片是 flex_1().min_w_0().self_stretch()(那一行是 items_center,不拉伸的话高度不齐),卡片里那句用途同样是 w_0().flex_grow(1.).overflow_x_hidden(),放不下时在卡片内折行。Radio 把子元素都排在圆点右边那一列,用途只占那一列的话 13 个字放不下、折成两行;所以用途那一行(类型图标在前、文字在后,用户要图标留在文字前面)用负的左外边距(RADIO_DOT_COLUMN:small 圆点 size_3p5 加 gap_x_2)拉回到圆点下面,占满卡片的整个宽度。对话框宽 rems(42.) 就是按这一行定的:每张卡片内容宽约 178 px,图标加间距 16 px,13 个 12 px 的字 156 px,余 6 px;再窄(原来是 40)最后一个字会掉到第二行,改这句文案或卡片内边距时要重算。gpui-kit 改了 Radio 的圆点尺寸或间距时 RADIO_DOT_COLUMN 要跟着改,the_three_kinds_of_forward_share_one_row 查用途这一行的两端离卡片边缘的距离。

  • 无头测试里的文字比真实的窄。 UI 测试的文字系统是 GPUI 的 NoopTextSystem:每个字符一律 0.6 em,而真实字体里中文是 1 em。靠文字宽度撑出来的布局问题(折行、把兄弟挤出去、溢出)在测试里可能根本不出现,上面三张卡片折行就是这样漏过去的(the_three_kinds_of_forward_share_one_row 只守得住结构)。要在测试里复现,临时把相关文案加长到约 1.7 倍再量几何;否则只能让用户看窗口。

  • 示意图的流向一直循环,播放用 gpui-kit 自带的。 用户要的是一直循环、动得柔和,并且用框架的动画而不是自己写播放(试过自己记开始时间、每帧请求重画来绕开「减少动态效果」的检查,用户让改回来)。播放是 gpui_kit::base::motion::animate_keyframes 加 IterationCount::Infinite,只给出一圈里从 0 到 1 的匀速进度,键里带 ForwardForm::flow 这个代次;圆点在一圈里怎么走是纯函数 diagram::flow_dots:一圈 3.6 秒,过隧道、到目标各 1.44 秒,再停 0.72 秒,每一段上缓入缓出(Easing::EaseInOut)、出发时淡入、到站时淡出,不会突然出现或跳到下一段。类型变了代次才加一、从新的入口重新出发,同一个类型再点不重来。animate_keyframes 在 cx.reduce_motion() 为真时报「一圈的末尾」,正好是停顿:没有圆点,只剩静态箭头,方向不靠动画也看得出。对话框开着时窗口每帧都在重画。三种类型都按「隧道入口 → 隧道出口 → 目标」从左到右画,本地和远程只是入口、出口两台机器互换(diagram::stops)。两台机器按角色叫「本机」和「SSH 服务器」,那句说明(diagram::explain)用同样的叫法:主机的名称是用户随手起的,不一定读得出是一台机器,而哪一台已经写在下面的「经由主机」里,所以示意图不随所选主机变化。系统的「减少动态效果」目前没有接进 set_reduce_motion,生产环境里动画总会播放。对话框比别的宽(rems(42.))也更高,所以从窗口高度的 5% 开始、最高占 90%,超出时正文滚动、按钮不动。

  • 表结构(SCHEMA_VERSION 是 14)。 发布前重构过一次,所有表都是 STRICT(类型不对直接拒绝),约束写出 store 已经保证的事,出 bug 或手改坏了数据时写库就报错,而不是读出一堆怪东西。groups(parent_id 自引用、ON DELETE CASCADE,有 groups_parent_id 索引)→ credentials(只有密钥凭据有 key_path)→ hosts → host_jumps(WITHOUT ROWID,主键 (host_id, position),jump_id 是 ON DELETE SET NULL)、bookmarks(WITHOUT ROWID,主键 (host_id, side, path),没有自增 id)、forwards。hosts 一行装下主机的全部字段(地址列叫 address,和界面的「地址」、Host.address 一致):登录是 auth(password / no-password / credential)加 username 或 credential_id——用凭据的主机 username 为 NULL、用户名只存在凭据上,credential_id 外键没有级联动作,所以还有主机在用的凭据删不掉,remove_credential 先把这些主机改成自己登录(用户名取凭据的)再删;连接方式是 route(direct / jump / proxy)加四个 proxy_* 列(只有代理路由才有值,CHECK 守着);notes 非空字符串默认 '';os、last_connected_at 由连接写,表单不碰。HostRow::of 是模型到这些列的唯一映射,load 反过来。版本 1–12 只存在于开发构建(发布前重构时把开发者的库转换过一次,那段转换代码随后删掉了),migrate 遇到这些版本直接拒绝、不动文件。14 加了命令片段的 snippet_categories 和 snippets(category_id 级联;scope_group_id / scope_host_id 是预留的「适用范围」,都为空就是所有主机,最多一个有值,对 groups / hosts 级联,内存里 remove_unnotified / remove_group_unnotified 照样丢掉;最后一列 run_on_click 是「点击时自动执行」,在 14 发布之前加进来的,所以没有再升版本),STEPS 里是 13 → 14 这一步(Step::Sql,步骤是历史,SCHEMA 以后再改它也不跟着改),a_version_13_file_gets_the_snippets_and_keeps_its_hosts 用冻结的 13 版 schema 守着。以后改表:升 SCHEMA_VERSION,在 STEPS 里加一步(改表用 Step::Sql,要在 Rust 里算就用 Step::Code,都在一个事务里、最后写 user_version),并照样加一个用冻结旧 schema 升级、再和新库比对的测试(schema_of 去掉空白排版后逐字比较)。加字段可以直接 ALTER TABLE ADD COLUMN:SQLite 把新列放在最后一列之后、表约束之前,所以新列在 SCHEMA 里也写在最后一列之后,去掉排版后两边相同(a_column_added_later_matches_the_schema 守着);一对多的东西才另建表。

  • 一个数据目录只跑一个 ShellRS。 两个进程各自「内存说了算」会撞 id、互相覆盖。main 在起图形界面之前调 cli::activate_running_app(cli_socket_path(), link):连得上外部 CLI 的套接字就说明已有实例,发一个 Request::Activate { open }(带着启动时的链接)后直接退出。不带链接时它序列化成和以前一字不差的 {"type":"activate"}(skip_serializing_if),旧版收到带 open 的也只当普通的 Activate(serde 忽略多余字段),所以不升 PROTOCOL_VERSION:升了会让 PATH 里已装的 shellrs-cli 全部报版本不符。

    • 服务端在版本检查和「启用外部 CLI」开关之前就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把链接追加进 Shared::activation(Mutex<Option<Vec<OpenLink>>>);请求线程够不着窗口,工作区每 200 ms 问一次 CliServer::take_activation(),是 Some 就 bring_forward,再按顺序 open_link(come_forward_when_opened_again,只在生产路径 Workspace::new 里启动)。
    • 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 bad_request)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
    • 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
  • 外部连接不保存,只开一个标签。 堡垒机按 Xshell 的写法拉起 ShellRS(ShellRS ssh://user[:password]@host[:port] 或 -url … -newtab 名称),开一个终端标签连过去;按 WinSCP 的写法(ShellRS sftp://…,JumpServer 的模板就是单独一个链接,前面可以有 /sessionname=名称)则开一个 SFTP 标签(open_link 按 SshLink::kind 调 connect_host 或 open_explorer),其余规则两种一样(用户定的:和 Xshell 的 -url 一样是临时的;只认命令行参数,不注册系统的 ssh:// / sftp:// 链接、不改安装包;sftp:// 链接里的路径暂不认,scp:// 不做)。

    • HostStore 把它放在单独的 temporary(临时主机)里,并记进 external:host(id) / login(id) / active() 找得到,所以终端、重新连接、「在新标签页中连接」、打开 SFTP、右侧栏工具、状态栏都照常;hosts() 不返回它,所以主机树、开始页、跳板 / 转发的主机选择、凭据用量、password_in_use 都看不到。外部 CLI 例外(用户定的):CliTarget::all 在保存的主机后面接上 temporary_hosts(),HostInfo::temporary 为真(#[serde(default)],旧版应用的回答读成保存的主机,不升协议版本),表格的分组一栏写「(外部连接)」(做临时连接时要让 HostInfo 分得出两种);它有自己的 public_id(unused_public_id 两边都查),每条命令照常新建连接、用内存里的链接密码登录,一次性令牌的堡垒机会拒绝这次登录(Termark 等同类工具也是每条命令新建连接)。id 取自 next_host_id,和保存的主机永不相撞。set_state / set_host_os / 书签只改内存(不进 recent、不写库:书签表的外键会失败)。会写外键的检查(normalized_route、route_login、转发、片段的范围、拖动排序)用私有的 saved_host,不用 host。
    • 终端只开一个通道(HostLogin::shell_only,login_of 只对 is_external 的置上):用户的堡垒机(老 Java 上的 Apache SSHD)在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会话,终端里先是「No more authentication methods available」(它正替终端登录目标机时探测通道到了),或者刚出提示符就「进程已退出,退出码 0」(探测命令跑完、通道一关)。Xshell 和 OpenSSH 只开终端这一个。所以 ssh/transport.rs 对 shell_only 不做系统探测、Exec 直接答 SHELL_ONLY 不发给服务器(a_terminal_a_bastion_host_opened_carries_nothing_beside_it 守着),右侧栏只给不执行命令的工具(ToolKind::runs_commands 为假的只有命令片段,offered_tools 看 is_external)。延迟测量是全局请求、不开通道,照常。SFTP 标签本来就只开一个通道(sftp/client.rs 的 sftp 子系统,延迟同样是全局请求),所以 sftp:// 的外部连接不用另外处理;以后给 SFTP 加别的通道(执行命令之类)要对 is_external 关掉。
    • 登录用「密码」方式,login_of 把临时主机的密码引用换成 SecretRef::Temporary { host }:链接里的密码只在 TemporarySecretStore 的内存里(钥匙串、数据库都不进),也不读同一 user@host:port 的已保存密码;没带密码就照常 none → 弹框。
    • 终端和 SFTP 的 Closed 处理器最后调 forget_unused_temporary_host:主机是临时的、而且没有标签了,就取消它的问答并 remove_temporary(连同内存密码和书签)。终端标签菜单没有「编辑主机…」(TabMenu::editable),on_edit_host、on_delete_host 只认保存的主机。
    • 打开时标签占满窗口(用户要的「终端默认全屏」「SFTP 全屏显示」,选的是收起左侧栏):open_link 成功后左侧 Dock 开着就 toggle_dock 收起,和 ⌘B 一样,用户按 ⌘B 或双击标签展开;解析失败什么也不打开时不收。不用 Dock 的放大,也不动窗口本身。
    • 命令片段照常全有:片段面板列出库里的全部片段,不看是哪台主机(store.snippets()),界面只会建「所有主机」范围的片段。以后做「适用范围」(分组 / 主机)的筛选时,外部连接和临时连接不属于任何分组、也不是保存的主机,只显示「所有主机」的(用户定的)。
    • 链接解析失败(不是 ssh:// / sftp://、缺用户名 / 地址、端口不对)弹「无法打开链接」通知,文字不带链接原文(可能有密码)。第二个进程在 Windows 发布版里没有控制台,所以错误一律由正在运行的那个显示。
    • tests/cli_bin.rs 的 shellrs_opened_with_a_link_hands_it_to_the_running_app 用真的 shellrs 二进制交接链接(含中文 -newtab,Windows CI 上也跑),子进程 20 秒不退就杀掉,免得交接坏了起出一个窗口。
  • macOS 上关窗口是隐藏,不是退出。 ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(QuitMode::Default),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 main 给窗口挂 app::hide_when_closed(on_window_should_close 里 cx.hide() 并返回 false),点 Dock 图标(Application::on_reopen)和再次打开 ShellRS(come_forward_when_opened_again)都走 app::bring_forward:先 NSApplication::unhide(只 activate 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 hide() 是 unimplemented!(),所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。

  • 在线升级的接入照外部 CLI 的先例。 new_with_services 只建一个没有服务的 Updater(阶段 Off、从不联网),生产路径 Workspace::new 再 set_services(UpdateServices::system(cx.app_path())) 和 start();UI 测试经 workspace.updater() 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 AppSettings::update(automatic、channel,后者直接用 update::Channel,在 settings 里实现 Choice),工作区在 observe_in(&settings) 里 sync_updater(set_automatic、set_channel),update 模块不依赖 settings。动作是 CheckForUpdates / DownloadUpdate / ShowUpdate / RestartToUpdate / OpenDownloadPage,处理器在 workspace/updates.rs;对话框里「重启会中断什么」由工作区数(restart_impact:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 cx.set_restart_path + cx.restart(),它会照常跑 on_app_quit(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。Windows 不在退出过程中起进程:gpui 退出时在 App 仍被借用的状态下跑 on_app_quit,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 update::start_handed_over(),由 main 在 run() 返回之后启动(Windows 的事件循环结束后 run 会返回)。更新前写 updates/applied.json(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 app::updates_dir()(用户缓存目录;设了 SHELLRS_DATA_DIR 时在数据目录下)。

  • 发布走 tag。 推 v<版本>(和 Cargo.toml 一致,CHANGELOG.md 里要有 ## [版本] 一节)触发 .github/workflows/release.yml:分平台构建打包(packaging/macos/bundle.sh、packaging/windows/shellrs.iss、packaging/linux/appimage.sh),在需要人工批准的 release 环境里上传 R2、建 GitHub Release,最后才用 packaging/make-manifest.sh 生成并上传清单、清 CDN 缓存,再调用部署挂钩(SITE_DEPLOY_HOOK)让官网 shellrs.com 重新构建。清单格式见 packaging/manifest.example.json(the_example_manifest_parses 守着它和解析器一致,the_release_script_signs_what_the_client_accepts 守着脚本和验签一致)。一次性准备和 secrets 见 docs/release.md。

  • 库只往前升,旧版本不碰新库。 migrate 遇到 user_version > SCHEMA_VERSION(或早于第一步)直接报错、不动文件(open_store 退回内存 store 并提示);迁移已有的库之前先 VACUUM INTO '<库>.v<旧版本>.bak'(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。

  • 窗口还在搭的时候不能直接弹通知。 通知层属于窗口的 Root,而 Root 要等 open_window 的构造闭包返回才挂上;在那之前 window.push_notification 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。main 里读库 / 读设置的问题、Workspace::new 里外部 CLI 启动失败,都用 workspace::notify_once_open(window.defer)延后到窗口建好。cx.emit 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。

  • 一个命令一个处理器。 按钮、菜单、右键菜单、快捷键都只派发 app/actions.rs 里的动作,由 Workspace 处理。新增命令加在那里,不要写临时闭包直接改状态。

  • 设置写穿到 JSON,由工作区落到窗口上。 设置是应用级的,不进主机数据库,存在 settings.json(app::settings_path),整文件先写临时文件再改名替换。SettingsStore 和 HostStore 一样内存说了算:update 改内存 → 写文件 → notify,写失败发 SettingsStoreEvent::PersistFailed,工作区弹错误通知;update_unnotified 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 Window);工作区 observe_in store 调 settings::apply,另外 observe_window_appearance 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(ToggleTheme)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(set_locale),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 locale 的解析。

  • 外部 CLI 经由正在运行的应用执行。 命令本身不读钥匙串、不开数据库,只把请求交给应用:所以不会多弹钥匙串授权框,也不会和「内存说了算」、内存里分配 id 冲突。

    • 服务端:生产路径 Workspace::new 用终端同一个 SshConnector(共享 known_hosts 锁)和 SFTP 同一个 provider 启动 CliServer;new_with_services(UI 测试)不启服务,测试绝不监听真实套接字。工作区在 store / 设置变化时 sync_cli_server:主机快照(CliTarget::all,带分组全路径)和「启用外部 CLI」开关。
    • 一直监听,按开关拒绝:开关关着时回 not_enabled,Agent 才分得清「没运行」和「没开」。套接字 0600,每个连接再用 peer_cred 核对 uid。残留的套接字文件:连得上说明另一个实例在跑(启动失败并弹通知),连不上就删掉接管。
    • Windows 用命名管道,两端互相确认。 管道名是整机的命名空间,不在用户自己的目录里,所以:
      • 服务端每个实例都带 DACL D:P(A;;GA;;;<当前用户 SID>) 和 PIPE_REJECT_REMOTE_CLIENTS,第一个实例带 FILE_FLAG_FIRST_PIPE_INSTANCE(名字已被占用就启动失败,报 AddrInUse)。DACL 在创建时原子生效,所以不像 Unix 那样再查对方的身份(查进程属主会误伤以管理员身份运行的 Agent)。
      • 客户端用 SECURITY_IDENTIFICATION 打开(抢注者不能冒充当前用户),发请求之前用 GetSecurityInfo 确认管道属主是当前用户或 BUILTIN\Administrators(提权运行的 ShellRS 建出来的属主是后者),否则拒绝。
      • 用同步的 Win32 管道,不用 tokio 的:请求线程要阻塞的 File,而 tokio 的管道是 overlapped 句柄,拿去做同步 IO 时 std 会中止进程。
      • 监听线程先建好下一个实例再把已连接的交给请求线程,否则那一刻来的客户端会看到「没运行」。请求线程结束时直接关句柄,不要 DisconnectNamedPipe(会丢掉客户端还没读的数据)也不要 FlushFileBuffers(客户端挂住时会一直阻塞)。停止时置标志、反复以客户端身份连一下唤醒 ConnectNamedPipe,直到线程结束。
    • Windows 的 PATH 是副本加用户 PATH。 IntegrationPaths::system() 在 Windows 上:exe 是 shellrs.exe 旁边的 shellrs-cli.exe,bin_link 是 %LOCALAPPDATA%\ShellRS\bin\shellrs.exe(副本,不是链接),user_path 是 UserPath::Registry(HKCU\Environment\Path,写回 REG_EXPAND_SZ 后广播 WM_SETTINGCHANGE;只有之后启动的程序看得到)。副本按长度加修改时间判断是否本版(安装时用 set_modified 把时间设成源文件的)。正在运行的副本不能覆盖但能改名,所以安装、移除都先把旧的改名成 shellrs.exe.<uuid>.old 再尽量删掉。生产环境启动时 CliIntegration::update_outdated 在后台更新旧副本,否则 Agent 会一直碰到 version_mismatch。测试一律 user_path: None 或 UserPath::File,绝不碰注册表。开发时 cargo run 只构建 shellrs,要 cargo build 才有 shellrs-cli.exe,否则设置页显示找不到。
    • Windows 只在 CI 上编译和测试(.github/workflows/windows.yml:clippy 全 crate、cargo test --lib cli::、cargo test --lib update::、cargo test --lib sftp::tests::local_listing(本地面板的路径:std 在 Windows 上把路径规范化成 \\?\C:\…,读目录改用 dunce::canonicalize)、cargo test --test cli_bin),本机没有 Windows 工具链;改 pipe_windows.rs、install_windows.rs 的 Windows 部分和 cfg(windows) 代码后要看 CI。它不是每次推送都跑:只在推送改到 paths 里列的文件(含 cfg(windows) 等平台代码的文件、build.rs、Cargo.toml / Cargo.lock)时才跑,tag 不触发;在别的文件里新加 Windows 代码时把文件加进这个列表,或者在 Actions 里手动运行。
    • 每个请求一个线程,后端方法都是阻塞的;SSH 句柄绑在创建它的 runtime 上,所以 ssh::run_command 和 SFTP 工作线程各自建 current-thread runtime。
    • exec 不许挂住:SshPrompts 非交互;陌生主机在 events 回调里直接返回 false(ask 没人回答会永远等下去,因为超时不计提示时间),缺凭据由 MissingCredential 失败。exec(true) 后立刻 eof(),远程没有 stdin;读循环读到 Close / None 为止,退出码可能在 EOF 之后才到。输出回调写失败(Agent 断开)就放弃命令、断开连接。
    • 传输无人值守:SshCliBackend::transfer 先等 Connected 再发请求;连接前的 Prompt 一律回 Cancel 并按提示种类给出错误码;Question 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 Idle 为结束。请求用 UploadRequest::scp / DownloadRequest::scp:worker 连上后按 scp 规则解析目标(scp_remote_target / scp_local_target,目标目录必须存在,远程还要 stat 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 0o777,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 Idle。
    • 安装集成的路径保护:CliIntegration 没有路径(UI 测试默认)时页面显示「暂不支持」、没有安装按钮,测试不会写到真实的 ~;需要时用 workspace.cli_integration() 注入临时目录。安装 / 移除是动作(InstallCliCommand、RemoveCliCommand、InstallAgentSkill(AgentKind)、RemoveAgentSkill(AgentKind)、RefreshCliIntegration、CopyAgentSkill),由工作区在后台线程执行后 refresh。
  • 终端字体走全局量。 终端模块不能依赖 settings,所以 settings::apply 把字体设成 terminal::TerminalFont 全局量并 window.refresh(),TerminalView 在 render(字体族、字号)和 prepaint(行高 row_height,按设备像素取整)里读它;格子变了,下一帧 prepaint 就按新尺寸 resize,已打开的终端立即生效。默认值就是加设置之前的样子:主题的等宽字体(macOS 上是 Menlo)、13 px、行高 1.54(13 × 1.54 取整后正好是原来的 20 px 行)。字体族存 None 表示跟随主题默认,下拉里选默认那一项也存 None。文件里的字体族只有装着才用(is_font_installed,进程内读一次字体列表;GPUI 找不到字体族时会换成别的字体),否则退回默认,文件里的值保留。数字在 AppSettings::normalized 里夹到范围并取整(字号整像素、行高两位小数),读文件和每次修改都过一遍;gpui-kit 的数字框用 f64::to_string 显示,步进 0.1 会显示 1.7000000000000002,store 取整后数字框下一次渲染会同步成取整后的值。字号用下拉不用数字框:数字框每敲一个字符就夹到最小值,输入 14 时敲下「1」就变成 8。字体下拉只列等宽字体:monospace_font_families 要加载每个字体族(macOS 上约 150 ms),在后台线程跑一次、进程内缓存,结果回来前下拉里只有默认字体;判定是「i」和「M」一样宽且窄于 0.8 em,后一条排除 Webdings 这种方块符号字体。预览 TerminalFontPreview 用和终端一样的排法(每格一个字形、宽字符后面补一个空格格子、终端的行高和配色),不是普通文字排版,否则中文的宽度和终端里对不上。

  • 持久化写穿,内存说了算。 HostStore 带 Context 的 mutator 负责「改内存 → 写库 → notify」,*_unnotified 那一半是纯内存的(给单元测试和加载用)。写库同步发生在 UI 线程:要落盘的都是用户在对话框里确认的单行写入,不是流。写失败时内存里的改动照样生效,store 发 HostStoreEvent::PersistFailed,工作区订阅后弹一个错误通知——绝不静默吞掉。ConnectionState 是运行时状态,不入库;last_connected_at 入库,开始页的「最近连接」因此能跨启动。删除分组靠数据库两个外键的 ON DELETE CASCADE,内存里的 remove_group_unnotified 必须给出一样的结果,并把被删主机的 id 返回给工作区去关标签页。

  • 主机系统每次连上都重探一遍。 认证过、shell 起来、Started 发出去之后,传输层另开一条 exec 通道跑 uname -s; cat /etc/os-release,在主 IO 循环里和 shell 的输出一起轮询,所以终端不会为了探测多等一个往返。POSIX 那条什么都没输出(Windows 的 shell 就是这样)时再问一次 cmd /c ver,还是不认就放弃,主机保留原来的标记。结果沿 TerminalTransportEvent::HostOsDetected → 引擎 → TerminalView → TerminalPanel → 工作区 → HostStore::set_host_os 这条链走,和 prompt 是同一套管线。os 列由 set_host_os 单独写,update_host 不碰它(同 last_connected_at)。标记是 shared::HostMark,主机树、开始页、远程终端标签和 SFTP 标签共用:探到了就用该系统的品牌色打底、白色(浅色品牌用黑色)画 logo,没探到就退回 muted 底色加主机名称第一个字。徽章沿用 Avatar 的圆形画法,尺寸走 Sizable——树里和远程终端 / SFTP 标签上用 small(20px),开始页用默认的 medium(32px)。AlmaLinux 和 macOS 没有单一品牌色,改用 foreground / background,这也是 Apple 官方的画法。品牌色写在 host_os! 表里而不是渲染处,那是设计规范允许的「颜色本身即数据」那一档。主机树里分组行和其他行用 plain_mark 占同样宽度的槽位,否则标签对不齐。主机树里的徽章不带自己的 tooltip(without_tooltip),因为整行已经有一个,两个会叠在一起。

  • 登录快照在 UI 线程解析。 工作线程读不到 store,所以终端、SFTP、端口转发的 provider trait 和外部 CLI 的 CliTarget 拿到的都是 HostLogin(HostStore::login(id) / login_of,凭据已经查好:用户名、方法、私钥路径、密码这一步查的钥匙串键,以及 route: LoginRoute——每台跳板主机的登录也查好了,JumpLogin::Host { name, login } 或已删除的 JumpLogin::Deleted;跳板自己的 route 一律是直连;主机表单测试连接时用 HostStore::route_login 把表单上的 Route 解析成同样的东西),不是 Host。HostLogin 只含影响登录的字段并派生 PartialEq(JumpLogin 手写 PartialEq、不比 name,跳板改名不让后面的主机重连),主机 update 和 update_credential 都在改之前取一遍全部主机的快照(logins),改完按「前后快照不相等」逐台发 ConnectionSettingsChanged(emit_changed_logins),所以只改名称不重连,而改一台跳板的地址或凭据时经由它的主机也会重连;删除主机不发(同删除凭据,已打开的连接不断)。引用凭据的主机在内存的 Host.user 里冗余存一份凭据的用户名(auth 存默认的 Password;库里这一行的 username 是 NULL、auth 是 credential),store 在保存主机(normalized)、改凭据和 load 时保持一致,所以状态栏、tooltip、搜索、CLI 列表、SFTP 续传键这些读 host.user 的地方不用知道凭据。删除凭据不发重连事件:已打开的连接不断,运行中的转发断线重连时可能因为缺密码停下。

  • ShellRS 保存的私钥是数据目录 keys/ 下的文件。 目录由 HostStore::with_key_dir 给出:main 只在库打开成功时才给(app::keys_dir()),否则一个关掉就忘的 store 会留下没人用的私钥;没有它时对话框的「粘贴」「生成新密钥」禁用并说明原因,大多数测试也是这样。文件名随机,目录 0700、文件 0600,写入经临时文件再改名,登录读到的永远是完整的旧钥或新钥。「是不是 ShellRS 保存的」只看路径是否直接在这个目录下(Credential::keeps_key_in,列表里这种凭据写「deploy · 密钥」而不是随机文件名)。save_private_key 在凭据原来的私钥就是 ShellRS 保存的、而且没有别的凭据或主机在用时原地覆盖:路径不变,用它的主机不会被重连去拿一把服务器还不认识的钥匙;否则写新文件。改凭据(换了文件或类型)和删凭据时,store 的 forget_key_file 在没人再用这个文件时删掉它和它的口令条目;不在这个目录下的文件是用户自己的,从不删除或覆盖(a_kept_key_goes_with_the_last_credential_using_it 守着)。Cargo.toml 给 rsa、crypto-bigint、crypto-primes、bcrypt-pbkdf、blowfish 开了 debug 优化,否则调试构建里生成 RSA 和用口令加密要好几秒。

  • 秘密只进钥匙串,不进数据库。 database.rs 的 schema_never_contains_secret_columns 守着这条线(它检查库里的每一张表)。写入的唯一入口是 HostStore::save_secret:它在后台线程上调钥匙串,失败同样走 PersistFailed。主机换了端点或被删掉时,store 用纯函数 password_in_use 判断还有没有别的主机在用旧端点,没有才删条目;私钥口令按文件共享,从不自动删。主机对话框里的密码字段是异步预填的,*_loaded 标志没置上之前空字段不算「用户清空了」,所以预填还没回来就点保存不会误删。秘密绝不放进 HostDraft,它派生了 Debug(CredentialDraft 同理)。凭据的密码由凭据对话框经 save_secret 写入 SecretRef::Credential;凭据从密码类型改成别的、或删除密码凭据时,store 删掉这一条。password_in_use 按解析后的登录快照判断,所以用密码凭据的主机不占着端点的密码条目,主机从「密码」切到密码凭据时旧条目会像换了端点一样被清理(「无密码」的快照仍指向端点条目,为密钥之后再要密码的服务器留着,所以切到「无密码」不删);它只删端点和代理的条目,从不删凭据的。代理密码(SecretRef::Proxy,proxy:<用户>@<代理地址>:<端口>)同端点密码:主机对话框用第二个 SecretFields 预填和提交,没有主机再经由这个代理、这个用户名时由 store 删掉(forget_password / password_in_use 同时认两种)。唯一不进钥匙串的是不保存的连接的密码(SecretRef::Temporary):只在 TemporarySecretStore 的内存里,随临时主机一起忘掉。

  • id 由内存分配。 next_host_id / next_group_id / next_forward_id / next_credential_id 四个独立计数器,HostStore::load 把它们置为库里最大 id + 1,所以删掉最大号再重启,这个 id 会被复用。HostStore::seed() 生产环境不再使用,是 UI 测试的夹具。

  • 对外只给 PublicId,不给 HostId。 主机右键「复制 ID」复制的是 Host.public_id:16 位字母数字随机串(rand 的 Alphanumeric,约 95 bit),存在 hosts.public_id(可为空,UNIQUE)。新建和复制主机时由 HostStore::unused_public_id 生成、保证不和内存里已有的重复;update_unnotified 从草稿重建主机时必须带上原来的 public_id,update_host 也不写这一列——改名、改地址都不变,删掉的也不会再出现。用 sqlite3 手工插入的行没有它,在每次打开库时由 fill_missing_public_ids 补上。第三方和将来的 CLI 只认它;它只是标识,不是凭据,别拿它做鉴权。

  • 状态经由 store 流动。 面板和对话框拿到 Entity<HostStore> 的克隆,通过它的方法修改(方法内部会 notify),消费方用 cx.observe(&store, ..)。状态栏得知当前主机的路径是 BasePanel::set_active → store.set_active;on_removed 负责复位状态并发出 TerminalPanelEvent::Closed / ExplorerPanelEvent::Closed,工作区据此清理注册表。

  • Dock 面板需实现 EventEmitter<PanelEvent> + Focusable + Render + BasePanel + Panel,一律用 panel_handle(...) 包装;tab_name 保持 None,由 title 返回富元素——标签页的图标和「×」关闭按钮就是这样来的(ClosableTabTitle 的「×」只派发面板的关闭动作,见下一条)。panel_name() 是持久化键,一经选定不可更改。

  • 标签页全部可关。 TabGroup::close_panel 会拒绝关闭区域内最后一个面板,所以关闭不走 tab group:「×」派发 CloseTerminal / CloseExplorer / CloseLocalTerminal / CloseSettings,⌘W 派发 CloseActiveTab(关工作区记录的最近显示的中间标签,面板在 set_active(true) 时发出 Activated 事件告知),处理器一律用 DockArea::remove_panel 移除。中间区空了就显示最近连接列表:HostStore::recent_hosts() 按「最近一次变为已连接」的顺序给出,set_state 变为 Connected 时把主机移到最前。

  • 终端内容区的右键菜单与查找、清屏。

    • 菜单:TerminalView 自己给出复制、粘贴、查找、清屏。面板通过 set_menu_items 在下面追加一段:远程终端追加打开 SFTP、重新连接、断开连接(DisconnectTerminal 只断这一个标签);本地终端不追加。追加段的构造器在菜单打开时运行,拿到的是事先读好的 TerminalLifecycle,因为这时 TerminalView 正在更新,读它会重入。
    • 动作:FindInTerminal / FindNextInTerminal / FindPreviousInTerminal / DismissTerminalFind / ClearTerminal 和复制、粘贴一样,由工作区通过 active_terminal 转给当前标签的 TerminalView。
    • 快捷键:⌘K 在 Terminal 上下文里是清屏,比全局的 FocusSearch 更深,所以优先生效。
    • 查找是字面量加智能大小写(RegexSearch 本身就是智能大小写)。
      • 渲染时只扫视口,起止扩到所在逻辑行的首尾,每帧几十行。
      • 全量扫描只在查询变化(去抖 80 ms)和上下切换时各做一次,而且是流式计数,不收集结果:一个字母可能匹配上百万次。release 构建里一万行乘 200 列约 15–55 ms。
      • 焦点匹配按回滚历史的增长量跟着输出上移,再用 regex_search_right 核对,对不上就丢掉。计数不随新输出刷新。
    • 查找条必须是终端 div 的兄弟元素,不能是子元素:否则输入框里敲的键会冒泡到终端的 on_key_down,被发给 shell。它还要 .occlude(),否则点它会被下面终端的鼠标处理器抢走焦点。
    • 清屏只在本地模拟器里做:把光标所在的逻辑行(含折行)挪到第 0 行,清空回滚历史,远端不知情。ALT_SCREEN 下什么也不做,菜单项置灰。
  • 标签右键菜单挂在 ClosableTabTitle 上。 dock 的 Tab 不归我们渲染,所以菜单只覆盖标题元素,Tab 自己左右的内边距右键无反应。双击标题派发 ToggleHostPanel(显示 / 隐藏左侧栏,同 ⌘B),也挂在标题上,所有中间区标签共用;第一下单击照常选中标签,关闭按钮的单击停止冒泡,双击它不会切换侧栏。gpui 的 on_click 只认左键,右键不会切换标签,菜单作用于被点的那个标签,不一定是当前显示的。菜单尾部的关闭一族由 shared::close_tab_items 统一给出:「关闭」派发各类型自己的关闭动作,左侧 / 右侧 / 其他 / 全部派发 CloseTabs { tab: CenterTab, scope: CloseScope };工作区按 TabGroup::panels() 的顺序用 CloseScope::targets 选出标签,再逐个走 close_center_tab(正在上传的 SFTP 照样先确认)。范围是这个标签所在的 TabGroup,不跨分栏。菜单构造器在打开时才运行(不在 render 中),所以可以读 TabGroup 算置灰状态。标签栏「…」菜单(Panel::dropdown_menu)和右键菜单由同一个构造函数给出(远程终端是 TabMenu,SFTP 和本地终端是各自文件里的 tab_menu),两处不许分叉。中间区各面板的 BasePanel::closable 都返回 false:否则 Dock 会往「…」里再加一个自己的「关闭」,走 TabGroup::close_panel,既重复又绕过上传确认;我们自己的关闭走 DockArea::remove_panel,不受这个开关影响。远程终端和 SFTP 标签的自定义名(两个面板各自的 custom_title)只活在内存里,和 Dock 布局一样不持久化;为空或等于默认名(RenamableTab::default_title:终端是主机名称,SFTP 是「主机名称 · SFTP」)时回到默认,默认名随主机改名。SFTP 标签的菜单(explorer_panel.rs 的 TabMenu)是「重命名标签…」、「打开 SFTP」、「复制 IP 地址 / 主机名」、「重新连接」加关闭一族,没有「编辑主机…」;标签栏按钮和远程终端一样是「SFTP」(("open-sftp", id))和「重新连接」(("reconnect-sftp", id))。SFTP 的「重新连接」是 ExplorerCommand::Reconnect:已连接时先 disconnect 再发 Resume(没有已停止的批次时,引擎的「继续」就是重新连接),有批次在跑或停在队首时不可用(后者用队列自己的「继续」)。

  • 焦点规则。 window.dispatch_action 只能到达焦点元素路径上的处理器,所以工作区内部必须有东西持有焦点(启动时聚焦主机面板)。永远不要聚焦工作区根节点的 handle:对话框层是它的子元素,祖先持有焦点会让对话框的焦点陷阱拿不到焦点,导致「取消 / 创建」无响应。标题栏和开始页的按钮改用工作区 handle 上的 FocusHandle::dispatch_action 派发。对话框打开后焦点停在对话框宿主上,直到用户点击某个字段;延迟或下一帧的聚焦请求不会生效,要预先聚焦某个字段只能在 open_dialog 返回后的同一次更新里同步调用(见 open_directory_dialog.rs)。中间区一次只渲染激活的那个标签页,所以每个面板都必须在 set_active(true) 里把焦点收下:失活的标签页要是还攥着焦点,它的 handle 就不在这一帧的派发树里,「×」、⌘W 和所有快捷键会一起静默失效(见 both_tabs_of_one_host_stay_closable)。

  • 树的语义。 树行的左键按下会同时选中并切换展开(无法抑制);双击打开靠 ListItem 上的 ClickEvent::click_count() == 2。TreeState::set_items 会清空选择,HostPanel::rebuild_tree 按 id 恢复选择,展开状态由它自己的 expanded 集合维护。对话框新建出来的节点靠 HostPanel 比对 known_groups / seen_hosts 快照来发现,然后展开祖先链并选中——对话框不需要回调面板。

  • 侧栏三个列表的行 tooltip 都开在行的右侧,共用 shared::RowTooltips。 主机、端口转发、凭据一样:悬停行 500 ms 后在行右侧弹出,配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字),第一行是要点,有补充时下面用小一号、淡一些的字(RowTooltip::note,最宽 20 rem、自动折行,超过 6 行省略)。主机是 user@address:port,下面是备注(不显示系统);转发是完整的「本地转发 · 8080 → db:3306 · web-01」(行里第二行会截断,出错时还会换成原因);凭据是行里第二行的全文「deploy · id_ed25519 · 2 台主机」,密钥凭据下面是私钥文件的完整路径。转发和凭据原来用 ListItem::tooltip(GPUI 自己的):贴着鼠标弹、盖住下面的行、popover 色,和主机列表不一样,用户要统一,统一成了主机列表的样子。只有这类 tooltip 用反色:gpui-kit 的 tooltip 和菜单、下拉共用 popover 色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip 只开放给它自己的组件(Button::tooltip_placement),普通元素的 .tooltip() 只会贴着鼠标弹,所以每个面板持有一个 RowTooltips<K>(里面是一个 gpui_kit::base::TooltipOverlay,overlay() 作为面板的子元素渲染,面板收起就一起消失;动画仿 gpui-kit 自己的),渲染行时 row(key, RowTooltip).attach(row):行在 on_hover 里带着行的边界 request_show(...placement(Placement::Right)),任意鼠标按下立即 hide,拖动中不显示。只有 tooltip 的当前主人才能收起它(owner):鼠标往下移时,下一行的「进入」先于这一行的「离开」触发,这一行的 request_hide 会把下一行刚要的 tooltip 在 300 ms 后收掉,往上移则不会——所以离开的行只在自己仍是主人时才收(the_row_tooltip_follows_the_pointer_down_the_list)。按键会收起 tooltip 是 GPUI 的 HoverListenerMode::InputModalityAware:键盘输入期间不算悬停,鼠标一动就恢复,这是有意保留的。行的边界由行里一个绝对定位的 canvas 在 prepaint 记下,必须带 top_0().left_0():不设偏移的绝对定位元素会排在内容下方(gpui-base 的 ElementExt::on_prepaint 就是这样),tooltip 随之偏下将近一行。绝对定位是相对直接父元素的,而 ListItem 把子元素放进内容盒、suffix 是内容盒的兄弟,所以挂在行上的 canvas 量到的只是内容盒,不含 suffix:转发行右端的启停按钮用 exclude 包一层,它自己再量一次,tooltip 按两者的并集定位,否则会压在按钮上(a_forward_rows_tooltip_opens_beside_it_and_makes_way_for_its_switch)。exclude 的另一半:按钮有自己的 tooltip(「启动」「停止」),指针在按钮上时行的 tooltip 立刻收起,回到行的其他地方再出来;离开按钮时看指针还在不在行的范围里,而不是看行的悬停状态,因为往下移到下一行时按钮的「离开」先于行的「离开」触发。行里以后再加带 tooltip 的按钮,照样 exclude。

  • 树的右键菜单挂在容器上,不挂在行上。 gpui-component 的 ContextMenu 在 request_layout 里给弹出菜单抢焦点,而树行是 uniform_list 在 prepaint 阶段才渲染的:焦点于是在一帧的中途易主,gpui 的无障碍断言会 panic(set_focus called more than once in a single frame,只在开了辅助功能的调试构建里出现)。所以 HostPanel 把 .context_menu(..) 挂在 #host-tree 容器上,右键命中的节点由行上的右键处理器写进一个 Rc<Cell<Option<HostNode>>>(容器在捕获阶段先清空它,空白处右键因此得到根菜单)。gpui-kit 自己的 Table 也是这么挂的。开始页的列表 recent-host-list 照样挂在容器上(右键行同时选中该行,空白处得到空菜单、不弹出);开始页是 deferred 画的,但 Deferred 的子元素在主布局阶段就做 request_layout,菜单抢焦点仍早于任何 prepaint,不会触发上面的断言。

  • 表单里的几选一用 shared::SegmentedControl。 用户给的样式:背景色的轨道加 border 细边框,选中的一段是 accent 底色、比轨道深一级的细边框(border 混入 8% 的 foreground,浅色主题约 neutral-300,深色主题反过来变亮)和轻阴影的块(截图里的灰太深,secondary_active 是 neutral-300,用户要浅一些;accent 浅色主题是 neutral-100,深色是 neutral-800),没选中的是 muted_foreground 字、悬停时半透明的 accent 底,比选中的淡;各段等宽铺满轨道(flex_1),高度是中号控件的 h_8,选中块的圆角按 2 px 内缩与轨道同心。行为全来自 gpui-base 的 Radio / RadioGroup(点击、回车 / 空格、每段一个 Tab 停靠点、无障碍角色),这里只管外观;每段的元素 id 是它的序号,和 gpui-component 的 RadioGroup 一样,所以测试照旧 within(组).click(1usize)。gpui-base 的 RadioGroup 是 RenderOnce,不能 test_support(),测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;右侧栏工具切换列表也用它:shared::count_tabs 是小号(small(),h_7)、每段带数量(Segment::count,淡色数字紧跟文字,无障碍名是「容器 9」)的它,外面包一个带 id 的盒子给测试量宽度,四段均分整行(用户选的),只给标签短的 Docker 用:系统服务的「已停止 202」在 320 px 均分的一段里放不下、被缩略,用户让它换回 gpui-kit 的下划线 TabBar(按文字宽度排,放不下时左右拖动)。不用 gpui-kit 的 TabBar:它的标签按文字宽度排,从外面给 Tab 加 flex_1 不起作用(分段样式为了滑动高亮还给每个标签包一层不伸缩的盒子),标签挤在左边、右边空一截底轨;Tab::suffix 又在标签居中的内容框外面,拉宽的标签里会被推到最右边(用户指出过)。assert_tabs_share_the_width(tests/ui/support)守着 Docker 的各段等宽、占满整行。详情对话框里的「状态 / 日志」「详情 / 日志」是同一对象的分页,仍用 gpui-kit 的下划线 TabBar。端口转发的三张类型卡片带说明和示意图,仍是 gpui-component 的 RadioGroup。

  • 主机对话框和转发对话框一样从窗口高度的 5% 开始、最高占 90%,选了跳板或代理、框里的内容多起来时正文滚动、按钮不动。

  • 对话框点外面一律不关。 点到对话框外面(半透明背景)就把填了一半的内容丢掉不合适,所以每个 window.open_dialog 都要 .overlay_closable(false)(默认是 true,新加对话框别漏):只能用「取消」、右上角的关闭按钮或 Esc 关(a_click_beside_a_dialog_does_not_close_it 查主机、分组、端口转发和凭据四个)。open_alert_dialog 开的确认框本来就不会被点外面关掉。

  • 表单的校验错误一律弹错误通知。 新建 / 编辑对话框(主机、分组、端口转发、凭据,SFTP 的重命名 / 新建和「打开目录」)校验不过时 window.push_notification(form_error_notification(..)) 并让 on_ok 返回 false,不在表单底下放错误行(用户要的样子)。同一种通知新的替换旧的,不会一次次叠起来;点它就收起;每个对话框的 on_close 调 dismiss_form_error,免得对话框关了错误还挂着。从别处 window.close_dialog 不经过 on_close(凭据对话框在口令加密完后就是这样关的),那里要自己调。新加的对话框照此办理(a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it 逐个查)。

  • 下拉框攥着焦点时,提交会把它弹开。 对话框的提交按钮(DialogAction)和回车都是 window.dispatch_action(Confirm),先到焦点元素;gpui-base 的 Select 在收起状态下收到 Confirm 会打开下拉并继续冒泡,所以表单照常校验 / 提交,但校验不过时下拉会跟着弹开,盖住下面的字段,下一次点击只是把它收起。这是 gpui-kit 的行为。转发对话框的「经由主机」、主机对话框的「凭据」「分组」都没有绕开(ForwardForm::focus_bind_port 只在打开新建对话框时聚焦一次,代码里没有订阅 SelectEvent;「分组」是最后一个字段,没有下一个字段可去)。UI 测试里选完这些下拉之后要先点一下别的字段再点 commit,否则后面第一次点击会被吃掉。

  • 对话框关闭后焦点会丢。 这是 gpui-component 的既有行为,不是本仓库引入的:关掉对话框后 window.dispatch_action 没有落点,要先点一下面板里的东西。UI 测试里同理(见 groups_and_hosts_are_read_back_from_the_database)。

  • 通知层要 deferred 并高过对话框。 gpui-base 的对话框(连同半透明背景)是用 deferred 画的,优先级是 10 + 对话框的层号(gpui-base 的 dialog.rs;开始页是 0);通知层的优先级比它低就会画在对话框背景底下,被压暗,点它点到的是背景。所以工作区把 Root::render_notification_layer 包成 deferred(..).with_priority(NOTIFICATION_PRIORITY)(= POPUP_PRIORITY - 1 = 99:高过任意层叠的对话框,低于菜单等弹出层的 POPUP_PRIORITY = 100 和 tooltip 的 200)。以前这里写成 1,以为对话框是 0,通知一直在对话框背景底下。无头测试里点击走真实的命中测试,被背景盖住的元素收不到点击:a_form_error_sits_above_the_dialog_and_a_click_puts_it_away 点主机对话框的错误通知,它在背景底下时这个测试会超时。

  • 要彩色就不能用 Icon。 GPUI 的 svg() 走 Window::paint_svg,签名只收一个颜色,整张图当遮罩上色,所以彩色 SVG 塞进 Icon 会变成一坨纯色。真要彩色只能走 img()(ImageSource),那条路把 SVG 光栅化成 RGBA,颜色能留住,代价是变位图。系统徽章因此是「主题色的圆底 + 单色遮罩 logo」,不是一张彩图。

  • 渲染回调不得读取实体。 树行渲染闭包和 render_td 运行在所属实体的 render 过程中,只能传入普通快照(例如主机面板里各主机系统的 Rc<HashMap<HostId, HostOs>>)。

  • px(..) 只允许出现在 API 要求 Pixels 的地方(窗口 / Dock / 分栏尺寸、表格列宽、缩放字号),其余一律用 rem 助手和 cx.theme() token。

测试约定

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(系统监控)、snippets(命令片段)、history(历史命令)、netstat(网络连接)、processes(进程管理)、services(系统服务)、docker(Docker)、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)。单元测试起子进程一律经 crate::testing(sh、sh_accepts,别的程序先拿 no_forks() 再启动):macOS 建套接字和标记「不传给子进程」是两步,别的测试线程恰好在这中间起子进程,子进程就攥着这个套接字的副本,测试关掉的监听套接字照样能连上——cli::tests 里「残留的套接字不算在运行」两个测试因此在加了几个工具的脚本测试之后每轮必挂;建这种套接字的测试同样拿着 no_forks()。不要在 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 / workbuddy,状态行的 aria_label 是显示的文字)、check-cli-integration、copy-agent-skill;设置项同理,下拉按钮是 group-<组> › item-<项> › btn,label 是当前选项的文字)、终端字体预览 terminal-font-preview(aria_label 形如「Menlo 13 px,行高 1.54」)、("close-explorer", id)、标签标题 ("terminal-tab", id) / ("explorer-tab", explorer_id) / ("local-terminal-tab", id)(aria_label 是标签文字)、("terminal-tab-os", id) 与 ("explorer-tab-os", explorer_id)、test-connection、("terminal-latency", id) 与 SFTP 标签的 ("sftp-latency", explorer_id)(aria_label 是「32 ms」或「超时」;SFTP 测试往 FakeSftpProvider::events 里送 SftpEvent::Latency)、terminal-find(查找输入框)、terminal-find-count(aria_label 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、terminal-find-previous / -next / -close、tab-name(重命名标签对话框)、侧栏行的 tooltip host-tooltip / forward-tooltip / credential-tooltip(aria_label 是第一行的文字,主机是 user@address:port;悬停后要 advance_clock 越过 500 ms 的显示延迟)与其下的补充 host-tooltip-note / credential-tooltip-note(aria_label 是全文:主机的备注、私钥文件的完整路径;没有时不存在)、recent-hosts、("recent-host", id)、recent-new-host、host-search、new-host、new-group、theme-toggle、窗口状态栏右侧的终端尺寸 status-terminal-size(aria_label 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 show-hosts / show-forwards(checked() 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 aria_label 是「端口转发,N 条运行中」)、右侧栏的切换 tool-switch 与其中的 tool-snippets / tool-history / tool-docker / tool-services / tool-processes / tool-connections / tool-monitor(checked() 是当前显示的那个,右侧栏收起时都不是)、右侧栏 ("tool-sidebar", 远程终端 id)(aria_label 是工具名,收起或当前标签不是远程终端时不存在);当前标签不是远程终端时 tool-switch 也不存在。命令片段的有:面板 snippets、汇总 snippets-summary(「共 4 个片段 · 2 个分类」,没有分类时只有前半)、按钮 snippets-new / snippets-new-category、snippets-search、列表 snippets-list、一个片段都没有时的 snippets-empty 与其中的 snippets-empty-new、搜不到时的 snippets-no-match、分类标题 snippet-category:<分类 id> 与未分类的 snippet-category:none(aria_label 形如「Docker 2」,搜索时数的是匹配的),卡片 ("snippet", 片段 id)(aria_label 形如「容器 · docker ps -a」,点击时自动执行的后面再加「 · 点击时自动执行」)与它的执行按钮 ("snippet-run", 片段 id)(指针不在卡片上时 visible() 为假、点不到:先把指针移到卡片上再点,snippets.rs 的 point_at);对话框的 snippet-name、snippet-category(包着下拉的 div)、snippet-command(包着 Textarea 的 div,点它再输入)、勾选框 snippet-run-on-click、分类对话框的 snippet-category-name。对话框关掉后焦点是空的,接着 window.dispatch_action 之前先点一下面板(snippets.rs 的 dispatch_from_panel);假终端的 shell 要先跑起来(remote_lifecycle(..).accepts_input()),点片段才有输入。历史命令的有:面板 history、汇总 history-summary(「共 3 条 · ~/.bash_history」)、history-refresh、说明 history-note、history-truncated、history-search、列表 history-list、history-empty、history-loading、问题说明 history-message(没连接、没有历史文件、不支持、读取失败)、卡片 history-entry:<命令>(aria_label 形如「ls -l · 3 分钟前 · 执行 2 次」)与它的执行按钮 history-run:<命令>;点了之后查 FakeTerminalFactory::written_text() 新增的那一段(「\x05\x15ls -l」,执行的末尾多一个 \r)。系统监控的有:面板 monitor、展开按钮 monitor-cores-toggle / monitor-interfaces-toggle(checked() 是展开着;前者只在核多到一行放不下时有,后者只在多于 1 块有流量的网卡时有)、读到第一次之前的 monitor-loading、问题说明 monitor-message(aria_label 是文字:没连接、不支持、读取失败)、monitor-host / monitor-arch / monitor-os / monitor-uptime(aria_label 是值,运行时长形如「62 天 00:39:24」)、monitor-cpu-usage(aria_label 形如「50.0%」,第一次读数时是「—」)、每核 monitor-cpu-cores(多于 1 核就有,aria_label 是显示出来的那些核,「核 0:100.0%,核 1:33.3%」)与其中每根竖条 ("monitor-core", 序号)(sixty_four_cores_show_one_row_until_unfolded 量它们的位置;收起时一行的个数要等第二帧,测试里先 simulate_next_frame)、monitor-memory-usage / monitor-memory-amount(「421.02 MB / 973.25 MB」)、有交换空间时的 monitor-swap-usage / monitor-swap-amount、网卡 monitor-interface:<名称>(「上传 5.44 KB/s,下载 3.88 KB/s」)、磁盘 monitor-disk:<挂载点>(「35.1%,3.09 GB / 9.21 GB」)。网络连接的有:面板 netstat、汇总 netstat-summary(aria_label 形如「共 5 条 · 3 个监听端口 · 1 条已连接」)、netstat-refresh、netstat-search、下拉 netstat-protocol / netstat-state(UI 测试不点下拉,筛选由 model.rs 的单元测试覆盖)、有搜索或筛选时的 netstat-matches(「筛选出 2 条」)、列表 netstat-list、一行都没有时的 netstat-empty、读到第一次之前的 netstat-loading、问题说明 netstat-message(没连接、不支持、读取失败)、netstat-truncated、看不全进程时的 netstat-processes(不是 root,或主机没有 ss)、卡片 netstat-socket:<协议> <本地> <远端>(如 netstat-socket:tcp 0.0.0.0:22 0.0.0.0:*,aria_label 形如「TCP 监听 · 本地 0.0.0.0:22 · 远端 0.0.0.0:* · sshd (PID 891) · root」,没有进程或用户的写「—」;列表是 uniform_list,只有看得见的卡片在元素树里)。进程管理的有:面板 processes、汇总 processes-summary(「共 3 个进程 · 每 15 秒刷新」)、processes-refresh、processes-search、排序按钮 processes-sort-memory / processes-sort-cpu(checked() 是正按它排的那个)、当前列出的个数 processes-count(aria_label 是数字)、列表 processes-list、一个都没有时的 processes-empty、processes-loading、processes-message、processes-truncated、卡片 ("process", PID)(aria_label 形如「java · PID 2202 · 休眠 · root · 内存 468.75 MB · 24.0% · CPU 5.0%」,第一次读数时 CPU 是「—」;测试量各卡片的 bounds 看顺序);结束进程直接派发 EndProcess { pid, force },确认框点 ok。详情对话框:正文 process-details、每项 process-field:<标签>(如 process-field:父进程,aria_label 是值)、命令行 process-command(aria_label 是整行)与读取中的 process-command-reading、读不到时的 process-command-unknown、按钮 process-copy-command / process-copy-pid / process-end / process-force-end;假终端要在两次读数之后再给一个 NUL 分隔的命令行输出。系统服务的有:面板 services、汇总 services-summary(「systemd 249 · 降级运行」)、services-refresh、services-search、标签栏外包的 services-tabs(within("services-tabs").click(3usize) 是「失败」,aria_label 形如「失败 1」)、列表 services-list、分组标题 services-group:custom / services-group:system(aria_label 是「自定义服务 1」)、卡片 service:<名字>(aria_label 形如「socialc.service · Social Callback Proxy Service · 运行中 · 已启用」)与上面的按钮 service-stop:<名字> / service-restart:<名字> / service-start:<名字> / service-more:<名字>、services-empty、services-loading、services-message;详情的正文 service-details、标签栏 service-tabs(1usize 是日志)、每项 service-field:<标签>、日志 service-journal(aria_label 是全文)与读取中的 service-status-reading / service-journal-reading、读不到的 service-status-unknown / service-journal-unknown、底部按钮 service-dialog-<动词>(stop / restart / start / enable / disable);假终端按顺序回答列表、@@status 0、状态、日志。Docker 的有:面板 docker、汇总 docker-summary(「Docker 26.0.0 · Compose v2.25.0」)、docker-refresh、标签栏外包的 docker-tabs(序号 0–3 是容器、卷、镜像、网络,aria_label 形如「容器 4」)、列表 docker-list、项目卡片 docker-project:<项目>(aria_label 形如「php-56 · 1/1 运行中」)与点了折叠展开的那一行 docker-project-line:<项目>、容器行 docker-container:<名字>(aria_label 形如「php56 · 运行中 · 56000→9000/tcp」)、按钮 docker-stop:<名字> / docker-start:<名字> / docker-restart:<名字>(项目的是 docker-stop:project:<项目>)/ docker-more:<名字>、卡片 docker-volume:<名字> / docker-image:<键>(仓库:标签,没有标签的是镜像 ID)/ docker-network:<名字> 与它们的删除按钮 <卡片 id>-remove、docker-empty、docker-loading、docker-message;详情的标签栏 container-tabs、正文 container-details、各行 container-basics:<标签> / container-ports:<端口> / container-mounts:<来源> / container-labels:<键>、container-environment、日志 container-output、底部 container-dialog-stop / -start / -restart / -remove;卷、镜像、网络的详情正文 docker-details(读取中 docker-details-reading、读不到 docker-details-unknown),各行同上换前缀 volume- / image- / network-,底部 docker-dialog-remove。假终端 FakeTerminalFactory::answering(&[输出…]) 依次回答旁路命令(最后一个重复,不看命令内容),exec_commands() / exec_count() 是执行过的命令,没给输出就丢掉请求、status-connection、commit、ok(确认对话框)、form-error(对话框的校验错误,在错误通知里,aria_label 是错误文字)、host-name / host-address / host-port / host-user / host-password、「无密码」下面的说明 host-no-password-note、group-name、remote-path。SFTP 相关的还有:各侧面板里的 path-select、bookmarks、up、root、home、refresh、back、forward、upload(本地)/ download(远程)、delete、rename、properties、new(同名按钮在两侧都有,要用 within((pane, id)) 查)、行 file:<名称>(拖放目标;行中心是空白处,点选、双击、⌘ / Shift 单击和拖文件都要对着 name:<名称>)与名称 name:<名称>(aria_selected 表示已选中)、名称单元格 name-cell:<名称>(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 size:<名称>(框选测试从它拖到另一行的它)(aria_label 是显示的大小,目录为空)、列标题 column:<列键>、框选时的 selection-rectangle、列表为空时的占位文字 list-placeholder(aria_label 是显示的文字;假传输的 slow_home 让远程主目录永远不回来,用来测慢网)、文件拖动预览 file-drag-preview、("file-list", 0|1)、entry-name(重命名 / 新建表单)、("perm", 0..9) 与 perm-octal / perm-recursive / perm-dir-x(属性对话框)、download-confirm / download-target / download-browse、上传 / 下载确认框的首行 transfer-summary(aria_label 形如「上传 2 个文件到 …」)与来源清单的行 source:<完整路径>(aria_label 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 local-path / remote-path(aria_value 是已读入的目录,读取中不报,所以可以拿它等列表;aria_selected 表示是当前面板)与其中的 path-parts、各级 path:<目录>、「打开目录/书签」的 open-directory-path / open-directory-browse / open-directory-confirm、书签列表 bookmark-list 与行 bookmark:<路径>(aria_selected)、bookmark-add / bookmark-remove / bookmark-up / bookmark-down、列表下方的状态栏 pane-status(Role::Status,aria_label 是显示的文字)、SFTP 标签栏的 ("open-sftp", id) 与 ("reconnect-sftp", id)、窗口状态栏的 status-connection(aria_label 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 ("transfer-queue", explorer_id)(为空时不渲染)与工具栏的 resume-transfer / cancel-transfer / remove-transfer / discard-transfer / clear-finished-transfers(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 ("queue-entry", 队列序号)(aria_label 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;aria_selected 表示选中)、队首批次的进度列 transfer-status(aria_label 同上,测试用 wait_for_head_status 等它)、其余批次的 ("queue-status", 序号)、当前文件子行 ("queue-file", 序号)(aria_label 是源路径)与它的 ("queue-file-status", 序号)(百分比)、展开按钮 ("queue-expand", 序号)、展开后的 ("transfer-detail-list", 序号)、transfer-detail-empty 与 transfer-detail:<路径>、确认框里的排队提示 transfer-queued-note。假传输 FakeSftpTransport 像真引擎一样在停止和完成后发 Idle,队列靠它推进、问答的 transfer-question-* 与 transfer-apply-all。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(focused().is_some());TestWindowExt::click 不带修饰键,⌘ / Shift 单击用 modified_click 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(on_focus_in、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 window.activate_window() 再 run_until_parked;按上一帧宽度重排的元素(路径标签)要 window.simulate_next_frame(cx) 才会重画。夹具(open_workspace / open_workspace_with_store / open_workspace_with_tester,以及 SFTP、转发、凭据各自的夹具)都会 set_reduce_motion(true):对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(new_host_dialog_validates_then_inserts、renaming_a_group_keeps_the_hosts_under_it 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(ElementSnapshot::value() 返回 None),所以密码相关的断言一律查注入的 InMemorySecretStore,不查界面。动作派发和 set_active 都是延迟执行的:先退出 update_window,调用 cx.run_until_parked(),再 render_frame 后断言。端口转发相关的有:转发列表的 forward-search、forward-list、空状态 forward-empty(aria_label 是标题)与其中的 forward-empty-new / forward-empty-new-host、Dock 工具栏的 new-forward、行 ("forward-row", id)、行尾的状态 ("forward-status", id)(aria_label 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 wait_for_forward_status 等它)和启停按钮 ("forward-toggle", id);对话框的类型卡片在 within("forward-kind") 里按序号 0usize / 1usize / 2usize(RadioGroup 会把子项的 id 改成序号)、forward-diagram(aria_label 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 forward-flow-dot(在某一段线上时才存在,每圈末尾停顿时没有)、forward-explanation(aria_label 是那句说明)、卡片里的用途 ("forward-purpose", 序号)、forward-name / forward-host / forward-bind-host / forward-bind-port / forward-target-host / forward-target-port、forward-auto-start、forward-notes(有提示时才存在)。假传输 FakeForwardProvider 不开任何套接字,记录每次运行(runs(),可以往 events 里送真实转发之后才会报的事件),像真的一样用 Stopped 回应 Stop;ForwardScript 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 open_workspace_with_forwards 和 SFTP 的一样 set_reduce_motion(true),示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 set_reduce_motion(false)(the_forward_diagram_keeps_showing_its_flow 这样做,再用 advance_clock 推进并比较圆点的位置)。凭据相关的有:标题栏的 show-credentials、列表的 credential-search、credential-list、空状态 credential-empty(aria_label 是标题)与其中的 credential-empty-new / credential-empty-generate(「生成密钥…」)、Dock 工具栏的 new-credential、行 ("credential-row", id)(aria_selected 表示选中);凭据对话框的类型在 within("credential-kind") 里按序号 0usize(密码)/ 1usize(密钥)/ 2usize(SSH Agent),密钥的来源在 within("credential-key-source") 里按序号 0usize(本机文件)/ 1usize(粘贴)/ 2usize(生成新密钥),算法在 within("credential-key-algorithm") 里,字段是 credential-name / credential-user / credential-password / credential-key-path / choose-credential-key / credential-passphrase、粘贴框 credential-key-text(外面包的 div;测试里先往剪贴板写再 cmd-v),公钥 credential-public-key(aria_label 是整行公钥)与它的复制按钮 copy-public-key、生成中的 credential-public-key-pending、生成失败的 credential-generate-error,保存会删掉 ShellRS 保存的原私钥时的提示 credential-kept-key-note,加密中的 credential-saving,Agent 的说明 credential-agent-note,编辑时的用量 credential-usage(aria_label 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 store_keeping_keys 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 selected() 表示选中)在 within("host-auth-source") 里按序号 0usize(密码)/ 1usize(使用凭据)/ 2usize(无密码),凭据下拉 host-credential(value 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 host-credential-summary;连接方式在 within("host-route") 里按序号 0usize(直接连接)/ 1usize(SSH 跳板)/ 2usize(代理连接),框里的说明 host-route-note,跳板的链 host-route-chain(aria_label 形如「本机 → 阿里云99 → 当前主机」)、每台一行 ("jump-hop", 主机 id)(aria_label 是名称;已删除的是 ("jump-hop-deleted", 序号))、行尾的移除按钮 ("remove-jump-hop", 序号)、添加用的 Combobox 的触发区 jump-add-trigger(gpui-component 的 Combobox 自己的 input 没有 test_support,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,add_jump_host 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 host-proxy-kind(Select)/ host-proxy-host / host-proxy-port / host-proxy-user / host-proxy-password,最后的备注 host-notes(多行 Textarea 外面包的 div,点它再输入;回车换行,不提交对话框)。FakeConnectionTester::routes() 记下每次测试连接的 LoginRoute 和表单给的代理密码。凭据测试的夹具 open_workspace_with_credentials 关掉动效,远程终端用 RecordingRemoteProvider 记下每次连接拿到的 HostLogin(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 about-version(aria_label 是「v0.1.0」)、「当前版本」下面的状态行 update-status(aria_label 是显示的文字,测试用 wait_for_update_status 等它)、版本号右边同一时间只有一个的按钮 check-for-updates / download-update / show-update / open-download-page、「更新渠道」下拉在 group-0 › item-1 › btn(测试用 set_update_channel 写设置,不点菜单)、「自动升级」开关在 group-0 › item-2 › check;假 feed 按渠道给清单(fixture.offer(Channel::Beta, ..)),fixture.channels() 记下查过的渠道;标题栏的 update-available(aria_label 是「新版本 0.2.0 已就绪」之类);更新对话框 update-dialog 里的 update-ready(已下载好时的那句)、update-progress、update-problem、update-restart-note 和按钮 open-changelog(点了查 cx.opened_url())/ restart-to-update / download-update / open-download-page。serve_updates 给更新器装上 FakeUpdateFeed(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 FakeInstaller(只记录 stage / apply,重启路径是 /Applications/ShellRS.app),重启用 cx.expect_restart() 断言,退出时安装用 cx.update(|cx| cx.shutdown()) 触发。种子主机 id 按插入顺序(web-01=1 … dev-box=6),种子分组同理(生产=1、测试=2、开发=3);web-01 与 staging-api 初始为已连接并已打开终端标签。