Files
shell-rs/CLAUDE.md
Xuz c69d14dc4b 其余新建、编辑对话框的校验错误统一改用错误通知
- 分组、端口转发、凭据、SFTP 重命名 / 新建和「打开目录」不再在表单最下面放错误行,
  校验不过时弹 form_error_notification;同一种通知新的替换旧的,点一下收起,
  对话框关闭时由 on_close 收掉(凭据加密完后自行关闭的那条路单独收)
- 删除不再使用的 shared::form_error
- 新增测试:主机、分组、端口转发、凭据四个对话框各弹一条错误通知,关闭对话框后消失
- CLAUDE.md 写明「表单的校验错误一律弹错误通知」
2026-10-01 15:47:24 +08:00

121 KiB
Raw Permalink 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 和测试连接都照它走。详见 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(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 一样,解析不了的名字也算网络错误。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 给没有钥匙串的机器(界面据此禁用密码字段)。服务名是 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 字节类型(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、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,因为续传只信它的长度。远程文件的大小和修改时间与续传记录不符时不续传,只能重新开始或跳过。

  • 文件操作从不跟随符号链接。 删除只删链接本身,改权限跳过链接,下载 / 上传原样创建链接。本地删除走 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,夹在列表范围内。

  • 列表右键菜单挂在列表容器 ("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,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 里只有 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 的先例。 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_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 --test cli_bin),本机没有 Windows 工具链;改 pipe_windows.rs、install_windows.rs 的 Windows 部分和 cfg(windows) 代码后要看 CI。
    • 每个请求一个线程,后端方法都是阻塞的;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 初始为已连接并已打开终端标签。