123 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)。界面文案用中文,标识符用英文。
保存的一项叫「主机」,代码里叫 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 和测试连接都照它走。详见 README.md。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),凭据带用户名:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 ~/.ssh 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 keys/ 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(CredentialKind::without_credential),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,只走这里列出的链:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(CONNECT)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(SecretRef::Proxy),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 ON DELETE SET NULL),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(dl.shellrs.com,GitHub Release 留一份,也是清单里每个包的第二个下载地址),客户端只写死 https://dl.shellrs.com/update/v1/{channel}.json,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查并下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 v版本号 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。
做 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!声明);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())渲染;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 线程解析」)、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)。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)。远程主机的传输在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(HTTPCONNECT和 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一样,解析不了的名字也算网络错误。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/— 系统钥匙串(keyring4:macOS Keychain / Windows 凭据管理器 / Secret Service)。SecretRef决定归属:密码按user@host:port(改名、复制主机都不丢,同一台机器的多个主机共用一条),私钥口令按文件路径(同一把钥匙只问一次),密码凭据的密码按凭据随机生成、永不复用的keychain_id(credential:<id>;数字 id 会被复用,删除时没删掉的旧条目不能被新凭据捡到)。SecretStore的三个方法全是阻塞的,只能在后台执行器或传输层工作线程上调用。InMemorySecretStore给测试,NoSecretStore给没有钥匙串的机器(界面据此禁用密码字段)。服务名是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;切换来源会清空口令栏,因为口令属于一把钥匙)。workspace/—workspace_view.rs持有HostStore、DockArea、按主机登记的面板注册表,以及全部动作处理器;title_bar.rs(应用名后面是侧栏的切换:show-hosts/show-forwards/show-credentials三个图标的ButtonGroup,有转发在跑时端口转发那个带数量;on_click按序号 0 / 1 / 2 派发)、sidebar.rs(左侧 Dock 唯一的面板Sidebar,见下面「侧栏是一个面板、两个列表」)、forwards.rs(impl Workspace的端口转发部分:侧栏切换、转发的动作处理器、ForwardManagerEvent的处理)、credentials.rs(impl Workspace的凭据部分:侧栏切换和凭据的新建、编辑、删除)、status_bar.rs、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_*之后还有参数就走 CLI、直接process::exit,不初始化 GPUI;没参数才起图形界面。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 字节类型(JJSON /1stdout /2stderr)+ 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与可注入的CliBackendtrait;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:UpdateFeedtrait(阻塞,只搬字节,所以假 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);Installertrait 的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、HostMark、远程终端和 SFTP 标签栏右侧的延迟LatencyLabel、close_tab_items,表单里「几选一」的分段控件SegmentedControl/Segment,表单对话框的「取消 + 提交」页脚commit_footer、校验错误的通知form_error_notification与收起它的dismiss_form_error(见下面「表单的校验错误一律弹错误通知」)、端口字段的parse_port和删除确认confirm_delete,以及远程终端和 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,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。 -
文件操作从不跟随符号链接。 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走
trashcrate 的 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,夹在列表范围内。 -
列表右键菜单挂在列表容器
("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保持默认(空菜单不显示),所以行和空白处都只弹一个菜单。菜单构造器在打开时运行,可以读面板。表格给右键行画的边框要由我们清掉(下一次左键或键盘移动时)。 -
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,idlist-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 里只有
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是 13)。 发布前重构过一次,所有表都是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遇到这些版本直接拒绝、不动文件;STEPS目前是空的,Step枚举留着给下一次改表用。以后改表:升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()):连得上外部 CLI 的套接字就说明已有实例,发一个Request::Activate后直接退出。- 服务端在版本检查和「启用外部 CLI」开关之前就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把
Shared::activation置位;请求线程够不着窗口,工作区每 200 ms 问一次CliServer::take_activation(),为真就cx.activate(true)加window.activate_window()(come_forward_when_opened_again,只在生产路径Workspace::new里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回
bad_request)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- 服务端在版本检查和「启用外部 CLI」开关之前就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把
-
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 缓存。清单格式见packaging/manifest.example.json(the_example_manifest_parses守着它和解析器一致,the_release_script_signs_what_the_client_accepts守着脚本和验签一致)。一次性准备和 secrets 见README.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_instore 调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,直到线程结束。
- 服务端每个实例都带 DACL
- 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 --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同时认两种)。 -
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 开在行的右侧。 悬停主机行 500 ms 后,行右侧显示
user@address:port,主机有备注时下面再用小一号、淡一些的字显示备注(host_tooltip/RowTooltip,不显示系统;备注最宽 20 rem、自动折行,超过 6 行省略),配色用主题反色(前景色做底、背景色做字:浅色主题黑底白字,深色主题白底黑字)。只有这一个 tooltip 这样:gpui-kit 的 tooltip 和菜单、下拉共用popover色,按钮的 tooltip 又是组件内部生成的,全局改不了。gpui-kit 能指定方位的受管 tooltip(Button::tooltip_placement那套)是 crate 私有的,普通元素的.tooltip()只会贴着鼠标弹、盖住下面的行,所以HostPanel自己持有一个gpui_kit::base::TooltipOverlay(row_tooltip,作为面板的子元素渲染,面板收起就一起消失;动画animate_row_tooltip仿 gpui-kit 自己的)。行在on_hover里带着行的边界request_show(...placement(Placement::Right)),任意鼠标按下立即hide,拖动中不显示。只有 tooltip 的当前主人才能收起它(row_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():ListItem的内容盒不是relative,不设偏移的 canvas(gpui-base 的ElementExt::on_prepaint就是这样)会落到内容下方,tooltip 随之偏下将近一行。 -
树的右键菜单挂在容器上,不挂在行上。 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(),测试里找不到轨道本身,只能量各段。主机对话框的认证方式和连接方式、凭据对话框的类型 / 私钥来源 / 算法都用它;端口转发的三张类型卡片带说明和示意图,仍是 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(标签页和关闭、开始页、标题栏、对话框的共性)、settings、sftp(SFTP 标签的面板、导航、书签、文件操作、选择)、transfer(上传、下载、拖动传输、队列)、forward、credential、update;共用的夹具和假实现在 support/:support/mod.rs 是公共的 use(pub use,所以各模块开头只写 use crate::support::*;)、种子 id 和 open_workspace / in_frame 这类通用助手,support/{connection,terminal,sftp,forward,credential}.rs 是各功能的假实现和夹具。新测试放进对应功能的模块;只有一个模块用的助手和假实现就放在那个模块里,两个以上模块用的才放进 support(项、字段和方法都要 pub)。不要在 tests/ 下另开测试文件:每个文件都是单独的测试程序,要再链接一遍整个 GPUI。Workspace::new 要求外部传入 Entity<HostStore>:生产环境由 main.rs 从数据库加载,测试用 open_workspace_with_store 注入 HostStore::seed()(或一个临时文件上的真库)。不要用右键菜单驱动测试:菜单项的 ElementId 只是序号,而且 PopupMenu 实体会让测试以「leaked handles」失败;直接 window.dispatch_action 派发菜单会派发的那个动作。测试模块里要显式导入类型,use gpui_kit::* 会遮蔽 #[test]。被测试查询的元素带稳定的领域 id,自定义 div 还要加 .test_support():("host-row", id)、("host-os", id) 与开始页的 ("recent-host-os", id)(aria_label 是系统名,未探测时是「未探测到系统」)、("group-row", id)、("terminal", id)、("explorer", explorer_id)、("local-pane"|"remote-pane", explorer_id)(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 SFTP_TAB = 1)、("close-terminal", id)、设置的 open-settings(主机面板底部)/ settings(页面)/ settings-tab / close-settings、设置页只渲染当前分类(左栏条目的 id 由 Settings 按位置生成,是 within("settings") 里的 0-<序号>,外观是 0-0、终端是 0-1、外部 CLI 是 0-2、关于是 0-3;开关是 group-<组> › item-<项> › check;外部 CLI 页的 cli-binary(安装 / 移除按钮)与 cli-binary-status、skill-<agent> 与 skill-status-<agent>(agent 为 agents / codex / claude / opencode,状态行的 aria_label 是显示的文字)、check-cli-integration、copy-agent-skill;设置项同理,下拉按钮是 group-<组> › item-<项> › btn,label 是当前选项的文字)、终端字体预览 terminal-font-preview(aria_label 形如「Menlo 13 px,行高 1.54」)、("close-explorer", id)、标签标题 ("terminal-tab", id) / ("explorer-tab", explorer_id) / ("local-terminal-tab", id)(aria_label 是标签文字)、("terminal-tab-os", id) 与 ("explorer-tab-os", explorer_id)、test-connection、("terminal-latency", id) 与 SFTP 标签的 ("sftp-latency", explorer_id)(aria_label 是「32 ms」或「超时」;SFTP 测试往 FakeSftpProvider::events 里送 SftpEvent::Latency)、terminal-find(查找输入框)、terminal-find-count(aria_label 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、terminal-find-previous / -next / -close、tab-name(重命名标签对话框)、主机行的 tooltip host-tooltip(aria_label 是 user@address:port;悬停后要 advance_clock 越过 500 ms 的显示延迟)与其下的备注 host-tooltip-notes(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 条运行中」)、status-connection、commit、ok(确认对话框)、form-error(对话框的校验错误,在错误通知里,aria_label 是错误文字)、host-name / host-address / host-port / host-user / host-password、「无密码」下面的说明 host-no-password-note、group-name、remote-path。SFTP 相关的还有:各侧面板里的 path-select、bookmarks、up、root、home、refresh、back、forward、upload(本地)/ download(远程)、delete、rename、properties、new(同名按钮在两侧都有,要用 within((pane, id)) 查)、行 file:<名称>(拖放目标;行中心是空白处,点选、双击、⌘ / Shift 单击和拖文件都要对着 name:<名称>)与名称 name:<名称>(aria_selected 表示已选中)、名称单元格 name-cell:<名称>(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 size:<名称>(框选测试从它拖到另一行的它)(aria_label 是显示的大小,目录为空)、列标题 column:<列键>、框选时的 selection-rectangle、列表为空时的占位文字 list-placeholder(aria_label 是显示的文字;假传输的 slow_home 让远程主目录永远不回来,用来测慢网)、文件拖动预览 file-drag-preview、("file-list", 0|1)、entry-name(重命名 / 新建表单)、("perm", 0..9) 与 perm-octal / perm-recursive / perm-dir-x(属性对话框)、download-confirm / download-target / download-browse、上传 / 下载确认框的首行 transfer-summary(aria_label 形如「上传 2 个文件到 …」)与来源清单的行 source:<完整路径>(aria_label 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 local-path / remote-path(aria_value 是已读入的目录,读取中不报,所以可以拿它等列表;aria_selected 表示是当前面板)与其中的 path-parts、各级 path:<目录>、「打开目录/书签」的 open-directory-path / open-directory-browse / open-directory-confirm、书签列表 bookmark-list 与行 bookmark:<路径>(aria_selected)、bookmark-add / bookmark-remove / bookmark-up / bookmark-down、列表下方的状态栏 pane-status(Role::Status,aria_label 是显示的文字)、SFTP 标签栏的 ("open-sftp", id) 与 ("reconnect-sftp", id)、窗口状态栏的 status-connection(aria_label 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 ("transfer-queue", explorer_id)(为空时不渲染)与工具栏的 resume-transfer / cancel-transfer / remove-transfer / discard-transfer / clear-finished-transfers(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 ("queue-entry", 队列序号)(aria_label 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;aria_selected 表示选中)、队首批次的进度列 transfer-status(aria_label 同上,测试用 wait_for_head_status 等它)、其余批次的 ("queue-status", 序号)、当前文件子行 ("queue-file", 序号)(aria_label 是源路径)与它的 ("queue-file-status", 序号)(百分比)、展开按钮 ("queue-expand", 序号)、展开后的 ("transfer-detail-list", 序号)、transfer-detail-empty 与 transfer-detail:<路径>、确认框里的排队提示 transfer-queued-note。假传输 FakeSftpTransport 像真引擎一样在停止和完成后发 Idle,队列靠它推进、问答的 transfer-question-* 与 transfer-apply-all。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(focused().is_some());TestWindowExt::click 不带修饰键,⌘ / Shift 单击用 modified_click 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(on_focus_in、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 window.activate_window() 再 run_until_parked;按上一帧宽度重排的元素(路径标签)要 window.simulate_next_frame(cx) 才会重画。夹具(open_workspace / open_workspace_with_store / open_workspace_with_tester,以及 SFTP、转发、凭据各自的夹具)都会 set_reduce_motion(true):对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(new_host_dialog_validates_then_inserts、renaming_a_group_keeps_the_hosts_under_it 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(ElementSnapshot::value() 返回 None),所以密码相关的断言一律查注入的 InMemorySecretStore,不查界面。动作派发和 set_active 都是延迟执行的:先退出 update_window,调用 cx.run_until_parked(),再 render_frame 后断言。端口转发相关的有:转发列表的 forward-search、forward-list、空状态 forward-empty(aria_label 是标题)与其中的 forward-empty-new / forward-empty-new-host、Dock 工具栏的 new-forward、行 ("forward-row", id)、行尾的状态 ("forward-status", id)(aria_label 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 wait_for_forward_status 等它)和启停按钮 ("forward-toggle", id);对话框的类型卡片在 within("forward-kind") 里按序号 0usize / 1usize / 2usize(RadioGroup 会把子项的 id 改成序号)、forward-diagram(aria_label 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 forward-flow-dot(在某一段线上时才存在,每圈末尾停顿时没有)、forward-explanation(aria_label 是那句说明)、卡片里的用途 ("forward-purpose", 序号)、forward-name / forward-host / forward-bind-host / forward-bind-port / forward-target-host / forward-target-port、forward-auto-start、forward-notes(有提示时才存在)。假传输 FakeForwardProvider 不开任何套接字,记录每次运行(runs(),可以往 events 里送真实转发之后才会报的事件),像真的一样用 Stopped 回应 Stop;ForwardScript 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 open_workspace_with_forwards 和 SFTP 的一样 set_reduce_motion(true),示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 set_reduce_motion(false)(the_forward_diagram_keeps_showing_its_flow 这样做,再用 advance_clock 推进并比较圆点的位置)。凭据相关的有:标题栏的 show-credentials、列表的 credential-search、credential-list、空状态 credential-empty(aria_label 是标题)与其中的 credential-empty-new / credential-empty-generate(「生成密钥…」)、Dock 工具栏的 new-credential、行 ("credential-row", id)(aria_selected 表示选中);凭据对话框的类型在 within("credential-kind") 里按序号 0usize(密码)/ 1usize(密钥)/ 2usize(SSH Agent),密钥的来源在 within("credential-key-source") 里按序号 0usize(本机文件)/ 1usize(粘贴)/ 2usize(生成新密钥),算法在 within("credential-key-algorithm") 里,字段是 credential-name / credential-user / credential-password / credential-key-path / choose-credential-key / credential-passphrase、粘贴框 credential-key-text(外面包的 div;测试里先往剪贴板写再 cmd-v),公钥 credential-public-key(aria_label 是整行公钥)与它的复制按钮 copy-public-key、生成中的 credential-public-key-pending、生成失败的 credential-generate-error,保存会删掉 ShellRS 保存的原私钥时的提示 credential-kept-key-note,加密中的 credential-saving,Agent 的说明 credential-agent-note,编辑时的用量 credential-usage(aria_label 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 store_keeping_keys 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 selected() 表示选中)在 within("host-auth-source") 里按序号 0usize(密码)/ 1usize(使用凭据)/ 2usize(无密码),凭据下拉 host-credential(value 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 host-credential-summary;连接方式在 within("host-route") 里按序号 0usize(直接连接)/ 1usize(SSH 跳板)/ 2usize(代理连接),框里的说明 host-route-note,跳板的链 host-route-chain(aria_label 形如「本机 → 阿里云99 → 当前主机」)、每台一行 ("jump-hop", 主机 id)(aria_label 是名称;已删除的是 ("jump-hop-deleted", 序号))、行尾的移除按钮 ("remove-jump-hop", 序号)、添加用的 Combobox 的触发区 jump-add-trigger(gpui-component 的 Combobox 自己的 input 没有 test_support,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,add_jump_host 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 host-proxy-kind(Select)/ host-proxy-host / host-proxy-port / host-proxy-user / host-proxy-password,最后的备注 host-notes(多行 Textarea 外面包的 div,点它再输入;回车换行,不提交对话框)。FakeConnectionTester::routes() 记下每次测试连接的 LoginRoute 和表单给的代理密码。凭据测试的夹具 open_workspace_with_credentials 关掉动效,远程终端用 RecordingRemoteProvider 记下每次连接拿到的 HostLogin(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 about-version(aria_label 是「v0.1.0」)、「当前版本」下面的状态行 update-status(aria_label 是显示的文字,测试用 wait_for_update_status 等它)、版本号右边同一时间只有一个的按钮 check-for-updates / download-update / show-update / open-download-page、「更新渠道」下拉在 group-0 › item-1 › btn(测试用 set_update_channel 写设置,不点菜单)、「自动升级」开关在 group-0 › item-2 › check;假 feed 按渠道给清单(fixture.offer(Channel::Beta, ..)),fixture.channels() 记下查过的渠道;标题栏的 update-available(aria_label 是「新版本 0.2.0 已就绪」之类);更新对话框 update-dialog 里的 update-notes(aria_label 是更新说明原文)、update-progress、update-problem、update-restart-note 和按钮 restart-to-update / download-update / open-download-page。serve_updates 给更新器装上 FakeUpdateFeed(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 FakeInstaller(只记录 stage / apply,重启路径是 /Applications/ShellRS.app),重启用 cx.expect_restart() 断言,退出时安装用 cx.update(|cx| cx.shutdown()) 触发。种子主机 id 按插入顺序(web-01=1 … dev-box=6),种子分组同理(生产=1、测试=2、开发=3);web-01 与 staging-api 初始为已连接并已打开终端标签。