文案放进 locales/<模块>.yml,经 crate::i18n 的 t! / tn! 取用;启动时先按设置定语言, 改设置时实时切换并发出 UiLocale。守卫测试检查两种语言齐全、占位一致、没有遗漏和死键。 UI 测试夹具固定中文并隔离本线程的语言。共用件(shared/)先转换。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
52 KiB
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)。界面文案有简体中文和英文两种(中文是源语言,见「多语言」),标识符用英文。功能说明见 docs/manual.md(README.md / README.en.md 是开源首页,截图在 docs/screenshots/)。
- 术语。 保存的一项叫「主机」(量词「台」),代码和库里叫 host(
Host、HostId、HostStore、hosts表)。它以前叫「会话」/Session,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(Host.address)。SSH 协议层的 session(「SSH 会话通道」、russh 的Session、SFTP 会话)照旧。英文用词见「多语言」的用词表。 - 两种不保存的连接。 「外部连接」:堡垒机像调 Xshell(
ssh://、-url)或 WinSCP(sftp://)一样拉起 ShellRS,只开一个终端或 SFTP 标签(HostStore::is_external)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(HostInfo::temporary)。 - 全是真的。 SQLite(
~/Library/Application Support/shellrs/shellrs.db,SHELLRS_DATA_DIR可覆盖目录)、本地终端(portable-pty+alacritty_terminal)、SSH(russh)、SFTP(双栏、传输队列、断点续传)、端口转发(-L/-R/-D)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级、匿名使用统计(Aptabase)。密码和口令只进系统钥匙串(keyring),数据库里永远不出现秘密。
做 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 为准。
已定的产品决定
用户定过的事,不要改回去。细节以 docs/manual.md 和代码为准。
- 界面语言:「设置 › 外观 › 界面语言」有跟随系统(默认:中文系统用简体中文,其余用英文,系统没说时用中文)、简体中文、English。改了立刻生效,不用重启。老用户的 settings.json 里已存着
zh-CN,不迁移。框架用 gpui-kit 自带的rust-i18n,不另引 Fluent 之类。 - SFTP:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(
.开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有shellrs sync)、过滤、查找、目录树和 Dock 布局持久化。 - 主机树:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。
- 主机对话框:
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、
~/.ssh默认私钥,服务器要密码就报错、不询问)。主机自己没有私钥文件,要用私钥就建密钥凭据。 - 「测试连接」是真登录,不强制填密码。
- 连接方式三选一:直接连接、SSH 跳板、代理连接。跳板只走列出的链(跳板自己的连接方式不参与),不做拖动排序;删掉的跳板留「已删除的主机」占位,连接时直接报错。代理是 HTTP
CONNECT或 SOCKS5,失败直接报错、不弹问答。跳板和代理不叠加。
- 认证方式三选一:「密码」(留空就每次连接时询问,不悄悄试密钥)、「使用凭据」、「无密码」(依次试服务器免认证、SSH Agent、
- 凭据:侧栏第三个列表(密码 / 密钥 / SSH Agent),凭据带用户名。
- 密钥的来源「本机文件 / 粘贴 / 生成新密钥」在同一个对话框里(Ed25519 默认,另有 RSA 4096)。粘贴和生成的私钥存成数据目录
keys/下的文件,不进钥匙串:Windows 凭据管理器装不下 RSA 私钥。 - 删除仍在用的凭据要确认,那些主机改回自己登录(密码凭据的改成「密码」,其余改成「无密码」)。
- 主机对话框里不做「新建凭据…」(不叠弹层)。
- 密钥的来源「本机文件 / 粘贴 / 生成新密钥」在同一个对话框里(Ed25519 默认,另有 RSA 4096)。粘贴和生成的私钥存成数据目录
- 端口转发:侧栏第二个列表,不是 Dock 标签;标题栏「ShellRS」后面三个图标切换主机 / 端口转发 / 凭据。
- 一条规则经由一台保存的主机,自己建连接:不算主机的连接状态,主机「断开连接」不停它;删除主机连同规则一起删。
- 「创建」只保存不启动,可设「启动 ShellRS 时自动开启」;新建时「经由主机」留空,由用户自己选。
- 断线按 1、3、10 秒自动重连三次,重连不弹问答。
- 对话框是一行三张类型卡片、实时示意图(服务器按角色叫「SSH 服务器」)、一句话说明和一直循环的流向动画。
- 临时连接与外部连接:
- 临时连接的密码只在内存。
- 外部连接只认命令行参数,不注册系统的
ssh://链接;打开时收起左侧栏。 - 外部 CLI 照样列出这两种主机,分组写「(未保存)」。
- 外部 CLI:
hosts/credentials的list、show、create、update、delete,以及exec、upload、download、sync,写法对齐 termark 的 skill。对象叫 host / host-id,不学 termark 的 asset(ShellRS 只有「主机」一种)。没有顶层的shellrs list(早先的写法,已去掉,不兼容),列主机用hosts list。不做交互式的shell(给人在自己终端里用,Agent 用不上)。- 改主机和凭据跟着「启用外部 CLI」走,不另设开关;校验和文案同对话框。
- 秘密只写不读:JSON 里的
password/passphrase/private_key进钥匙串或keys/,任何输出都没有,show只说有没有保存,ShellRS 保存的私钥连路径也不给。 - 删除不确认:
hosts delete遇到有打开标签的主机报host_in_use,--force才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能show。 sync只从本机到主机,大小和修改时间都没变的跳过;--delete不删.filepart和.shellrs-….backup,本地读不了的目录不清理,拒绝同步到/。
- 开始页:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量
MULTIPLE)。 - 右侧栏:窗口最右一列竖排的工具切换。
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
- 工具从上到下:命令片段、历史命令、Docker、系统服务、进程管理、网络连接、系统监控。
- 已知不是 Linux 的主机不显示系统监控、网络连接、进程管理、系统服务;已知是 Windows 的再去掉 Docker 和历史命令;外部连接只有命令片段。
- 系统监控每 2 秒读一次(运行时长随读数刷新,不自己走),磁盘每 30 秒,只在显示时读。≥ 70% 黄、≥ 90% 红。
- 网络连接、系统服务、Docker、历史命令的列表不自己刷新:打开、换终端、点刷新、执行命令之后才读。进程管理每 15 秒读一次。
- 结束进程,停止 / 重启服务,停止 / 重启 / 删除容器都先确认。不是 root 时用
sudo -n,要密码就报「需要 root 权限…」。 - 系统服务的标签用 gpui-kit 的下划线
TabBar(均分放不下「已停止 202」),Docker 用均分的shared::count_tabs。 - 历史命令读主机的
~/.bash_history,不自己记录;点一条是放进输入行、不执行,另有执行按钮。 - 命令片段所有主机共用(库里预留了「适用范围」列);「点击时自动执行」新建时默认勾上;执行按钮鼠标移上去才出现,位置一直留着。
- 在线升级:
- 清单地址
https://dl.shellrs.com/update/v1/{channel}.json、平台 key 的名字和公钥槽位一经发布就不能改。 - 默认在后台检查并下载,下好、校验完才在标题栏亮按钮(
success色,出错时warning色)。 - 更新对话框不列更新内容:「查看更新内容」打开整页的
https://shellrs.com/changelog,另一个按钮是「重启并安装」,没有「稍后」。不点也在退出时装好。 - 「设置 › 关于」三组:「应用更新」三行(当前版本、更新渠道(稳定版 / Beta,运行时切换)、自动升级),「隐私」一行(见下条),「ShellRS」一行「官网」(打开
https://shellrs.com)。 - 检查只发 User-Agent,不发安装标识。
- 清单地址
- 匿名使用统计:发给 Aptabase(没有 Rust SDK,自己调
POST /api/v0/events;App Key 是analytics::APP_KEY,不是秘密)。「设置 › 关于 › 隐私 › 发送匿名使用统计」默认开,关掉就清空本机攒下的计数。- 只发版本、系统名和版本、CPU 架构、设置快照(取值固定,主机等数量只报档位)和功能使用次数。不发主机、凭据、命令、文件名、路径、语言,也没有任何标识(Aptabase 用 IP + UA 加每日的盐算日活)。
- 四种事件:
app_installed(analytics.json记着已报过,送达后才记)、app_started、app_active(窗口到前台且当天 UTC 还没报过,macOS 藏在后台不算)、usage(功能次数,从第一次使用起满 4 小时汇总一条,不是每次使用一条:免费额度每月 2 万个事件)。 - 不用定时器:只在启动、窗口激活、用到功能、改设置时动作,满 4 小时的汇总和失败后的重试都等下一个信号。用户明确不要定时任务,数据有误差可以接受。
- 只有发布构建发;开发构建要设
SHELLRS_ANALYTICS_DEBUG=1,发的是调试事件,汇总周期 1 分钟、失败 10 秒后重试(Pace::Debug),好在面板上马上看到。
- 编辑器:中间区的「编辑器」Dock 标签,不用系统程序打开。
- 双击、回车、F4、右键「编辑」都打开它,本地文件也行;新建文件后直接打开。
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
- 远程文件原地改写:截断后写原文件,不走
.filepart加改名。保存前核对,被改过就问「覆盖」。
- 设置 › 应用 › 窗口:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
- 终端通知:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
- 主题:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色
#1e1e1f,标题栏、标签栏、状态栏#2f2f30,边框和悬停#3b3b3d,文字#e0e0e4。用户嫌近黑刺眼。次要文字比微信的#7b7b7f亮一些(#929296),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(selected_row_color):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(DARK_HOVER_SHARE)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。 - 左栏只列浅色主题,右栏只列深色主题;卡片是名称加示例输出,选中的整张加底色,右上角是蓝底(主题的
blue)对勾。 - 只有内置的,浅色、深色各 10 套,默认 ShellRS Light / Dark(底色与界面一致),Solarized 第二,Tokyo Day / Tokyo Night 第三。同一主题的浅、深两版在两栏同一行,没有另一版的(Rosé Pine Dawn、Everforest Light、Dracula、Nord)排最后。只收宽松许可(MIT、Apache-2.0)的配色。不做自定义主题、导入、按主机指定。
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色
- 键盘快捷键:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。
- ⌘= / ⌘- / ⌘0 在终端(和它的查找条)里调终端字号(每次 1 像素,经设置写进「终端 › 字号」,范围同那里的 10–28,
FONT_SIZE_RANGE),在终端外缩放界面(基准字号 12–20,步长 2,不保存)。同主流终端,别改回去。 - 只收主要功能(含新加的下一个 / 上一个 / 切换到标签 1…9),不收 SFTP 文件列表、图片预览的键和 Enter、方向键、Tab、Esc 这类固定键。
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt 或是 F1–F24;撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
- 存在
settings.json的shortcuts,只存和默认不同的(null是禁用)。Shortcut的id一经发布不能改。
- ⌘= / ⌘- / ⌘0 在终端(和它的查找条)里调终端字号(每次 1 像素,经设置写进「终端 › 字号」,范围同那里的 10–28,
- 关键字高亮:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在
settings.json,所有终端共用。- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit
ColorPicker加#rrggbb输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。 - 总开关默认关闭。首次(文件里没有这一项)预置三条示例:ERROR 红、WARN 琥珀、IPv4 蓝,各自启用、都不通知,打开总开关就能用;用户删光后保持为空。空的新行照样保存。
- 通知同响铃:看不到那个终端才提醒,同一终端 10 秒一次,标题用备注。全屏程序(备用屏)里不高亮也不通知。
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit
- 预览:图片和 Markdown(不做 HTML),显示在快速查看式的大对话框里,不是标签。
- 双击图片预览;Markdown 和 SVG 是文本,双击编辑,右键另有「预览」。
- 图片默认适合窗口居中,可放大、缩小、看原图、滚动;比例只写百分比。
常用命令
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 它们。cc 停在 1.2.x,因为 SQL 高亮的 tree-sitter-sequel 要求 cc ~1.2。cargo test 需要 gpui-kit 的 test-support 特性,首次会再编译一遍。
本终端的 screencapture 被 macOS 权限拦截,视觉检查只能靠 UI 集成测试或让用户看窗口。
架构
单个二进制 crate,带 src/lib.rs,这样 tests/ui/ 能驱动生产环境的 Workspace。模块按能力划分:功能模块不依赖 workspace(窗口壳),也不碰彼此的内部实现。
app/:actions.rs是全部用户命令;带 id 的动作由id_actions!声明。- 另有快捷键、数据目录路径(
paths.rs)、额外的 Lucide 图标(CatalogIcon)、操作系统 logo 和品牌图标(单色 SVG)、退出守卫(quit.rs)、macOS 关窗口即隐藏(window_hiding.rs)。
host/:主机、分组、凭据、端口转发规则和命令片段的模型(它们同库、随主机级联删除,所以都放在这里,host 不依赖其他功能模块),以及:HostStore:内存是唯一事实来源,写穿到 SQLite;database.rs:SCHEMA、迁移;login.rs:登录快照HostLogin;- 主机树面板、主机和分组对话框、快速连接、
ssh_link.rs(解析堡垒机链接)。
connection.rs:问答协议(ConnectionPrompt…)、Latency、测试连接的LoginTest/ConnectionTester。ssh/:SshConnector:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
- 代理握手;外部 CLI 用的
run_command。
terminal/:本地 PTY、驱动alacritty_terminal的引擎、TerminalView(网格、选区、查找、清屏)、关键字高亮(highlight.rs)、终端字体全局量、右侧栏工具共用的ExecTarget。sftp/:可注入的传输和本地目录接口、RemotePath、RemoteFs协议适配、worker(批次、三次重连、空闲断网检测),以及:upload.rs/download.rs:断点续传,续传记录由journal.rs管;operations.rs:删除、重命名、新建、改权限;edit.rs:编辑器和预览的整文件读写。
explorer/:SFTP 标签的界面:两侧面板、列表、路径标签、各种对话框、传输队列,以及:file_edit.rs:给编辑器和预览读写文件;preview.rs/image_preview.rs:预览对话框和可缩放的图片。
editor/:编辑器标签(EditorPanel),语言、缩进和状态栏文字是model.rs的纯函数。forward/:端口转发的运行(ForwardManager、每条规则一个工作线程、SOCKS 解析)和界面。credential/:凭据的界面。monitor/、netstat/、processes/、services/、docker/、history/、snippets/:右侧栏的七个工具。各自是「命令(linux.rs等)+ 纯解析和模型(model.rs)+ 面板」,确认和通知在工作区。secrets/:SecretStore(钥匙串)、SecretRef(条目归属)、TemporarySecretStore(不保存的连接的密码只在内存)。settings/:AppSettings、写settings.json的SettingsStore、把设置落到窗口上的apply、设置标签。cli/:外部 CLI(shellrs hosts/credentials/exec/upload/download/sync,给 AI Agent 用)、套接字协议、应用里的服务端、主机和凭据改动的校验(manage.rs)、PATH 和 Agent skill 的安装。update/:在线升级,包括清单、验签、下载、各平台安装器和Updater。analytics/:匿名使用统计。model.rs是纯的计数、事件和发送时机(Reporter),system.rs用os_info读系统名和版本,client.rs是投递线程和工作区拿着的Analytics句柄;工作区的接线在workspace/analytics.rs。workspace/:窗口壳。持有 store、DockArea、各类标签的注册表和全部动作处理器,按领域拆成多个文件(forwards.rs、credentials.rs、links.rs、editors.rs、tools.rs、updates.rs…)。另有标题栏、状态栏、左侧栏Sidebar、右侧栏ToolSidebar、开始页。shared/:多个功能共用的展示片段(ClosableTabTitle、HostMark、SegmentedControl、RowTooltips、confirm_danger、form_error_notification等)。
关键约定与坑
状态与持久化
- 内存说了算,写穿。
HostStore的 mutator「改内存 → 写库 →notify」,*_unnotified是纯内存的一半(给单元测试和加载)。写失败时内存照样改,store 发PersistFailed,工作区弹错误通知,绝不静默吞掉。消费方cx.observe(&store, ..)。设置同理:写穿到settings.json(整文件替换),工作区观察后用settings::apply落到窗口上。 - 秘密只进钥匙串。
schema_never_contains_secret_columns守着这条线。SecretStore的方法是阻塞的,只在后台执行器或工作线程上调;写入只经HostStore::save_secret。SecretRef决定条目归属:密码按user@host:port,私钥口令按文件路径,密码凭据按随机的keychain_id,代理密码按代理地址加用户名,不保存的连接只在内存。- 没人再用的条目由 store 清理(
password_in_use),私钥口令从不自动删。 - 秘密不放进派生了
Debug的HostDraft/CredentialDraft。 - 对话框里的密码字段是异步预填的,
*_loaded标志没置上之前,空字段不算「用户清空了」。
- 私钥文件。 ShellRS 保存的私钥在
keys/下(目录 0700、文件 0600,经临时文件改名写入)。只有没人再用时才删这个目录下的文件;用户自己的私钥文件从不改动或删除。 - 登录快照在 UI 线程解析。 工作线程读不到 store,拿到的是
HostLogin:凭据和跳板都已查好。它只含影响登录的字段并派生PartialEq:改主机或凭据时比较前后快照,不同才发ConnectionSettingsChanged重连,所以只改名称不重连。 - id 由内存分配,四个独立计数器,重启后从库里最大 id + 1 开始,删掉的最大号会被复用。对外只给
PublicId(16 位随机串,改名、改地址都不变,不复用),不给HostId。 - 改表:现在
SCHEMA_VERSION是 14,所有表都是STRICT。- 改表要升版本、在
STEPS里加一步,并加一个「用冻结的旧 schema 升级、再和新库比对」的测试。 - 加列直接
ALTER TABLE ADD COLUMN,在SCHEMA里也写在最后一列之后。 - 库只往前升:旧版本遇到新库直接报错、不动文件。迁移前先
VACUUM INTO备份。
- 改表要升版本、在
- 窗口的大小和位置存在
window.json,不进settings.json(workspace/window_state.rs):移动、缩放时observe_window_bounds记下,停 0.5 秒再写;退出时再写一次,用 App 级的on_app_quit,因为 Windows / Linux 关窗口时工作区已经先没了。只有Workspace::new记,UI 测试不碰这个文件。- 启动时
main先读设置和window.json再开窗口,window_options按两个开关算window_bounds和display_id。 - 屏幕按
PlatformDisplay::uuid认(DisplayId重启会变)。GPUI 的窗口坐标在 macOS 上从所在屏幕左上角算,别处是整个桌面,原样存、原样交回,配上同一个display_id就能落回原处。 - macOS 的
window_bounds()从不报最大化,靠is_maximized()(大小等于屏幕可见区)判断,恢复时给可见区大小的普通窗口;别的平台用WindowBounds::Maximized。
- 启动时
- 一个数据目录只跑一个 ShellRS。 起图形界面之前先连外部 CLI 的套接字:连得上就发
Request::Activate(带启动链接)然后退出。为兼容已装的 CLI,不升PROTOCOL_VERSION,新请求只加变体。 - 临时主机在
HostStore的temporary里:host()/login()找得到,hosts()看不到,也不写库。会写外键的检查用saved_host。最后一个标签关掉就remove_temporary。
多语言
- 文案都在
locales/<模块>.yml(rust-i18n 的_version: 2):最外层直接写键的全名(host.dialog.title_new:),下面zh-CN、en并排。代码用crate::i18n::t!(返回SharedString)和tn!(带数量,填%{count}:英文数量为 1 取.one,否则.other;中文只写.other)。不用 rust-i18n / gpui-kit 自己的t!、set_locale。 - 键说用途(
host.dialog.title_new),不拿中文句子当键。一句话一个键,不拼片段:format!("无法{}{}", 动词, 对象)改成每种情况一个整句。插值写%{name};列表用i18n::join_list(「A、B 和 C」/ "A, B and C")。以%、{开头的值、含:的值和true、纯数字这类值要加引号,否则 YAML 不当它是字符串,那条文案就悄悄没了。 - 中文是源语言:改中文时同一个键的英文一起改。转换旧代码时中文输出要和原来逐字相同。
- 实时切换:语言变了,
settings::apply调i18n::set_locale、设全局量UiLocale、refresh。文案在 render 时翻译,实体里不存翻译好的字符串,存键、枚举或原始数据。gpui-kit 要预先存下的(InputState的占位、表格的列)用cx.observe_global_in::<UiLocale>重设。终端回滚里已写下的提示、已弹出的通知、已存成String的报错留在旧语言,可以接受。 - 启动:
main读完设置、建任何东西之前先set_locale;外部 CLI 的客户端按 settings.json 设,与应用一致。 - 标签和静态表:返回文案的函数返回
SharedString,不是&'static str。static表里存键,显示时t!(key);键要以完整的字面量出现在源码里,守卫测试靠它找没人用的文案。 - 英文文风按 Design Guides:sentence case;省略号用单字符
…;按钮写结果(Delete,不写 Confirm deletion);不写 Please、successfully、Are you sure;引号用 “ ” 和 ’。语言名用各自的语言写(「简体中文」「English」),这样的中文字面量所在行加// i18n: keep。 - 守卫测试在
src/i18n.rs:两种语言都齐、占位一致、英文里没有中文和...、代码里的键都有文案且文案都被用到、TRANSLATED列出的目录没有中文字面量(SHELLRS_I18N_CHECK=src/host,src/forward可临时追加目录)。 - 测试:单元测试默认就是中文。UI 夹具经
support::init_app和settings_store():本线程的语言与别的线程隔开(i18n::isolate_thread),并固定中文;切到英文的测试不影响并行的其他测试,也碰不到 gpui-kit 的全局语言。
用词表(新文案照此,改要整体改):
| 中文 | English |
|---|---|
| 主机(台) | host(1 host / 3 hosts) |
| 分组 / 地址 / 用户名 | group / address / username |
| 凭据 | credential |
| 密码 / 密钥 / 私钥 / 公钥 / 口令 | password / key / private key / public key / passphrase |
| 认证:密码 / 使用凭据 / 无密码 | Password / Credential / No password |
| 连接方式:直接连接 / SSH 跳板 / 代理连接 | Direct / Jump hosts / Proxy |
| 跳板主机 | jump host |
| 测试连接 | Test connection |
| 端口转发:本地 / 远程 / 动态;经由主机 | port forwarding: Local / Remote / Dynamic; via host |
| 临时连接… | Quick connect… |
| 开始页「快速连接」 | Find a host |
| 外部连接 | external connection |
| 最近连接 | Recent |
| 本地终端 / 远程终端 | local terminal / remote terminal |
| 传输队列 / 书签 | Transfers / Bookmarks |
| 显示隐藏文件 | Show hidden files |
| 废纸篓 | macOS 写 Trash,Windows 写 Recycle Bin |
| 右侧栏七个工具 | Snippets / History / Docker / Services / Processes / Network / Monitor |
| 设置七类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于) | Appearance / Terminal / Highlighting / Keyboard shortcuts / External CLI / General / About |
| 应用更新 / 稳定版 / 重启并安装 / 查看更新内容 | Updates / Stable / Restart to update / What’s new |
| 连接 / 断开连接 / 重新连接 | Connect / Disconnect / Reconnect |
| 已连接 / 未连接 / 连接中 | Connected / Not connected / Connecting |
GPUI / gpui-kit
- 一个命令一个处理器。 按钮、菜单、快捷键都只派发
app/actions.rs里的动作,由Workspace处理,不写临时闭包直接改状态。例外是对话框内部自己的状态(预览的缩放):工作区够不着对话框里的视图,由视图自己处理。 - 焦点。
window.dispatch_action只能到达焦点路径上的处理器,所以工作区里必须有东西持有焦点。- 永远不要聚焦工作区根节点的 handle,否则对话框的焦点陷阱拿不到焦点。
- 中间区每个面板都要在
set_active(true)里收下焦点,否则「×」、⌘W 和快捷键会静默失效。 - 对话框关掉后焦点为空:标题栏、开始页、对话框和右侧栏的按钮一律经工作区 focus handle 的
dispatch_action派发,在对话框里还要用window.defer延后。 - 要预先聚焦对话框里的字段,只能在
open_dialog返回后的同一次更新里同步调用。
- 焦点监听器里改渲染状态要
defer_in:on_focus_in等在绘制的焦点阶段执行,这时notify不排新帧。 - 后台线程不直接唤醒 GPUI 前台任务。 工作线程的事件由 UI 定时轮询:终端和 SFTP 每 16 ms,转发和右侧栏工具每 50 ms,升级每 100 ms。等回答的
oneshot在轮询的事件处理里(UI 线程)完成。 - 渲染回调不得读取实体(树行、
render_td),只传快照。 - 窗口还在搭的时候不能弹通知:
Root还没挂上,会 panic。用notify_once_open延后。 - 对话框:
- 每个
open_dialog都要.overlay_closable(false)。 - 表单校验错误一律弹
form_error_notification,并在on_close里dismiss_form_error。 - 通知层要包成
deferred、优先级 99,高过对话框,否则被压在对话框背景底下。 - 下拉框攥着焦点时,提交会把它弹开,这是 gpui-kit 的行为。
- 每个
- 右键菜单挂在容器上,不挂在行上(主机树、文件列表、开始页、进程列表)。
ContextMenu在request_layout里抢焦点,挂在uniform_list的行上会触发「set_focus called more than once」断言。行把命中的项写进一个Rc<Cell<..>>,容器在捕获阶段先清空它。行和容器不要各挂一个菜单,两个会一起弹出。 - 滚动条挂在不滚动的外层上,或直接用
overflow_y_scrollbar():挂在滚动元素自己身上,滚动条会跟着内容一起移走。 - 按上一帧的尺寸重排(路径标签、监控的竖条、图片预览的画框):render 时拿不到布局尺寸。放一个
canvas,在 prepaint 记下尺寸,变了就on_next_frame通知重画。绝对定位的canvas必须top_0().left_0()。 img会给自己的盒子加上图片的宽高比,压过百分比高度。要控制尺寸就给宽和高都写绝对值。- gpui-kit 的
Settings把选中的分类放在元素状态里,标签切走、不再绘制时就丢了,切回来回到第一类。设置页靠页头的title_suffix(只给正在显示的页绘制)记下当前分类,再经default_selected_index恢复;页内滚动位置不保留。 Select必须放进有尺寸的容器,否则会盖住整行。ListItem里会增长的文字要w_0().flex_grow(1.).overflow_x_hidden(),否则会把suffix挤出去。ColorPicker要自己给常用色(featured_colors):默认的第一行是主题色,它们也在下面的调色板里。gpui-kit 按十六进制给色块起 id,同一颜色出现两格就是重复的无障碍节点,调试版在辅助技术(或借助它的窗口管理工具)连着时直接 panic;测试平台不建无障碍树,测不出来。常用色不能取调色板(shadcn 的 stone、red … pink)里的颜色。- 要彩色就不能用
Icon:它把 SVG 当成单色遮罩画。系统徽章是「主题色圆底 + 单色 logo」。 px(..)只用在 API 要Pixels的地方,其余用 rem 助手和cx.theme()的 token。- 快捷键改了要重建整个 keymap(
app/shortcuts.rs):GPUI 不能删单条绑定,clear_key_bindings又会清掉 gpui-kit 在gpui_kit::init里注册的输入框、对话框、列表等绑定且无法重新注册。所以app::init先存 gpui-kit 绑定的快照,apply_shortcuts每次按「快照 → 可改的通用键 → 固定的通用键 → 带上下文的键(可改的、固定的)」重绑。- 同一按键上下文越深越优先,深度相同后加的赢,所以带上下文的必须排在通用的后面(终端的 ⌘K、预览的 ⌘= 靠这个)。新加固定键放进
fixed_*_bindings,可改的放进SHORTCUTS。 - 录制用
cx.intercept_keystrokes:动作在on_key_down之前就执行了,只有拦截能让 ⌘W 之类在录制时不生效。录制框是普通可聚焦 div,不用Input(中文输入法会先截字母)。 - 显示按键别写死文字,用
tooltip_with_action/Kbd从 keymap 取,否则改绑后会过期。
- 同一按键上下文越深越优先,深度相同后加的赢,所以带上下文的必须排在通用的后面(终端的 ⌘K、预览的 ⌘= 靠这个)。新加固定键放进
- 标签页全部可关。 关闭走
DockArea::remove_panel(TabGroup::close_panel拒绝关最后一个)。中间区面板的closable()返回false,否则「…」菜单会多一个绕过确认的「关闭」。标题用ClosableTabTitle,它带标签菜单,双击切换侧栏。panel_name()是持久化键,不能改。 - 左右侧栏各是一个面板。 左侧 Dock 里只有
Sidebar(三个列表),右侧 Dock 里只有ToolSidebar。不用set_dock换面板,否则焦点会丢。- 当前标签只经
Workspace::set_active_tab改,否则右侧栏不跟着变。 - 右侧 Dock 不可折叠,否则标签栏会多出 gpui-kit 的折叠按钮;关闭时临时设成可折叠。
- 最窄 320 px,由
hold_tool_sidebar_width维持。
- 当前标签只经
- 动画用 gpui-kit 的
animate_keyframes(转发示意图),不自己写播放。
SSH
- 登录。 所有登录都走
SshConnector,共享 known_hosts 写锁。known_hosts 写在<data_dir>/known_hosts,不碰~/.ssh/known_hosts。- 每次先发
none,再按LoginMethod走。 - 密码和口令先查钥匙串,查不到或被拒才弹框;被拒时不删钥匙串条目。
- 失败的说明一律经
describe_login_error,它沿错误链找真正的原因。
- 每次先发
- 算法按 OpenSSH 的默认来:加了
ecdh-sha2-nistp*,素数组交换最小 2048 位,因为用户的堡垒机是老 Java 上的 Apache SSHD。没有加 SHA-1、CBC 这些 OpenSSH 默认也不开的算法。 - 区分断网和要人处理:
is_network_error,它要显式认russh::Error::IO。只有网络类错误才自动重连。agent 的错误只格式化进文字,不挂进错误链。 - 跳板。 每台跳板开好通道就丢掉自己的
Handle,整条链靠下一跳攥着的通道流活着:最终连接一断,链依次关闭。问答写明是哪台跳板;跳板和代理的失败包一层RouteFailure。 - 外部连接只开一个通道(
shell_only)。用户的堡垒机在同一连接上多开一个通道就会结束会话,所以不做系统探测,旁路 exec 直接拒绝。以后给 SFTP 加别的通道,也要对is_external关掉。 - 右侧栏的工具在终端自己的连接上执行命令(
TerminalTransportCommand::Exec),不另起登录。- 一次一条,10 秒超时,输出最多 1 MB。
- 命令写成一行
sh -c '…',脚本里不出现单引号和!,因为要经得过 bash、zsh、fish、csh。 - 拼进命令的名字先过
valid_*。 - 各工具都有用本机
sh真跑脚本的单元测试。
- 每次连上都重探主机系统(
uname/os-release,Windows 再试cmd /c ver),结果经HostStore::set_host_os入库。往返延迟每 5 秒量一次(keepalive@openssh.com)。
SFTP
- 每个标签独立连接、独立传输引擎。 引擎一次只跑一批,其余在
TransferQueue里排队,引擎的Idle推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。 - SFTP 独立于终端。 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
- 下载严格按偏移顺序写
.filepart,因为续传只信它的长度。 - 文件操作从不跟随符号链接:删除只删链接本身,改权限跳过链接,传输原样创建链接。
- 空闲时也要发现断网:通道流包一层
WatchedStream。等待时只持有Weak,不能攥着Arc<SftpClient>,否则「断开」关不掉连接。 - 选择归面板,不归表格。
DataTable设成row_selectable(false),FilePane持有按名称保存的Selection。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。- GPUI 会把同一类型的拖动通知给所有监听者,所以框选的监听要核对是不是自己发起的。
- 快速一甩时拖动的开始和松开落在同一帧,起点仍会收到单击,用
ends_a_drag排除。 - 文件列表的快捷键按焦点路由(
ExplorerShortcut,找contains_focus的那个标签),不按最近激活的标签。
- 工具栏永远一行高,放不下的按钮整个藏起来。新标签两侧对半分,改工具栏宽度时要重算放不放得下。分栏状态归标签(
ResizableState)。读目录不挪动列表;出的问题写在窗口左下角(status-connection)。 - 断开时,对远程侧的操作要弹「SFTP 连接已断开 / 重新连接」。
- 编辑器和预览:先读后开,读写走 SFTP 标签自己的连接。
ReadFile、ReadBytes、WriteFile和List/Operate一样另起任务,和传输并行。- 文件读成功才开标签或对话框;读不了的在
refuse_open说明,远程的给「下载…」。 - 查重在工作区(
editor_for_location):已开就切过去。
- 编辑器写回:
open_replace截断写,保存前核对FileStamp(大小加修改时间)。写到一半失败是SaveFailure::Interrupted:作为 context 包在原错误外面,所以仍认得出断网;这时作废戳,下一次保存不再核对。 - 编辑器的文本规则:只认 UTF-8;所有换行都是 CRLF 才转成 LF 给编辑器、保存时换回,BOM 原样保留。⌘S 绑在
FileEditor上下文。 - 编辑器的关闭和退出:关 SFTP 标签会带走它的远程编辑器;批量关闭、关 SFTP 标签、退出时有未保存的文件,只问一次。
- 退出守卫是全局量(
app::set_quit_guard):对话框关掉后焦点为空,Quit到不了工作区的元素。Windows / Linux 点窗口的关闭按钮也走它。 - macOS 程序坞右键退出、注销关机,以及「重启并安装」都拦不住。
- 退出守卫是全局量(
- 高亮语言就是
Cargo.toml给 gpui-kit 开的tree-sitter-*特性;language_for只能给出这些名字。 - 预览的图片:
ImagePreview自己在后台解码,所以解不开时能说出来;用ImageSource::Render画,关掉时drop_image。- 缩放的四个动作由它自己处理(
ImagePreview上下文:⌘= / ⌘- 放大缩小,⌘0 原图,⌘9 适合窗口)。 - 打开后在同一次更新里把焦点交给它,快捷键才有落点。
端口转发
- 规则在 store,运行状态在
ForwardManager。 管理器订阅 store:规则删了就停,设置变了就重启。转发不计入主机的连接状态。 - 认证问答归属
PromptOwner::Forward(id, 代次),对话框开头写明是哪条转发;取消问答就等于停止。 - 发现断线靠处理器被丢弃,不靠
Handler::disconnected:russh 有时会跳过它。远程转发先连上本机目标,再accept通道。 - 本地和动态转发先监听再登录,监听器跨重连保留。停止时要
handle.disconnect。停止中的运行占着自己的位置(Link::stopping),否则重启会撞上「端口已被占用」。 - 首次登录可以问,重连不问:重连非交互,陌生主机直接答否;只对网络类错误重试。
外部 CLI、升级、发布、平台
- 外部 CLI 经正在运行的应用执行:命令本身不读钥匙串、不开数据库。
- 服务端一直监听,按「启用外部 CLI」开关拒绝。
- Unix 套接字 0600,并用
peer_cred核对 uid。 - Windows 用命名管道:带 DACL 和
FIRST_PIPE_INSTANCE;客户端以SECURITY_IDENTIFICATION打开,并核对属主;用同步的 Win32 管道;关句柄时不DisconnectNamedPipe。 - exec 和传输都不许挂住:非交互,陌生主机直接答否,问题按固定策略回答。
- 读(
list、show)走 UI 线程推过去的快照(CliServer::set_hosts);password_saved只在 show 时经CliBackend::is_saved查钥匙串。 - 改主机和凭据只能在 UI 线程做:请求线程把
CliChange放进服务端的队列后等着,工作区每 100 ms 取一条(serve_cli,和「再次打开」共用),经apply_cli_change做完、快照刷新后再回复,然后才取下一条。超时只算排队的时间:没轮到就在锁里撤回、保证没做;已经开始的一直等,免得 Agent 重试做出两份。服务端 drop 时清空队列。 cli/manage.rs先校验、解析 ID 和分组路径,再交给 store:store 的normalized会把不存在的凭据、跳板悄悄改掉,CLI 要先拒绝。主机的密码随地址变化搬到新条目时,先在后台读旧值再改 store(store 会删没人用的旧条目)。- 写库失败靠
HostStore::capture_failures收集(照常发PersistFailed),钥匙串写入用write_secret等结果,都报save_failed。 - 凭据对外的 ID 就是
keychain_id,发布后不能改。请求里的秘密用Secret,Debug不露明文。 - 协议只加变体、不升
PROTOCOL_VERSION:旧应用对新请求回 serde 的「unknown variant」,客户端改说成version_mismatch。服务端先读版本再解析请求。 exec --json的输入输出都是 JSON,输出只用 ASCII(ascii_json转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。- UI 测试不监听真实套接字,
CliIntegration默认没有路径。
- Windows:
- 发布版是 GUI 子系统,命令改由
shellrs-cli.exe承担;PATH 里放的是副本,加进用户 PATH。 main先用link_arguments认堡垒机的链接参数,认出来就不走 CLI。- Windows 只在 CI 上编译和测试:
windows.yml按paths触发,新加cfg(windows)代码的文件要加进那个列表。
- 发布版是 GUI 子系统,命令改由
- 在线升级:
new_with_services建的Updater没有服务(测试从不联网),生产路径再set_services加start。- 重启走
set_restart_path加restart。Windows 不在退出过程中起进程,交给run返回之后的start_handed_over。
- 匿名使用统计:
- 只在
Workspace::new里启动(AnalyticsServices::system),new_with_services没有;UI 测试用Analytics::new(sender)读信号。 - 计数只能是
Counter的变体,快照字段只有&'static str、开关和数量,用户输入的东西放不进去。计数的键发布后不能改,新加的要同步docs/manual.md「隐私」一节的清单。 - UI 线程只往通道里发信号;系统检测(
os_info会起uname等子进程)、HTTP、写analytics.json都在shellrs-analytics线程上,退出不冲队列。 - SSH 连上靠
TerminalEvent::Started→TerminalPanelEvent::Connected,不能在每批输出都发的StatusChanged里记;SFTP 看StateChanged时的connection_state()。外部 CLI 的请求种类由CliServer::take_usage交给serve_cli的轮询。
- 只在
- 发布走 tag
v<版本>(release.yml,需要人工批准,清单最后才上传)。CHANGELOG.md那一节由改版本号的提交写,功能提交不改。一次性准备见docs/release.md。 - macOS 上关窗口是隐藏(
hide_when_closed),⌘Q 才退出。测试平台的hide()没实现,所以这条只挂在生产路径上。
终端
- 查找条必须是终端
div的兄弟,不能是子元素,否则输入会被发给 shell;它还要.occlude()。 - 清屏只在本地模拟器里做,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的
FocusSearch深一层,所以优先。 - 终端字体走全局量
TerminalFont(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。 - 终端颜色走全局量
TerminalColors(theme.rs):settings::apply按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的background/foreground;查找高亮仍用界面的warning。主题的key存进settings.json,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。 - 界面配色由所选主题推出(
settings/app_theme.rs):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。base.*按名字换成对应的 ANSI 色,其余彩色按色相换;.light和彩色的*foreground删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,TerminalThemeSettings::app_theme)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按SOFT_DARK把每级灰换成炭灰,彩色不动。settings::apply按名字(theme_name)判断要不要重建,把结果放进Theme的浅色或深色槽再Theme::change。 - 链接:正则找出的地址和 OSC 8 标出的都算,只认 http / https(
links.rs);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的link和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。 - 鼠标上报(
mouse.rs是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走engine.write,不算用户输入。 - 关键字高亮只叠加在显示上(
highlight.rs)。snapshot()每帧对可见行把折行接成逻辑行,用regexcrate 匹配,结果写进TerminalCell.highlight,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。- 不用 alacritty 的
RegexSearch(查找、链接用的那套):它的 DFA 编不了\b,^只在第一行生效,$会碰到行尾补的空格。 - 通知在解析线程上:按
\n切开输出,在 LF 之前读光标所在的逻辑行(这时\r和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。 - 规则由
settings::apply编译成全局量TerminalHighlights,引擎observe_global后换进与解析线程共用的槽;重启运行时也要把槽传过去。 settings.json里读不了的规则单条丢掉(readable_rules),否则整个设置文件会回退成默认值。- 规则表的输入框和取色器状态归
HighlightRulesEditor(行 id 不随位置变,拖动后字段跟着走),每次改动整表写回设置;设置被别处改了才重建行。
- 不用 alacritty 的
- 同步更新(DEC 2026)要解析线程自己收尾:vte 只管攒住
\e[?2026h之后的输出,超时(150 ms)后调stop_sync是调用方的事,alacritty 自己的事件循环就是这么做的。解析线程等输出时带着截止时间(receive,用async_io的定时器),到点就画出来;输出一直不停时在advance前补查;退出、出错追加提示前也先stop_sync。否则程序死在一帧中间、连接断在一帧中间时,屏幕会一直冻住。 - OSC 52 只写不读(
Osc52::OnlyCopy),只认剪贴板c,主选择区忽略。 - 通知:OSC 9 / 777 由
terminal/notices.rs的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在workspace/notices.rs:窗口不在前台走 GPUI 的show_system_notification(tag 是terminal:local:<id>/terminal:remote:<id>,点击回调按 tag 切标签),在前台走应用内通知。shellrs::init里的set_app_identity用 bundle id,测试平台没有它就不发系统通知;macOS 上cargo run没有 bundle,看不到系统通知。
测试约定
- 一个测试程序。 UI 测试(
tests/ui/main.rs,cargo test --test ui)按功能分模块,共用的夹具和假实现在support/。只有一个模块用的助手就放在那个模块里。不要在tests/下另开测试文件:每个文件都要再链接一遍 GPUI。 - 全部注入。
Workspace::new_with_services注入终端、SFTP、本地目录、测试连接和端口转发的假实现。测试不碰用户的服务器、钥匙串(用InMemorySecretStore)、设置文件(用SettingsStore::in_memory())、真实套接字和网络。HostStore::seed()是夹具:种子主机按插入顺序编号(web-01= 1 …dev-box= 6),分组也一样(生产 = 1、测试 = 2、开发 = 3);web-01和staging-api初始已连接,并打开了终端标签。 - 元素定位。 被测试查询的元素带稳定的领域 id,自定义的
div还要加.test_support()。id 从源码或已有测试里找,这里不列。 - 不要用右键菜单驱动测试:菜单项的 id 只是序号,
PopupMenu还会让测试报「leaked handles」。直接派发菜单会派发的那个动作。测试模块里显式导入类型,use gpui_kit::*会遮蔽#[test]。 - 异步与时序。
- 动作派发和
set_active都是延迟执行的:先run_until_parked再render_frame,然后断言。 - 工作线程的事件要推进时钟(
advance_clock)或用cx.wait_for等。 - 夹具一律
set_reduce_motion(true)。 - GPUI 只给激活的窗口发焦点事件,需要时先
activate_window。 - 按上一帧尺寸重排的元素,要
simulate_next_frame才会重画。 - 对话框关掉后焦点是空的,接着派发动作前先点一下面板。
- 动作派发和
- 看不到的东西换个法子查。
- 被遮蔽的输入框读不到值,密码一律查
InMemorySecretStore。 - 按钮的禁用状态用「是否还能取得焦点」判断。
- ⌘ / Shift 单击用
modified_click。 - 测试的文字系统每个字符都是 0.6 em(真实中文是 1 em),靠文字宽度撑出的布局问题在测试里看不出来。
- 被遮蔽的输入框读不到值,密码一律查
- 单元测试起子进程一律经
crate::testing(sh、sh_accepts、no_forks()),免得子进程继承别的测试刚建的套接字。 - 测试里的二进制常量(PNG 等)用脚本生成,别手抄:抄错一个字节就解不开。